Compare commits

..

11 Commits

Author SHA1 Message Date
Krrish Ghimire
ec4d030c0a update site generation flow 2026-07-12 00:10:15 +05:45
Krrish Ghimire
c362219a40 prune old sites 2026-07-11 01:28:09 +05:45
Krrish Ghimire
e684154938 add tests 2026-07-10 15:44:48 +05:45
Krrish Ghimire
e705f240d4 optimize site generation 2026-07-10 15:20:25 +05:45
Krrish Ghimire
a3f9cef883 optimize account creation and verification 2026-07-10 15:11:20 +05:45
Krrish Ghimire
bbd6e6d007 fix email issues 2026-07-10 12:02:40 +05:45
Krrish Ghimire
84316e9c53 fix ui and tests 2026-07-10 11:55:05 +05:45
Krrish Ghimire
24ca1521b4 fix timeout, cors and token issues 2026-07-10 11:39:19 +05:45
Krrish Ghimire
3e77b54d71 update readme 2026-07-08 23:25:29 +05:45
Krrish Ghimire
0b6886774e update readme 2026-07-08 23:15:40 +05:45
Krrish Ghimire
87d4acc2e2 update readme 2026-07-08 23:05:53 +05:45
50 changed files with 3237 additions and 261 deletions

444
README.md
View File

@@ -1,6 +1,6 @@
# Indie — Independent Website Platform
A platform that lets anyone create, own, and publish their own static website without social media gatekeeping. Built with a drag-and-drop builder and LLM prompt-to-site generation. Every site is backed by a git repository.
A platform that lets anyone create, own, and publish their own static website through AI prompts. Describe what you want, and the AI generates it. Every site is backed by a git repository.
---
@@ -10,9 +10,8 @@ A platform that lets anyone create, own, and publish their own static website wi
- Java 17+
- Node.js 18+ and npm
- Docker & Docker Compose (for PostgreSQL, MongoDB, MinIO)
- Angular CLI 17 (`npm install -g @angular/cli@17`)
- Gradle wrapper (included)
- Docker & Docker Compose (for PostgreSQL, MongoDB)
- npm packages: `@angular/cli@17` (`npm install -g @angular/cli@17`)
### Setup
@@ -22,6 +21,8 @@ A platform that lets anyone create, own, and publish their own static website wi
docker compose up -d
```
This starts PostgreSQL (port 5432) and MongoDB (port 27017).
2. **Start the backend:**
```bash
@@ -43,22 +44,54 @@ A platform that lets anyone create, own, and publish their own static website wi
The app will be available at `http://localhost:4200`.
API calls are proxied to `http://localhost:8080`.
### Environment Variables
---
## Infrastructure (Docker Compose)
| Service | Image | Ports | Credentials | Volume |
|------------|--------------------|--------------|--------------------------------|---------------------|
| PostgreSQL | `postgres:16-alpine` | host:5432 | `indie` / `indie_dev` | `pgdata` |
| MongoDB | `mongo:7` | host:27017 | (no auth) | `mongodata` |
---
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| **Database** | | |
| `SPRING_DATASOURCE_URL` | PostgreSQL JDBC URL | `jdbc:postgresql://localhost:5432/indie` |
| `SPRING_DATASOURCE_USERNAME` | DB username | `indie` |
| `SPRING_DATASOURCE_PASSWORD` | DB password | `indie_dev` |
| `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_ID` | Google OAuth 2.0 client ID (omit to disable Google login) | — |
| `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_SECRET` | Google OAuth 2.0 client secret | — |
| `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GITHUB_CLIENT_ID` | GitHub OAuth App client ID (omit to disable GitHub login) | — |
| `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GITHUB_CLIENT_SECRET` | GitHub OAuth App client secret | — |
| `MONGODB_URI` | MongoDB connection URI | `mongodb://localhost:27017/indie` |
| **JWT** | | |
| `INDIE_JWT_ACCESS_EXPIRATION` | Access token TTL (ms) | `900000` (15 min) |
| `INDIE_JWT_REFRESH_EXPIRATION` | Refresh token TTL (ms) | `604800000` (7 days) |
| `INDIE_JWT_PRIVATE_KEY_PATH` | RS256 private key path | `config/jwt-private.key` |
| `INDIE_JWT_PUBLIC_KEY_PATH` | RS256 public key path | `config/jwt-public.key` |
| **Git** | | |
| `INDIE_GIT_REPOS_PATH` | Git repositories root | `/var/git/sites/` |
| `INDIE_GIT_AUTHOR_NAME` | Default git author name | `Indie Platform` |
| `INDIE_GIT_AUTHOR_EMAIL` | Default git author email | `git@indie.local` |
| **Encryption** | | |
| `INDIE_ENCRYPTION_KEY_PATH` | AES-256-GCM master key path | `config/encryption.key` |
| **App** | | |
| `INDIE_APP_BASE_URL` | Frontend base URL (for verification emails) | `http://localhost:4200` |
| `INDIE_VERIFICATION_TOKEN_EXPIRATION` | Email verification token TTL (minutes) | `1440` (24h) |
| `INDIE_CORS_ORIGINS` | Allowed CORS origins (comma-separated) | `http://localhost:4200,http://localhost:3000` |
| **LLM** | | |
| `GEMINI_API_KEY` | Google Gemini API key | — |
| `GEMINI_MODEL` | Gemini model name | `gemini-2.0-flash` |
| `OPENAI_API_KEY` | OpenAI API key | — |
| `OPENAI_MODEL` | OpenAI model name | `gpt-4o-mini` |
| `INDIE_LLM_RATE_LIMIT` | Max LLM requests per hour per user | `10` |
| **Email (MailHog compatible)** | | |
| `EMAIL_HOST` | SMTP host | `localhost` |
| `EMAIL_PORT` | SMTP port | `1025` |
| `EMAIL_USERNAME` | SMTP username | _(empty)_ |
| `EMAIL_PASSWORD` | SMTP password | _(empty)_ |
| `EMAIL_SMTP_AUTH` | Enable SMTP auth | `true` |
| `EMAIL_SMTP_STARTTLS` | Enable STARTTLS | `true` |
---
@@ -70,81 +103,175 @@ backend/
├── settings.gradle # Project name
├── gradlew / gradlew.bat # Gradle wrapper
├── gradle/wrapper/ # Wrapper JAR + properties
└── src/main/
├── java/com/indie/
│ ├── IndieApplication.java # Spring Boot main class
└── src/
├── main/java/com/krrishg/
│ ├── IndieApplication.java # Spring Boot main class (@EnableScheduling)
│ ├── config/
│ │ ├── SecurityConfig.java # HTTP security, JWT filter, OAuth2 login, CORS
│ │ ├── SecurityConfig.java # HTTP security, JWT filter, CORS
│ │ ├── JwtConfig.java # RS256 token generation and validation
│ │ ── GitConfig.java # Git repository settings
│ │ ── TokenService.java # Interface for token generation/validation
│ │ ├── JwtAuthenticationToken.java # Custom AbstractAuthenticationToken
│ │ ├── UserPrincipal.java # UserDetails record
│ │ ├── GitConfig.java # Git repository settings
│ │ ├── EncryptionService.java # AES-256/GCM for LLM API key encryption
│ │ ├── RateLimitConfig.java # Bucket4j token-bucket rate limiter
│ │ ├── SchemaMigration.java # Startup DDL migrations
│ │ ├── MapToJsonConverter.java # JPA converter for JSON text columns
│ │ └── GlobalExceptionHandler.java # @ControllerAdvice error handling
│ ├── model/
│ │ ├── User.java # User entity with local + OAuth support
│ │ ├── User.java # User entity (email, password, emailVerified)
│ │ ├── VerificationToken.java # Email verification / set-password tokens
│ │ ├── Site.java # Site entity per user
│ │ ├── Page.java # Page within a site
│ │ ├── Component.java # Component on a page (heading, text, image, etc.)
│ │ ├── JsonbConverter.java # JPA converter for JSONB columns
│ │ ── *.java # Enums (AuthProvider, SiteStatus, ComponentType)
│ │ ├── SiteStatus.java # Enum: DRAFT, PUBLISHED
│ │ ├── AuthProvider.java # Enum: LOCAL only (OAuth planned)
│ │ ├── GitRemote.java # Connected git remote (GitHub/GitLab)
│ │ ── SelfHostedConfig.java # SSH/deploy configuration per site
│ │ └── GenerationVersion.java # MongoDB: LLM generation history per site
│ ├── repository/
│ │ ├── UserRepository.java
│ │ ├── SiteRepository.java
│ │ ├── PageRepository.java
│ │ ── ComponentRepository.java
│ │ ├── GitRemoteRepository.java
│ │ ── SelfHostedConfigRepository.java
│ │ └── VerificationTokenRepository.java
│ ├── dto/
│ │ ├── RegisterRequest.java
│ │ ├── RegistrationResponse.java
│ │ ├── LoginRequest.java
│ │ ├── RefreshTokenRequest.java
│ │ ├── AuthResponse.java # JWT tokens + user info
│ │ ├── SiteRequest.java
│ │ ├── VerifyEmailRequest.java
│ │ ├── VerifyEmailResponse.java
│ │ ├── ResendVerificationRequest.java
│ │ ├── SetPasswordRequest.java
│ │ ├── SiteRequest.java # Includes deploymentTarget field
│ │ ├── SiteResponse.java
│ │ ├── PageRequest.java
│ │ ── PageResponse.java
│ │ ├── SiteStructure.java # Top-level DTO: pages + theme
│ │ ── Reference.java # URL/IMAGE reference for LLM context
│ │ ├── LLMOptions.java # LLM generation options
│ │ ├── LLMResult.java # LLM generation result
│ │ ├── LLMConfigRequest.java # User LLM provider config
│ │ ├── SelfHostedConfigRequest.java
│ │ └── SelfHostedConfigResponse.java
│ ├── service/
│ │ ├── AuthService.java # Register, login, token refresh
│ │ ├── OAuth2UserService.java # Maps Google/GitHub user info to User entity
│ │ ── OAuth2SuccessHandler.java # Redirects with JWT after OAuth login
│ │ ├── AuthService.java # Register, login, token refresh, verify email
│ │ ├── EmailService.java # Verification email sender
│ │ ── CleanupService.java # Scheduled cleanup of stale unverified accounts
│ │ ├── LLMService.java # Orchestrates LLM calls, stores/restores versions
│ │ ├── LLMResponseParser.java # Parses LLM JSON into SiteStructure
│ │ ├── ReferenceService.java # Fetches web page content for LLM context
│ │ ├── RenderService.java # Renders SiteStructure into HTML + CSS + JS
│ │ ├── DeploymentService.java # Orchestrates publish/unpublish
│ │ ├── Deployer.java # Interface for self-hosted deployment
│ │ ├── SshRsyncDeployer.java # SSH + rsync + nginx + Let's Encrypt
│ │ ├── PagesProvider.java # Interface for git provider Pages
│ │ ├── GitHubPagesProvider.java # GitHub Pages implementation
│ │ ├── GitLabPagesProvider.java # GitLab Pages implementation
│ │ ├── GitService.java # JGit: init, commit, push, log, rollback
│ │ ├── KeyManager.java # SSH key-pair generation per site
│ │ ├── ExportService.java # Exports site structure to ZIP
│ │ └── DomainService.java # Custom domain resolution skeleton
│ ├── service/llm/
│ │ ├── LLMProvider.java # Interface for LLM providers
│ │ ├── GeminiProvider.java # Google Gemini implementation
│ │ ├── OpenAIProvider.java # OpenAI implementation
│ │ └── ProviderFactory.java # Factory creating providers by name + key
│ ├── engine/
│ │ ├── JsBundle.java # Generates static JS (nav, smooth scroll, lightbox, contact form)
│ │ └── StyleCompiler.java # Generates base CSS with custom property fallbacks
│ └── controller/
│ ├── AuthController.java # POST /api/auth/register, /login, /refresh, /logout
│ ├── AuthController.java # POST /api/auth/*
│ ├── SiteController.java # CRUD /api/sites
── PageController.java # CRUD /api/sites/{siteId}/pages
└── resources/
└── application.yml # All configuration
── LLMController.java # POST /api/llm/generate, /refine, /versions/*
│ ├── GitController.java # POST /api/sites/{id}/git/*
├── DeploymentController.java # POST /api/sites/{id}/publish, /unpublish
│ ├── ExportController.java # GET /api/sites/{id}/export
│ ├── UserSettingsController.java # GET/PUT/DELETE /api/user/llm-config
│ └── SelfHostedController.java # CRUD /api/sites/{id}/self-hosted
├── main/resources/
│ ├── application.yml # All configuration
│ └── prompts/
│ ├── site-generator.txt # LLM system prompt for site generation
│ └── site-refiner.txt # LLM system prompt for refinement
└── test/java/com/krrishg/
├── config/ # 7 config tests
├── controller/ # 9 controller tests
├── dto/ # 7 DTO tests
├── engine/ # 2 engine tests
├── model/ # 7 model tests
├── repository/ # 4 repository tests
├── service/ # 16 service tests + fakes
├── service/llm/ # 3 LLM provider tests
└── support/ # 13 test fakes, stubs, and configs
frontend/
├── Dockerfile # Multi-stage production build
├── nginx.conf # Nginx config (SPA fallback + /api/ proxy)
├── proxy.conf.json # Dev proxy to backend
├── angular.json # Angular CLI configuration
├── tailwind.config.js # Tailwind CSS configuration
├── postcss.config.js # PostCSS (tailwindcss + autoprefixer)
├── package.json
── src/
├── index.html
├── styles.css # Global styles + Tailwind imports
── app/
├── app.component.ts # Root component
├── app.config.ts # App providers
├── app.routes.ts # Lazy-loaded routes
── core/
├── models/
│ │ ├── auth-response.ts # AuthResponse & UserInfo interfaces
├── site.ts # SiteResponse interface
── page.ts # PageResponse interface
│ ├── services/
│ │ ├── auth.service.ts # Auth HTTP + token storage
│ │ ├── site.service.ts # Site CRUD HTTP
│ │ ── page.service.ts # Page CRUD HTTP
│ ├── guards/
│ │ └── auth.guard.ts # Route guard (redirects to /login)
── interceptors/
── jwt.interceptor.ts # Attaches Bearer token
├── auth/
│ ├── login/
│ │ ── login.component.ts # Email/password login form
└── register/
└── register.component.ts # Name/email/password register form
└── dashboard/
├── dashboard.component.ts # Site cards grid + toolbar
── create-site-dialog/
│ └── create-site-dialog.ts # Create site dialog
└── delete-site-dialog/
└── delete-site-dialog.ts # Confirm delete dialog
── tsconfig.json / tsconfig.app.json / tsconfig.spec.json
├── src/
├── index.html
── main.ts # Angular bootstrap
├── styles.css # Global styles + Tailwind imports
├── favicon.ico
├── assets/
── app/
├── app.component.ts / .html / .css
├── app.config.ts # App providers
├── app.routes.ts # Lazy-loaded routes
── core/
│ ├── models/
│ │ ├── auth-response.ts # AuthResponse & UserInfo
│ │ ├── site.ts # SiteResponse
│ │ ── page.ts # PageResponse
│ ├── llm.ts # LLM request/response types, GenerationVersion
│ │ └── deployment.ts # Git/self-hosted config types
── services/
── auth.service.ts # Auth HTTP + token storage
│ │ ├── site.service.ts # Site CRUD HTTP
│ ├── page.service.ts # Page CRUD HTTP
│ │ ── deployment.service.ts # Export, git, publish HTTP
│ ├── llm-api.service.ts # LLM generate/refine/version HTTP
└── llm-config.service.ts # User LLM key storage HTTP
│ ├── guards/
├── auth.guard.ts # Redirects unauthenticated to /login
── login.guard.ts # Redirects authenticated to /dashboard
│ └── interceptors/
└── jwt.interceptor.ts # Attaches Bearer token
├── auth/
│ │ ├── login/
│ │ │ └── login.component.ts
│ │ ├── register/
│ │ │ └── register.component.ts
│ │ └── verify-email/
│ │ └── verify-email.component.ts # Email verification + set-password
│ ├── dashboard/
│ │ ├── dashboard.component.ts # Site cards grid + toolbar
│ │ ├── create-site-dialog/
│ │ │ └── create-site-dialog.ts
│ │ └── delete-site-dialog/
│ │ └── delete-site-dialog.ts
│ └── llm-workspace/
│ ├── llm-workspace.component.ts # Main workspace (prompt, preview, timeline)
│ ├── llm-session.service.ts # LLM generation session state
│ ├── settings/
│ │ └── settings.component.ts # LLM provider key/dialog settings
│ ├── git-settings/
│ │ └── git-settings.component.ts # Git remote connection settings
│ ├── preview-dialog/
│ │ └── preview-dialog.component.ts # Preview generated site
│ └── delete-version-dialog/
│ └── delete-version-dialog.ts # Confirm delete version
config/ # Auto-generated on first boot
├── jwt-private.key # RS256 private key
├── jwt-public.key # RS256 public key
└── encryption.key # AES-256-GCM master key (base64)
```
---
@@ -155,30 +282,79 @@ frontend/
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/auth/register` | Register with email + password |
| POST | `/api/auth/register` | Register with name + email + password (returns verification pending) |
| POST | `/api/auth/login` | Login with email + password |
| POST | `/api/auth/refresh` | Refresh access token |
| POST | `/api/auth/logout` | Logout |
| POST | `/api/auth/verify-email` | Verify email via token, returns setup token |
| POST | `/api/auth/resend-verification` | Resend verification email |
| POST | `/api/auth/set-password` | Set password after email verification |
### Sites (`/api/sites`)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/sites` | List user's sites |
| POST | `/api/sites` | Create a new site |
| POST | `/api/sites` | Create a new site (with `deploymentTarget`: `GIT_PAGES`, `SELF_HOSTED`, or `NONE`) |
| GET | `/api/sites/{id}` | Get site details |
| PUT | `/api/sites/{id}` | Update site |
| DELETE | `/api/sites/{id}` | Delete site |
### Pages (`/api/sites/{siteId}/pages`)
### LLM Generation (`/api/llm`)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/sites/{siteId}/pages` | List pages for a site |
| POST | `/api/sites/{siteId}/pages` | Create a new page |
| GET | `/api/sites/{siteId}/pages/{pageId}` | Get page details |
| PUT | `/api/sites/{siteId}/pages/{pageId}` | Update page |
| DELETE | `/api/sites/{siteId}/pages/{pageId}` | Delete page |
| POST | `/api/llm/generate` | Generate site from prompt (rate-limited to 10/hour/user) |
| POST | `/api/llm/refine` | Refine existing site structure with follow-up prompt |
| GET | `/api/llm/versions/{siteId}` | List generation versions for a site |
| POST | `/api/llm/versions/{id}/restore` | Restore a specific generation version |
| DELETE | `/api/llm/versions/{id}` | Delete a generation version |
### Git Remotes (`/api/sites/{id}/git`)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/sites/{id}/git/connect` | Connect git remote (GitHub/GitLab) |
| POST | `/api/sites/{id}/git/disconnect` | Disconnect git remote |
| GET | `/api/sites/{id}/git/status` | Get connection status, SSH keys, commit log |
### Deployment (`/api/sites/{id}`)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/sites/{id}/publish` | Publish site (git push) |
| POST | `/api/sites/{id}/unpublish` | Unpublish site |
| GET | `/api/sites/{id}/export` | Download site as `.zip` archive |
### Self-Hosted Deployment (`/api/sites/{id}/self-hosted`)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/sites/{id}/self-hosted` | Get SSH/deploy configuration |
| PUT | `/api/sites/{id}/self-hosted` | Save SSH/deploy configuration |
| DELETE | `/api/sites/{id}/self-hosted` | Remove configuration |
### User Settings (`/api/user`)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/user/llm-config` | Get saved LLM provider configs (keys redacted) |
| PUT | `/api/user/llm-config` | Save API key / base URL / model for a provider |
| DELETE | `/api/user/llm-config/{provider}` | Delete saved config for a provider |
---
## Frontend Routes
| Path | Component | Auth |
|------|-----------|------|
| `/login` | LoginComponent | Redirects to /dashboard if already logged in |
| `/register` | RegisterComponent | Redirects to /dashboard if already logged in |
| `/verify-email` | VerifyEmailComponent | Public (email verification flow) |
| `/dashboard` | DashboardComponent | Requires auth |
| `/llm/:siteId` | LlmWorkspaceComponent | Requires auth |
| `/llm/:siteId/settings` | SettingsComponent | Requires auth |
| `/llm/:siteId/git` | GitSettingsComponent | Requires auth |
---
@@ -186,44 +362,142 @@ frontend/
| Layer | Technology |
|-------|-----------|
| Backend | Java 17, Spring Boot 3.2, Gradle |
| Frontend | Angular 17, Angular Material, Tailwind CSS |
| Backend | Java 17, Spring Boot 3.2, Gradle (Groovy) |
| Frontend | Angular 17, Angular Material, Tailwind CSS, Leaflet |
| Relational DB | PostgreSQL 16 |
| Document DB | MongoDB 7 |
| Object Storage | MinIO (dev) / Cloudflare R2 (prod) |
| Auth | Spring Security + OAuth2 Client + JWT (RS256) |
| Git | JGit |
| Document DB | MongoDB 7 (generation versions, LLM sessions) |
| Auth | Spring Security + JWT (RS256), email/password |
| LLM | Gemini (primary), OpenAI (fallback), provider-abstraction |
| Git | JGit — local repo per site, GitHub/GitLab remote push |
| Email | Spring Boot Mail (MailHog compatible) |
| Rate Limiting | Bucket4j |
| API Docs | Springdoc OpenAPI (Swagger UI) |
---
## Architecture
```
┌──────────────────────┐
│ Spring Boot API │
│ (VPS / Docker) │
└────────┬─────────────┘
┌──────────────┼──────────────┐
│ │ │
┌────────▼───┐ ┌──────▼──────┐ ┌───▼────────┐
│ PostgreSQL │ │ MongoDB │ │ Git Repos │
│ (users, │ │ (versions, │ │ /var/git/ │
│ sites) │ │ sessions) │ └────────────┘
└─────────────┘ └─────────────┘
```
---
## Development
```bash
# Backend
cd backend
./gradlew build # Build
./gradlew test # Run tests
./gradlew test # Run tests (unit + slice + integration)
./gradlew bootRun # Run locally (requires docker compose up -d first)
# Frontend
cd frontend
npm install # Install dependencies
ng serve # Dev server at localhost:4200
ng build # Production build
ng build # Production build (outputs to dist/)
ng test # Run unit tests (Jasmine + Karma)
```
### Production Build (Docker)
```bash
docker build -t indie-frontend frontend/
docker run -p 80:80 indie-frontend
```
The frontend Docker image serves the app via nginx (port 80) and proxies `/api/` requests to `http://backend:8080`.
---
## Build Status
## Roadmap
### v0.1 — Auth & Foundation ✅
- [x] User registration (email/password)
- [x] User registration (email/password + email verification)
- [x] User login (email/password + JWT)
- [x] OAuth2 login (Google, GitHub) with JWT redirect
- [x] Token refresh with 7-day refresh token
- [x] Site CRUD (create, list, get, update, delete)
- [x] Page CRUD within a site
- [x] Frontend: Login, Register, OAuth callback pages
- [x] Frontend: Dashboard with site cards, create/delete dialogs
- [x] Frontend: JWT interceptor, auth guard, lazy routes
- [x] Frontend: Login, Register, Verify Email, Dashboard
- [x] Frontend: JWT interceptor, auth guard, login guard, lazy routes
- [x] Scheduled cleanup of unverified accounts
### v0.2 — LLM Prompt Workspace ✅
- [x] Prompt-to-site generation via Gemini/OpenAI
- [x] Version timeline with MongoDB storage
- [x] Live preview (sandboxed iframe)
- [x] Content editor (grouped by page)
- [x] Refine via follow-up prompts
- [x] Rate limiting (10 generations/hour/user)
- [x] Reference URL support for context
### v0.3 — Export, Git & Hosting ✅
- [x] Render engine (HTML + CSS + JS generation)
- [x] Git init, commit, push via JGit
- [x] SSH key-pair management per site
- [x] GitHub Pages / GitLab Pages providers
- [x] Export as `.zip` archive
- [x] Publish/unpublish flow
- [x] Custom domain skeleton
- [x] Self-hosted deployment (SSH + rsync + nginx)
---
## LLM Provider Abstraction
```java
public interface LLMProvider {
LLMResult generate(String systemPrompt, String userPrompt,
List<Reference> references, LLMOptions options);
String getName();
}
```
Adding a new provider = implement `LLMProvider` interface + add config block in `application.yml`.
Provider API keys can be configured globally via env vars (`GEMINI_API_KEY`, `OPENAI_API_KEY`) or per-user through the `/api/user/llm-config` endpoint (encrypted at rest via AES-256-GCM).
---
## Key Design Decisions
1. **Pure prompt-based** — No drag-and-drop builder. Every site starts from a prompt. Iteration happens through follow-up prompts and inline text edits.
2. **Static output only** — Generated sites are pure HTML/CSS/JS. No runtime framework dependency. Users own the output forever.
3. **Git as source of truth** — Every site is a git repository. Publish = commit. Rollback = checkout. Export includes full `.git/`.
4. **Version timeline** — Every generation creates a version in MongoDB. Users can browse, preview, and restore any previous version.
5. **No billing in MVP** — Monetization is post-MVP.
---
## config/ Directory
The following files live in `config/` and are auto-generated on first boot if they don't exist.
In production, generate and mount them manually:
```bash
# RS256 key pair for JWT
openssl genpkey -algorithm RSA -out config/jwt-private.key -pkeyopt rsa_keygen_bits:2048
openssl rsa -pubout -in config/jwt-private.key -out config/jwt-public.key
# AES-256-GCM master key for LLM API key encryption
openssl rand -base64 32 > config/encryption.key
```
Set the `INDIE_JWT_PRIVATE_KEY_PATH`, `INDIE_JWT_PUBLIC_KEY_PATH`, and
`INDIE_ENCRYPTION_KEY_PATH` env vars to point to these files.

View File

@@ -1,6 +1,7 @@
package com.krrishg.config;
import io.jsonwebtoken.JwtException;
import org.apache.catalina.connector.ClientAbortException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
@@ -35,6 +36,13 @@ public class GlobalExceptionHandler {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, message);
}
@ExceptionHandler(ClientAbortException.class)
public void handleClientAbort(ClientAbortException ex) {
if (log.isDebugEnabled()) {
log.debug("Client disconnected: {}", ex.getMessage());
}
}
@ExceptionHandler(Exception.class)
public ProblemDetail handleGeneral(Exception ex) {
log.error("Unhandled exception", ex);

View File

@@ -5,7 +5,6 @@ import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
@@ -13,7 +12,6 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@@ -33,9 +31,6 @@ public class SecurityConfig {
private final JwtConfig jwtConfig;
@Value("${indie.cors.allowed-origins:http://localhost:4200}")
private String[] allowedOrigins;
public SecurityConfig(JwtConfig jwtConfig) {
this.jwtConfig = jwtConfig;
}
@@ -72,7 +67,7 @@ public class SecurityConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(allowedOrigins));
config.setAllowedOriginPatterns(List.of("*"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);

View File

@@ -0,0 +1,40 @@
package com.krrishg.config;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.Refill;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class SuggestRateLimitConfig {
private final Map<UUID, Bucket> cache = new ConcurrentHashMap<>();
private final int maxRequestsPerHour;
public SuggestRateLimitConfig(
@Value("${indie.llm.suggest-rate-limit.max-requests-per-hour:30}") int maxRequestsPerHour) {
this.maxRequestsPerHour = maxRequestsPerHour;
}
public boolean tryConsume(UUID userId) {
Bucket bucket = cache.computeIfAbsent(userId, this::createBucket);
return bucket.tryConsume(1);
}
public int getAvailableTokens(UUID userId) {
Bucket bucket = cache.computeIfAbsent(userId, this::createBucket);
return (int) bucket.getAvailableTokens();
}
private Bucket createBucket(UUID userId) {
Bandwidth limit = Bandwidth.classic(maxRequestsPerHour,
Refill.greedy(maxRequestsPerHour, Duration.ofHours(1)));
return Bucket.builder().addLimit(limit).build();
}
}

View File

@@ -1,9 +1,11 @@
package com.krrishg.controller;
import com.krrishg.config.RateLimitConfig;
import com.krrishg.config.SuggestRateLimitConfig;
import com.krrishg.config.UserPrincipal;
import com.krrishg.dto.Reference;
import com.krrishg.dto.SiteStructure;
import com.krrishg.dto.SuggestSectionsResponse;
import com.krrishg.model.GenerationVersion;
import com.krrishg.service.LLMService;
import com.fasterxml.jackson.core.type.TypeReference;
@@ -23,11 +25,14 @@ public class LLMController {
private final LLMService llmService;
private final RateLimitConfig rateLimitConfig;
private final SuggestRateLimitConfig suggestRateLimitConfig;
private final ObjectMapper objectMapper;
public LLMController(LLMService llmService, RateLimitConfig rateLimitConfig, ObjectMapper objectMapper) {
public LLMController(LLMService llmService, RateLimitConfig rateLimitConfig,
SuggestRateLimitConfig suggestRateLimitConfig, ObjectMapper objectMapper) {
this.llmService = llmService;
this.rateLimitConfig = rateLimitConfig;
this.suggestRateLimitConfig = suggestRateLimitConfig;
this.objectMapper = objectMapper;
}
@@ -72,7 +77,41 @@ public class LLMController {
SiteStructure structure = llmService.generateSite(
principal.id(), siteId, prompt, references, provider, apiKey, baseUrl, model);
return ResponseEntity.ok(structure);
} catch (IllegalStateException e) {
} catch (IllegalStateException | IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@PostMapping("/suggest-sections")
public ResponseEntity<?> suggestSections(
@AuthenticationPrincipal UserPrincipal principal,
@RequestBody Map<String, Object> request) {
if (!suggestRateLimitConfig.tryConsume(principal.id())) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(Map.of("error", "Suggest rate limit exceeded. Max " +
suggestRateLimitConfig.getAvailableTokens(principal.id()) +
" requests per hour."));
}
String briefDescription = (String) request.get("briefDescription");
if (briefDescription == null || briefDescription.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "briefDescription is required"));
}
String provider = (String) request.get("provider");
if (provider == null || provider.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "provider is required"));
}
String apiKey = (String) request.get("apiKey");
String baseUrl = (String) request.get("baseUrl");
String model = (String) request.get("model");
try {
SuggestSectionsResponse response = llmService.suggestSections(
principal.id(), briefDescription, provider, apiKey, baseUrl, model);
return ResponseEntity.ok(response);
} catch (IllegalStateException | IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@@ -130,7 +169,7 @@ public class LLMController {
SiteStructure structure = llmService.refineSite(principal.id(), siteId,
currentStructure, prompt, references, provider, apiKey, baseUrl, model);
return ResponseEntity.ok(structure);
} catch (IllegalStateException e) {
} catch (IllegalStateException | IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@@ -162,4 +201,17 @@ public class LLMController {
return ResponseEntity.notFound().build();
}
}
@PostMapping("/versions/{siteId}/prune")
public ResponseEntity<?> pruneVersions(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID siteId,
@RequestBody(required = false) Map<String, Object> request) {
int keep = 10;
if (request != null && request.containsKey("keep")) {
keep = ((Number) request.get("keep")).intValue();
}
int deleted = llmService.pruneVersions(siteId, keep);
return ResponseEntity.ok(Map.of("deleted", deleted));
}
}

View File

@@ -13,7 +13,7 @@ public class LLMOptions {
private double temperature = 0.9;
@Builder.Default
private int maxTokens = 4096;
private int maxTokens = 16384;
@Builder.Default
private int timeoutSeconds = 60;

View File

@@ -2,7 +2,6 @@ package com.krrishg.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
@@ -11,11 +10,4 @@ public class RegisterRequest {
@NotBlank(message = "Email is required")
@Email(message = "Invalid email format")
private String email;
@NotBlank(message = "Password is required")
@Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters")
private String password;
@NotBlank(message = "Name is required")
private String name;
}

View File

@@ -16,4 +16,7 @@ public class SetPasswordRequest {
@NotBlank
@Size(min = 8, max = 100)
private String password;
@NotBlank(message = "Name is required")
private String name;
}

View File

@@ -35,5 +35,6 @@ public class SiteStructure {
private String seoDescription;
private int orderIndex;
private String rawHtml;
private boolean deleted;
}
}

View File

@@ -0,0 +1,9 @@
package com.krrishg.dto;
public record SuggestSectionsRequest(
String briefDescription,
String provider,
String apiKey,
String baseUrl,
String model
) {}

View File

@@ -0,0 +1,19 @@
package com.krrishg.dto;
import java.util.List;
public record SuggestSectionsResponse(
List<SectionSuggestion> sections
) {
public record SectionSuggestion(
String title,
String description,
String slug,
List<PatternSuggestion> patterns
) {}
public record PatternSuggestion(
String title,
String description
) {}
}

View File

@@ -29,7 +29,7 @@ public class User {
@Column(name = "password")
private String password;
@Column(name = "name", nullable = false)
@Column(name = "name")
private String name;
@Column(name = "avatar_url")

View File

@@ -69,8 +69,6 @@ public class AuthService {
User user = User.builder()
.email(email)
.password(passwordEncoder.encode(request.getPassword()))
.name(request.getName())
.emailVerified(false)
.build();
@@ -88,12 +86,12 @@ public class AuthService {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("Invalid email or password"));
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new IllegalArgumentException("Invalid email or password");
if (!user.isEmailVerified() || user.getPassword() == null) {
throw new IllegalArgumentException("Please verify your email address before logging in");
}
if (!user.isEmailVerified()) {
throw new IllegalArgumentException("Please verify your email address before logging in");
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new IllegalArgumentException("Invalid email or password");
}
return generateAuthResponse(user);
@@ -174,6 +172,7 @@ public class AuthService {
User user = userRepository.findById(token.getUserId())
.orElseThrow(() -> new IllegalArgumentException("User not found"));
user.setName(request.getName());
user.setPassword(passwordEncoder.encode(request.getPassword()));
userRepository.save(user);

View File

@@ -16,15 +16,19 @@ public class EmailService {
private final JavaMailSender mailSender;
private final String baseUrl;
private final String from;
public EmailService(JavaMailSender mailSender,
@Value("${indie.app.base-url}") String baseUrl) {
@Value("${indie.app.base-url}") String baseUrl,
@Value("${spring.mail.from}") String from) {
this.mailSender = mailSender;
this.baseUrl = baseUrl;
this.from = from;
}
public void sendVerificationEmail(String to, String name, String token) {
String link = baseUrl + "/verify-email?token=" + token;
String displayName = name != null ? name : to.substring(0, to.indexOf('@'));
String subject = "Verify your Indie account";
String html = """
<!DOCTYPE html>
@@ -58,7 +62,7 @@ public class EmailService {
</table>
</body>
</html>
""".formatted(name, link);
""".formatted(displayName, link);
sendHtml(to, subject, html);
}
@@ -67,6 +71,7 @@ public class EmailService {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(from);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(html, true);

View File

@@ -31,7 +31,17 @@ public class LLMResponseParser {
String json = extractJson(llmOutput);
try {
JsonNode root = objectMapper.readTree(json);
return parseSiteStructure(root);
return parseSiteStructure(root, true);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Failed to parse LLM output as JSON: " + e.getMessage());
}
}
public SiteStructure parseLenient(String llmOutput) {
String json = extractJson(llmOutput);
try {
JsonNode root = objectMapper.readTree(json);
return parseSiteStructure(root, false);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Failed to parse LLM output as JSON: " + e.getMessage());
}
@@ -69,7 +79,7 @@ public class LLMResponseParser {
return trimmed;
}
private SiteStructure parseSiteStructure(JsonNode root) {
private SiteStructure parseSiteStructure(JsonNode root, boolean requirePages) {
SiteStructure.SiteStructureBuilder builder = SiteStructure.builder();
if (root.has("theme")) {
@@ -79,13 +89,13 @@ public class LLMResponseParser {
List<SiteStructure.PageStructure> pages = new ArrayList<>();
if (root.has("pages") && root.get("pages").isArray()) {
for (JsonNode pageNode : root.get("pages")) {
pages.add(parsePage(pageNode));
pages.add(parsePage(pageNode, !requirePages));
}
}
builder.pages(pages);
SiteStructure structure = builder.build();
if (structure.getPages() == null || structure.getPages().isEmpty()) {
if (requirePages && (structure.getPages() == null || structure.getPages().isEmpty())) {
throw new IllegalArgumentException("LLM output must contain at least one page");
}
@@ -118,7 +128,7 @@ public class LLMResponseParser {
return map;
}
private SiteStructure.PageStructure parsePage(JsonNode pageNode) {
private SiteStructure.PageStructure parsePage(JsonNode pageNode, boolean lenient) {
SiteStructure.PageStructure.PageStructureBuilder builder = SiteStructure.PageStructure.builder();
if (pageNode.has("id")) {
@@ -145,9 +155,12 @@ public class LLMResponseParser {
if (pageNode.has("rawHtml")) {
builder.rawHtml(pageNode.get("rawHtml").asText());
}
if (pageNode.has("deleted")) {
builder.deleted(pageNode.get("deleted").asBoolean());
}
SiteStructure.PageStructure page = builder.build();
if (page.getTitle() == null || page.getSlug() == null) {
if (!lenient && !page.isDeleted() && (page.getTitle() == null || page.getSlug() == null)) {
throw new IllegalArgumentException("Page must have title and slug");
}

View File

@@ -5,12 +5,16 @@ import com.krrishg.dto.LLMOptions;
import com.krrishg.dto.LLMResult;
import com.krrishg.dto.Reference;
import com.krrishg.dto.SiteStructure;
import com.krrishg.dto.SuggestSectionsResponse;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.ResourceAccessException;
import com.krrishg.model.GenerationVersion;
import com.krrishg.model.User;
import com.krrishg.repository.UserRepository;
import com.krrishg.service.llm.LLMProvider;
import com.krrishg.service.llm.ProviderFactory;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -28,8 +32,12 @@ import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@@ -37,7 +45,6 @@ import java.util.stream.Collectors;
public class LLMService {
private static final Logger log = LoggerFactory.getLogger(LLMService.class);
private static final int MAX_VERSIONS_PER_SITE = 100;
private final ProviderFactory providerFactory;
private final LLMResponseParser responseParser;
@@ -47,6 +54,10 @@ public class LLMService {
private final EncryptionService encryptionService;
private final String systemPrompt;
private final String refinerPrompt;
private final String suggestSectionsPrompt;
private final ObjectMapper objectMapper;
private final int maxVersionsPerSite;
private final double refineTemperature;
public LLMService(ProviderFactory providerFactory,
LLMResponseParser responseParser,
@@ -56,7 +67,10 @@ public class LLMService {
EncryptionService encryptionService,
ResourceLoader resourceLoader,
@Value("${indie.llm.system-prompt-path:classpath:prompts/site-generator.txt}") String promptPath,
@Value("${indie.llm.refiner-prompt-path:classpath:prompts/site-refiner.txt}") String refinerPath) {
@Value("${indie.llm.refiner-prompt-path:classpath:prompts/site-refiner.txt}") String refinerPath,
@Value("${indie.llm.suggest-sections-prompt-path:classpath:prompts/suggest-sections.txt}") String suggestSectionsPath,
@Value("${indie.llm.max-versions-per-site:10}") int maxVersionsPerSite,
@Value("${indie.llm.refine.temperature:0.3}") double refineTemperature) {
this.providerFactory = providerFactory;
this.responseParser = responseParser;
this.referenceService = referenceService;
@@ -65,6 +79,10 @@ public class LLMService {
this.encryptionService = encryptionService;
this.systemPrompt = loadSystemPrompt(resourceLoader, promptPath);
this.refinerPrompt = loadSystemPrompt(resourceLoader, refinerPath);
this.suggestSectionsPrompt = loadSystemPrompt(resourceLoader, suggestSectionsPath);
this.objectMapper = new ObjectMapper();
this.maxVersionsPerSite = maxVersionsPerSite;
this.refineTemperature = refineTemperature;
}
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt,
@@ -97,6 +115,35 @@ public class LLMService {
return structure;
}
public SuggestSectionsResponse suggestSections(UUID userId, String briefDescription,
String provider, String apiKey) {
return suggestSections(userId, briefDescription, provider, apiKey, null, null);
}
public SuggestSectionsResponse suggestSections(UUID userId, String briefDescription,
String provider, String apiKey, String baseUrl) {
return suggestSections(userId, briefDescription, provider, apiKey, baseUrl, null);
}
public SuggestSectionsResponse suggestSections(UUID userId, String briefDescription,
String provider, String apiKey, String baseUrl, String model) {
String resolvedKey = resolveApiKey(userId, provider, apiKey);
String resolvedBaseUrl = resolveBaseUrl(userId, provider, baseUrl);
String resolvedModel = resolveModel(userId, provider, model);
LLMOptions options = LLMOptions.builder()
.temperature(0.2)
.maxTokens(2000)
.build();
LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel,
suggestSectionsPrompt, briefDescription, null, options);
log.info("Section suggestions completed: model={}, latency={}ms, tokens={}+{}",
result.getModel(), result.getLatencyMs(),
result.getPromptTokens(), result.getCompletionTokens());
return parseSectionSuggestions(result.getContent());
}
public SiteStructure refineSite(UUID userId, UUID siteId, SiteStructure currentStructure,
String refinementPrompt, List<Reference> references,
String provider, String apiKey) {
@@ -120,29 +167,54 @@ public class LLMService {
String combinedPrompt = "Current site structure:\n" + currentJson
+ "\n\nRefinement request: " + refinementPrompt
+ referenceContext
+ "\n\nOutput the complete updated JSON structure with the requested changes applied.";
+ "\n\nOutput only the fields that changed. Fields you omit will remain unchanged."
+ " Include the \"id\" field for any page you modify or add."
+ " To delete a page, include it with \"id\" and \"deleted\": true."
+ " Pages not included in your output will stay as-is.";
LLMOptions options = LLMOptions.builder()
.temperature(0.5)
.temperature(refineTemperature)
.build();
LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel, refinerPrompt, combinedPrompt, references, options);
log.info("LLM refinement completed: model={}, latency={}ms, tokens={}+{}",
result.getModel(), result.getLatencyMs(),
result.getPromptTokens(), result.getCompletionTokens());
SiteStructure structure = responseParser.parse(result.getContent());
saveVersion(userId, siteId, refinementPrompt, references, result.getContent());
SiteStructure patch = responseParser.parseLenient(result.getContent());
SiteStructure merged = mergePatches(currentStructure, patch);
saveVersion(userId, siteId, refinementPrompt, references, serializeStructure(merged));
return structure;
return merged;
}
public List<GenerationVersion> getVersions(UUID siteId) {
Query query = new Query(Criteria.where("siteId").is(siteId))
.with(Sort.by(Sort.Direction.DESC, "versionNumber"))
.limit(MAX_VERSIONS_PER_SITE);
.limit(maxVersionsPerSite);
return mongoTemplate.find(query, GenerationVersion.class);
}
public int pruneVersions(UUID siteId) {
return pruneVersions(siteId, maxVersionsPerSite);
}
public int pruneVersions(UUID siteId, int keep) {
Query countQuery = new Query(Criteria.where("siteId").is(siteId));
long total = mongoTemplate.count(countQuery, GenerationVersion.class);
if (total <= keep) {
return 0;
}
long toDelete = total - keep;
Query findQuery = new Query(Criteria.where("siteId").is(siteId))
.with(Sort.by(Sort.Direction.ASC, "versionNumber"))
.limit((int) toDelete);
List<GenerationVersion> oldVersions = mongoTemplate.find(findQuery, GenerationVersion.class);
for (GenerationVersion v : oldVersions) {
mongoTemplate.remove(v);
}
return oldVersions.size();
}
public SiteStructure restoreVersion(String versionId) {
GenerationVersion version = mongoTemplate.findById(versionId, GenerationVersion.class);
if (version == null) {
@@ -159,6 +231,110 @@ public class LLMService {
mongoTemplate.remove(version);
}
private SiteStructure mergePatches(SiteStructure current, SiteStructure patch) {
SiteStructure.SiteStructureBuilder builder = SiteStructure.builder();
if (patch.getTheme() != null) {
builder.theme(mergeTheme(current.getTheme(), patch.getTheme()));
} else {
builder.theme(current.getTheme());
}
if (patch.getPages() != null && !patch.getPages().isEmpty()) {
Map<String, SiteStructure.PageStructure> currentPageMap = new HashMap<>();
for (SiteStructure.PageStructure page : current.getPages()) {
if (page.getId() != null) {
currentPageMap.put(page.getId(), page);
}
}
Set<String> deletedIds = new HashSet<>();
for (SiteStructure.PageStructure patchPage : patch.getPages()) {
if (patchPage.isDeleted() && patchPage.getId() != null) {
deletedIds.add(patchPage.getId());
}
}
List<SiteStructure.PageStructure> mergedPages = new ArrayList<>();
for (SiteStructure.PageStructure currentPage : current.getPages()) {
if (currentPage.getId() != null && deletedIds.contains(currentPage.getId())) {
continue;
}
SiteStructure.PageStructure matchingPatch = null;
if (currentPage.getId() != null) {
for (SiteStructure.PageStructure pp : patch.getPages()) {
if (currentPage.getId().equals(pp.getId())) {
matchingPatch = pp;
break;
}
}
}
if (matchingPatch != null && !matchingPatch.isDeleted()) {
mergedPages.add(mergePage(currentPage, matchingPatch));
} else {
mergedPages.add(currentPage);
}
}
for (SiteStructure.PageStructure patchPage : patch.getPages()) {
if (patchPage.isDeleted()) continue;
String pid = patchPage.getId();
if (pid == null || !currentPageMap.containsKey(pid)) {
mergedPages.add(patchPage);
}
}
builder.pages(mergedPages);
} else {
builder.pages(current.getPages());
}
return builder.build();
}
private SiteStructure.ThemeConfig mergeTheme(SiteStructure.ThemeConfig current, SiteStructure.ThemeConfig patch) {
SiteStructure.ThemeConfig.ThemeConfigBuilder builder = SiteStructure.ThemeConfig.builder();
if (patch.getColors() != null) {
Map<String, String> mergedColors = new HashMap<>(current.getColors() != null ? current.getColors() : Map.of());
mergedColors.putAll(patch.getColors());
builder.colors(mergedColors);
} else {
builder.colors(current.getColors());
}
if (patch.getFonts() != null) {
Map<String, String> mergedFonts = new HashMap<>(current.getFonts() != null ? current.getFonts() : Map.of());
mergedFonts.putAll(patch.getFonts());
builder.fonts(mergedFonts);
} else {
builder.fonts(current.getFonts());
}
if (patch.getSpacing() != null) {
Map<String, String> mergedSpacing = new HashMap<>(current.getSpacing() != null ? current.getSpacing() : Map.of());
mergedSpacing.putAll(patch.getSpacing());
builder.spacing(mergedSpacing);
} else {
builder.spacing(current.getSpacing());
}
return builder.build();
}
private SiteStructure.PageStructure mergePage(SiteStructure.PageStructure current, SiteStructure.PageStructure patch) {
SiteStructure.PageStructure.PageStructureBuilder builder = SiteStructure.PageStructure.builder();
builder.id(patch.getId() != null ? patch.getId() : current.getId());
builder.title(patch.getTitle() != null ? patch.getTitle() : current.getTitle());
builder.slug(patch.getSlug() != null ? patch.getSlug() : current.getSlug());
builder.seoTitle(patch.getSeoTitle() != null ? patch.getSeoTitle() : current.getSeoTitle());
builder.seoDescription(patch.getSeoDescription() != null ? patch.getSeoDescription() : current.getSeoDescription());
builder.orderIndex(patch.getOrderIndex() != 0 ? patch.getOrderIndex() : current.getOrderIndex());
builder.rawHtml(patch.getRawHtml() != null ? patch.getRawHtml() : current.getRawHtml());
builder.deleted(patch.isDeleted());
return builder.build();
}
private String resolveApiKey(UUID userId, String provider, String apiKey) {
if (apiKey != null && !apiKey.isBlank()) {
return apiKey;
@@ -204,6 +380,9 @@ public class LLMService {
LLMProvider p = providerFactory.createProvider(provider, apiKey, baseUrl, model);
try {
return p.generate(systemPrompt, userPrompt, references, options);
} catch (ResourceAccessException e) {
throw new IllegalStateException(
provider + " timed out. Please try again or use a simpler prompt.");
} catch (HttpClientErrorException e) {
String detail = e.getResponseBodyAsString();
String msg = switch (e.getStatusCode().value()) {
@@ -243,6 +422,7 @@ public class LLMService {
.createdAt(LocalDateTime.now())
.build();
mongoTemplate.save(version);
pruneVersions(siteId);
}
private int getNextVersionNumber(UUID siteId) {
@@ -278,4 +458,65 @@ public class LLMService {
throw new RuntimeException("Failed to serialize site structure", e);
}
}
private SuggestSectionsResponse parseSectionSuggestions(String llmOutput) {
String json = extractJsonArray(llmOutput);
try {
JsonNode root = objectMapper.readTree(json);
List<SuggestSectionsResponse.SectionSuggestion> sections = new ArrayList<>();
if (root.isArray()) {
for (JsonNode node : root) {
String title = node.has("title") ? node.get("title").asText() : null;
String description = node.has("description") ? node.get("description").asText() : null;
String slug = node.has("slug") ? node.get("slug").asText() : null;
if (title != null && slug != null) {
List<SuggestSectionsResponse.PatternSuggestion> patterns = new ArrayList<>();
if (node.has("patterns") && node.get("patterns").isArray()) {
for (JsonNode pn : node.get("patterns")) {
String pt = pn.has("title") ? pn.get("title").asText() : null;
String pd = pn.has("description") ? pn.get("description").asText() : null;
if (pt != null) {
patterns.add(new SuggestSectionsResponse.PatternSuggestion(pt, pd));
}
}
}
sections.add(new SuggestSectionsResponse.SectionSuggestion(title, description, slug, patterns));
}
}
}
if (sections.isEmpty()) {
throw new IllegalArgumentException("LLM output must contain at least one section suggestion");
}
return new SuggestSectionsResponse(sections);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Failed to parse section suggestions as JSON: " + e.getMessage());
}
}
private String extractJsonArray(String raw) {
if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException("LLM output is empty");
}
String trimmed = raw.trim();
if (trimmed.startsWith("```")) {
int start = trimmed.indexOf('\n');
int end = trimmed.lastIndexOf("```");
if (start > 0 && end > start) {
trimmed = trimmed.substring(start, end).trim();
}
}
if (!trimmed.startsWith("[")) {
int bracket = trimmed.indexOf('[');
if (bracket >= 0) {
trimmed = trimmed.substring(bracket);
}
}
if (!trimmed.endsWith("]")) {
int bracket = trimmed.lastIndexOf(']');
if (bracket >= 0) {
trimmed = trimmed.substring(0, bracket + 1);
}
}
return trimmed;
}
}

View File

@@ -14,14 +14,17 @@ public class ProviderFactory {
private final RestTemplateBuilder restTemplateBuilder;
private final Map<String, ProviderConfig> providerConfigs;
private final Duration readTimeout;
public ProviderFactory(
@Value("${indie.llm.providers.gemini.model:gemini-3.1-flash-lite}") String geminiModel,
@Value("${indie.llm.providers.gemini.url:https://generativelanguage.googleapis.com/v1beta/models}") String geminiUrl,
@Value("${indie.llm.providers.openai.model:gpt-4o-mini}") String openaiModel,
@Value("${indie.llm.providers.openai.url:https://api.openai.com/v1/chat/completions}") String openaiUrl,
@Value("${indie.llm.read-timeout:180s}") Duration readTimeout,
RestTemplateBuilder restTemplateBuilder) {
this.restTemplateBuilder = restTemplateBuilder;
this.readTimeout = readTimeout;
this.providerConfigs = Map.of(
"gemini", new ProviderConfig(geminiModel, geminiUrl),
"openai", new ProviderConfig(openaiModel, openaiUrl)
@@ -52,7 +55,7 @@ public class ProviderFactory {
}
RestTemplate rt = restTemplateBuilder
.setConnectTimeout(Duration.ofSeconds(30))
.setReadTimeout(Duration.ofSeconds(60))
.setReadTimeout(readTimeout)
.build();
return switch (name) {
case "gemini" -> new GeminiProvider(apiKey, resolvedModel, url, rt);

View File

@@ -31,6 +31,7 @@ spring:
port: ${EMAIL_PORT:1025}
username: ${EMAIL_USERNAME:}
password: ${EMAIL_PASSWORD:}
from: ${EMAIL_FROM:mailme@krrishg.com}
properties:
mail:
smtp:
@@ -55,9 +56,6 @@ indie:
repos-path: ${INDIE_GIT_REPOS_PATH:/home/krrish/git/sites/}
author-name: ${INDIE_GIT_AUTHOR_NAME:Indie Platform}
author-email: ${INDIE_GIT_AUTHOR_EMAIL:git@indie.local}
cors:
allowed-origins: ${INDIE_CORS_ORIGINS:http://localhost:4200,http://localhost:3000}
storage:
endpoint: ${INDIE_STORAGE_ENDPOINT:http://localhost:9000}
region: ${INDIE_STORAGE_REGION:auto}
@@ -70,10 +68,15 @@ indie:
master-key-path: ${INDIE_ENCRYPTION_KEY_PATH:config/encryption.key}
app:
base-url: ${INDIE_APP_BASE_URL:http://localhost:4200}
base-url: ${INDIE_APP_BASE_URL:https://indie.krrishg.com}
verification-token-expiration-minutes: ${INDIE_VERIFICATION_TOKEN_EXPIRATION:1440}
llm:
read-timeout: ${INDIE_LLM_READ_TIMEOUT:180s}
max-versions-per-site: ${INDIE_LLM_MAX_VERSIONS:10}
refine:
temperature: ${INDIE_LLM_REFINE_TEMPERATURE:0.3}
read-timeout: ${INDIE_LLM_REFINE_READ_TIMEOUT:300s}
rate-limit:
max-requests-per-hour: ${INDIE_LLM_RATE_LIMIT:10}
providers:

View File

@@ -1,5 +1,7 @@
package com.krrishg.config;
import io.jsonwebtoken.JwtException;
import org.apache.catalina.connector.ClientAbortException;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
@@ -13,6 +15,19 @@ class GlobalExceptionHandlerTest {
private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
@Test
void jwtExceptionReturns401() {
ProblemDetail detail = handler.handleJwtException(new JwtException("token expired"));
assertEquals(HttpStatus.UNAUTHORIZED.value(), detail.getStatus());
assertEquals("token expired", detail.getDetail());
}
@Test
void clientAbortDoesNotThrow() {
handler.handleClientAbort(new ClientAbortException("client disconnected"));
}
@Test
void illegalArgumentReturns400() {
ProblemDetail detail = handler.handleBadRequest(new IllegalArgumentException("bad input"));

View File

@@ -0,0 +1,193 @@
package com.krrishg.config;
import com.krrishg.model.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.util.UUID;
import static io.jsonwebtoken.Jwts.SIG.RS256;
import static org.junit.jupiter.api.Assertions.*;
class JwtConfigTest {
@Test
void generateAndValidateAccessToken() {
JwtConfig jwtConfig = createWithExpiration(900_000L, 604_800_000L);
jwtConfig.init();
User user = User.builder()
.id(UUID.randomUUID())
.email("test@example.com")
.name("Test User")
.build();
String token = jwtConfig.generateAccessToken(user);
Claims claims = jwtConfig.validateToken(token);
assertEquals(user.getId().toString(), claims.getSubject());
assertEquals("test@example.com", claims.get("email", String.class));
assertEquals("Test User", claims.get("name", String.class));
assertNotNull(claims.getIssuedAt());
assertNotNull(claims.getExpiration());
}
@Test
void generateAndValidateRefreshToken() {
JwtConfig jwtConfig = createWithExpiration(900_000L, 604_800_000L);
jwtConfig.init();
User user = User.builder()
.id(UUID.randomUUID())
.email("refresh@example.com")
.name("Refresh User")
.build();
String token = jwtConfig.generateRefreshToken(user);
Claims claims = jwtConfig.validateToken(token);
assertEquals(user.getId().toString(), claims.getSubject());
assertEquals("refresh@example.com", claims.get("email", String.class));
assertEquals("Refresh User", claims.get("name", String.class));
}
@Test
void multipleTokensForSameUserAreAllValid() {
JwtConfig jwtConfig = createWithExpiration(900_000L, 604_800_000L);
jwtConfig.init();
User user = User.builder()
.id(UUID.randomUUID())
.email("same@example.com")
.build();
String token1 = jwtConfig.generateAccessToken(user);
String token2 = jwtConfig.generateAccessToken(user);
assertDoesNotThrow(() -> jwtConfig.validateToken(token1));
assertDoesNotThrow(() -> jwtConfig.validateToken(token2));
Claims claims1 = jwtConfig.validateToken(token1);
Claims claims2 = jwtConfig.validateToken(token2);
assertEquals(user.getId().toString(), claims1.getSubject());
assertEquals(user.getId().toString(), claims2.getSubject());
}
@Test
void rejectTamperedToken() {
JwtConfig jwtConfig = createWithExpiration(900_000L, 604_800_000L);
jwtConfig.init();
User user = User.builder()
.id(UUID.randomUUID())
.email("tamper@example.com")
.build();
String token = jwtConfig.generateAccessToken(user);
String tampered = token.substring(0, token.lastIndexOf('.') + 1) + "tampered";
assertThrows(Exception.class, () -> jwtConfig.validateToken(tampered));
}
@Test
void rejectExpiredToken() throws Exception {
JwtConfig jwtConfig = new JwtConfig();
setField(jwtConfig, "accessTokenExpiration", -1000L);
jwtConfig.init();
User user = User.builder()
.id(UUID.randomUUID())
.email("expired@example.com")
.build();
String token = jwtConfig.generateAccessToken(user);
assertThrows(Exception.class, () -> jwtConfig.validateToken(token));
}
@Test
void initGeneratesEphemeralKeys() {
JwtConfig jwtConfig = createWithExpiration(900_000L, 604_800_000L);
jwtConfig.init();
assertNotNull(jwtConfig.getPublicKey());
User user = User.builder().id(UUID.randomUUID()).email("ephemeral@test.com").build();
String token = jwtConfig.generateAccessToken(user);
assertDoesNotThrow(() -> jwtConfig.validateToken(token));
}
@Test
void initLoadsKeysFromFiles(@TempDir Path tempDir) throws Exception {
KeyPair keyPair = RS256.keyPair().build();
Path privPath = tempDir.resolve("private.key");
Path pubPath = tempDir.resolve("public.key");
Files.write(privPath, keyPair.getPrivate().getEncoded());
Files.write(pubPath, keyPair.getPublic().getEncoded());
JwtConfig jwtConfig = new JwtConfig();
setField(jwtConfig, "privateKeyPath", privPath.toString());
setField(jwtConfig, "publicKeyPath", pubPath.toString());
setField(jwtConfig, "accessTokenExpiration", 900_000L);
setField(jwtConfig, "refreshTokenExpiration", 604_800_000L);
jwtConfig.init();
User user = User.builder().id(UUID.randomUUID()).email("file@test.com").name("File Test").build();
String token = jwtConfig.generateAccessToken(user);
Claims claims = jwtConfig.validateToken(token);
assertEquals(user.getId().toString(), claims.getSubject());
assertEquals("file@test.com", claims.get("email", String.class));
assertEquals("File Test", claims.get("name", String.class));
}
@Test
void initGeneratesAndSavesKeysWhenFilesDoNotExist(@TempDir Path tempDir) throws Exception {
Path privPath = tempDir.resolve("private.key");
Path pubPath = tempDir.resolve("public.key");
JwtConfig jwtConfig = new JwtConfig();
setField(jwtConfig, "privateKeyPath", privPath.toString());
setField(jwtConfig, "publicKeyPath", pubPath.toString());
setField(jwtConfig, "accessTokenExpiration", 900_000L);
setField(jwtConfig, "refreshTokenExpiration", 604_800_000L);
jwtConfig.init();
assertTrue(Files.exists(privPath));
assertTrue(Files.exists(pubPath));
User user = User.builder().id(UUID.randomUUID()).email("saved@test.com").build();
String token = jwtConfig.generateAccessToken(user);
assertDoesNotThrow(() -> jwtConfig.validateToken(token));
}
@Test
void getExpirationReturnsConfiguredValues() throws Exception {
JwtConfig jwtConfig = new JwtConfig();
setField(jwtConfig, "accessTokenExpiration", 300_000L);
setField(jwtConfig, "refreshTokenExpiration", 1_800_000L);
assertEquals(300_000L, jwtConfig.getAccessTokenExpiration());
assertEquals(1_800_000L, jwtConfig.getRefreshTokenExpiration());
}
private JwtConfig createWithExpiration(long access, long refresh) {
JwtConfig jwtConfig = new JwtConfig();
try {
setField(jwtConfig, "accessTokenExpiration", access);
setField(jwtConfig, "refreshTokenExpiration", refresh);
} catch (Exception e) {
throw new RuntimeException(e);
}
return jwtConfig;
}
private void setField(Object target, String name, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
}

View File

@@ -0,0 +1,60 @@
package com.krrishg.config;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
import java.sql.SQLException;
import static org.junit.jupiter.api.Assertions.*;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class SchemaMigrationTest {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private DataSource dataSource;
@Test
void migrateExecutesWithoutError() {
SchemaMigration migration = new SchemaMigration(jdbcTemplate);
assertDoesNotThrow(() -> migration.migrate());
}
@Test
void migrateIsIdempotent() {
SchemaMigration migration = new SchemaMigration(jdbcTemplate);
migration.migrate();
assertDoesNotThrow(() -> migration.migrate());
}
@Test
void sslColumnsExistOnSelfHostedConfigs() throws SQLException {
SchemaMigration migration = new SchemaMigration(jdbcTemplate);
migration.migrate();
assertTrue(columnExists("self_hosted_configs", "ssl_enabled"));
assertTrue(columnExists("self_hosted_configs", "ssl_email"));
}
@Test
void encryptedSshKeyColumnIsDropped() throws SQLException {
SchemaMigration migration = new SchemaMigration(jdbcTemplate);
migration.migrate();
assertFalse(columnExists("self_hosted_configs", "encrypted_ssh_key"));
}
private boolean columnExists(String table, String column) throws SQLException {
try (var rs = dataSource.getConnection().getMetaData()
.getColumns(null, null, table, column)) {
return rs.next();
}
}
}

View File

@@ -0,0 +1,144 @@
package com.krrishg.config;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.lang.reflect.Method;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class SecurityConfigTest {
@Mock
private JwtConfig jwtConfig;
private SecurityConfig securityConfig;
@BeforeEach
void setUp() {
securityConfig = new SecurityConfig(jwtConfig);
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
@Test
void setsAuthenticationForValidToken() throws Exception {
UUID userId = UUID.randomUUID();
Claims claims = mock(Claims.class);
when(claims.getSubject()).thenReturn(userId.toString());
when(claims.get("email", String.class)).thenReturn("test@example.com");
when(claims.get("name", String.class)).thenReturn("Test User");
when(jwtConfig.validateToken("valid-token")).thenReturn(claims);
OncePerRequestFilter filter = getJwtFilter();
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader("Authorization")).thenReturn("Bearer valid-token");
HttpServletResponse response = mock(HttpServletResponse.class);
FilterChain chain = mock(FilterChain.class);
filter.doFilter(request, response, chain);
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
assertInstanceOf(JwtAuthenticationToken.class, auth);
UserPrincipal principal = (UserPrincipal) auth.getPrincipal();
assertEquals(userId, principal.id());
assertEquals("test@example.com", principal.email());
assertEquals("Test User", principal.name());
verify(chain).doFilter(request, response);
}
@Test
void clearsContextOnInvalidToken() throws Exception {
when(jwtConfig.validateToken(anyString())).thenThrow(new RuntimeException("bad token"));
OncePerRequestFilter filter = getJwtFilter();
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader("Authorization")).thenReturn("Bearer bad-token");
HttpServletResponse response = mock(HttpServletResponse.class);
FilterChain chain = mock(FilterChain.class);
filter.doFilter(request, response, chain);
assertNull(SecurityContextHolder.getContext().getAuthentication());
verify(chain).doFilter(request, response);
}
@Test
void doesNotAuthenticateWhenNoAuthHeader() throws Exception {
OncePerRequestFilter filter = getJwtFilter();
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader("Authorization")).thenReturn(null);
HttpServletResponse response = mock(HttpServletResponse.class);
FilterChain chain = mock(FilterChain.class);
filter.doFilter(request, response, chain);
assertNull(SecurityContextHolder.getContext().getAuthentication());
verify(chain).doFilter(request, response);
verify(jwtConfig, never()).validateToken(any());
}
@Test
void doesNotAuthenticateForNonBearerScheme() throws Exception {
OncePerRequestFilter filter = getJwtFilter();
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader("Authorization")).thenReturn("Basic credentials");
HttpServletResponse response = mock(HttpServletResponse.class);
FilterChain chain = mock(FilterChain.class);
filter.doFilter(request, response, chain);
assertNull(SecurityContextHolder.getContext().getAuthentication());
verify(chain).doFilter(request, response);
verify(jwtConfig, never()).validateToken(any());
}
@Test
void filterCallsDoFilterOnChain() throws Exception {
OncePerRequestFilter filter = getJwtFilter();
var request = mock(HttpServletRequest.class);
var response = mock(HttpServletResponse.class);
var chain = mock(FilterChain.class);
filter.doFilter(request, response, chain);
verify(chain).doFilter(request, response);
}
@Test
void alwaysCallsDoFilterEvenOnException() throws Exception {
OncePerRequestFilter filter = getJwtFilter();
var request = mock(HttpServletRequest.class);
when(request.getHeader("Authorization")).thenReturn("Bearer token");
when(jwtConfig.validateToken(anyString())).thenThrow(new RuntimeException("fail"));
var response = mock(HttpServletResponse.class);
var chain = mock(FilterChain.class);
filter.doFilter(request, response, chain);
verify(chain).doFilter(request, response);
}
private OncePerRequestFilter getJwtFilter() throws Exception {
Method method = SecurityConfig.class.getDeclaredMethod("jwtAuthenticationFilter");
method.setAccessible(true);
return (OncePerRequestFilter) method.invoke(securityConfig);
}
}

View File

@@ -0,0 +1,30 @@
package com.krrishg.config;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.s3.S3Client;
import java.lang.reflect.Field;
import static org.junit.jupiter.api.Assertions.*;
class StorageConfigTest {
@Test
void s3ClientIsCreatedWithConfiguration() throws Exception {
StorageConfig config = new StorageConfig();
setField(config, "endpoint", "http://localhost:9000");
setField(config, "region", "us-east-1");
setField(config, "accessKey", "test-access-key");
setField(config, "secretKey", "test-secret-key");
S3Client client = config.s3Client();
assertNotNull(client);
client.close();
}
private void setField(Object target, String name, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
}

View File

@@ -0,0 +1,76 @@
package com.krrishg.config;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class SuggestRateLimitConfigTest {
private SuggestRateLimitConfig config;
@BeforeEach
void setUp() {
config = new SuggestRateLimitConfig(3);
}
@Test
void tryConsumeReturnsTrueWithinLimit() {
UUID userId = UUID.randomUUID();
assertTrue(config.tryConsume(userId));
assertTrue(config.tryConsume(userId));
assertTrue(config.tryConsume(userId));
}
@Test
void tryConsumeReturnsFalseWhenExceeded() {
UUID userId = UUID.randomUUID();
assertTrue(config.tryConsume(userId));
assertTrue(config.tryConsume(userId));
assertTrue(config.tryConsume(userId));
assertFalse(config.tryConsume(userId));
}
@Test
void differentUsersHaveIndependentLimits() {
UUID userA = UUID.randomUUID();
UUID userB = UUID.randomUUID();
for (int i = 0; i < 3; i++) {
assertTrue(config.tryConsume(userA));
}
assertFalse(config.tryConsume(userA));
assertTrue(config.tryConsume(userB));
assertTrue(config.tryConsume(userB));
assertTrue(config.tryConsume(userB));
assertFalse(config.tryConsume(userB));
}
@Test
void getAvailableTokensReturnsRemaining() {
UUID userId = UUID.randomUUID();
assertEquals(3, config.getAvailableTokens(userId));
config.tryConsume(userId);
assertEquals(2, config.getAvailableTokens(userId));
}
@Test
void getAvailableTokensReturnsZeroWhenExhausted() {
UUID userId = UUID.randomUUID();
for (int i = 0; i < 3; i++) {
config.tryConsume(userId);
}
assertEquals(0, config.getAvailableTokens(userId));
}
@Test
void constructorWithDefaultRate() {
SuggestRateLimitConfig defaultConfig = new SuggestRateLimitConfig(30);
UUID userId = UUID.randomUUID();
assertEquals(30, defaultConfig.getAvailableTokens(userId));
}
}

View File

@@ -52,8 +52,6 @@ class AuthControllerTest {
RegisterRequest request = new RegisterRequest();
request.setEmail("test@example.com");
request.setPassword("password123");
request.setName("Test");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
@@ -218,6 +216,7 @@ class AuthControllerTest {
SetPasswordRequest request = new SetPasswordRequest();
request.setSetupToken("setup-token-123");
request.setPassword("new-password");
request.setName("Test");
mockMvc.perform(post("/api/auth/set-password")
.contentType(MediaType.APPLICATION_JSON)
@@ -233,6 +232,7 @@ class AuthControllerTest {
SetPasswordRequest request = new SetPasswordRequest();
request.setSetupToken("bad-token");
request.setPassword("new-password");
request.setName("Test");
mockMvc.perform(post("/api/auth/set-password")
.contentType(MediaType.APPLICATION_JSON)

View File

@@ -3,9 +3,11 @@ package com.krrishg.controller;
import com.krrishg.config.GlobalExceptionHandler;
import com.krrishg.config.JwtAuthenticationToken;
import com.krrishg.config.RateLimitConfig;
import com.krrishg.config.SuggestRateLimitConfig;
import com.krrishg.config.UserPrincipal;
import com.krrishg.dto.Reference;
import com.krrishg.dto.SiteStructure;
import com.krrishg.dto.SuggestSectionsResponse;
import com.krrishg.model.GenerationVersion;
import com.krrishg.service.LLMService;
import com.krrishg.support.TestSecurityConfig;
@@ -45,6 +47,9 @@ class LLMControllerTest {
@MockBean
private RateLimitConfig rateLimitConfig;
@MockBean
private SuggestRateLimitConfig suggestRateLimitConfig;
private UUID userId;
private UserPrincipal principal;
private JwtAuthenticationToken auth;
@@ -219,6 +224,22 @@ class LLMControllerTest {
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("No API key configured for gemini"));
}
@Test
void returns400WhenSiteIdMissing() throws Exception {
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
Map<String, Object> request = Map.of(
"prompt", "Build a site",
"provider", "gemini"
);
mockMvc.perform(post("/api/llm/generate")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
}
@Nested
@@ -296,6 +317,171 @@ class LLMControllerTest {
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void returns400WhenPromptMissing() throws Exception {
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
Map<String, Object> request = Map.of(
"siteId", UUID.randomUUID().toString(),
"provider", "gemini",
"currentStructure", SiteStructure.builder().build()
);
mockMvc.perform(post("/api/llm/refine")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void returns400WhenSiteIdMissing() throws Exception {
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
Map<String, Object> request = Map.of(
"prompt", "Refine",
"provider", "gemini",
"currentStructure", SiteStructure.builder().build()
);
mockMvc.perform(post("/api/llm/refine")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void returns400WhenCurrentStructureInvalid() throws Exception {
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
Map<String, Object> request = Map.of(
"prompt", "Refine",
"siteId", UUID.randomUUID().toString(),
"provider", "gemini",
"currentStructure", "not-a-valid-structure"
);
mockMvc.perform(post("/api/llm/refine")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void returns400WhenCurrentStructureNull() throws Exception {
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
Map<String, Object> request = Map.of(
"prompt", "Refine",
"siteId", UUID.randomUUID().toString(),
"provider", "gemini"
);
mockMvc.perform(post("/api/llm/refine")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
}
@Nested
class SuggestSections {
@Test
void returns200WithSuggestions() throws Exception {
when(suggestRateLimitConfig.tryConsume(userId)).thenReturn(true);
SuggestSectionsResponse response = new SuggestSectionsResponse(
List.of(
new SuggestSectionsResponse.SectionSuggestion("Home", "Hero section", "home", List.of()),
new SuggestSectionsResponse.SectionSuggestion("About", "Bio", "about", List.of())
)
);
when(llmService.suggestSections(any(), anyString(), anyString(), any(), any(), any())).thenReturn(response);
Map<String, Object> request = Map.of(
"briefDescription", "photography portfolio",
"provider", "gemini"
);
mockMvc.perform(post("/api/llm/suggest-sections")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.sections[0].title").value("Home"))
.andExpect(jsonPath("$.sections[1].slug").value("about"));
}
@Test
void returns429WhenRateLimited() throws Exception {
when(suggestRateLimitConfig.tryConsume(userId)).thenReturn(false);
when(suggestRateLimitConfig.getAvailableTokens(userId)).thenReturn(0);
Map<String, Object> request = Map.of(
"briefDescription", "portfolio",
"provider", "gemini"
);
mockMvc.perform(post("/api/llm/suggest-sections")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isTooManyRequests());
}
@Test
void returns400WhenBriefDescriptionMissing() throws Exception {
when(suggestRateLimitConfig.tryConsume(userId)).thenReturn(true);
Map<String, Object> request = Map.of(
"provider", "gemini"
);
mockMvc.perform(post("/api/llm/suggest-sections")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void returns400WhenProviderMissing() throws Exception {
when(suggestRateLimitConfig.tryConsume(userId)).thenReturn(true);
Map<String, Object> request = Map.of(
"briefDescription", "portfolio"
);
mockMvc.perform(post("/api/llm/suggest-sections")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void returns400WhenNoKeyConfigured() throws Exception {
when(suggestRateLimitConfig.tryConsume(userId)).thenReturn(true);
when(llmService.suggestSections(any(), anyString(), anyString(), any(), any(), any()))
.thenThrow(new IllegalStateException("No API key configured for gemini"));
Map<String, Object> request = Map.of(
"briefDescription", "portfolio",
"provider", "gemini"
);
mockMvc.perform(post("/api/llm/suggest-sections")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("No API key configured for gemini"));
}
}
@Nested
@@ -383,4 +569,45 @@ class LLMControllerTest {
.andExpect(status().isNotFound());
}
}
@Nested
class PruneVersions {
@Test
void returns200WithDefaultKeep() throws Exception {
when(llmService.pruneVersions(any(UUID.class), eq(10))).thenReturn(3);
UUID siteId = UUID.randomUUID();
mockMvc.perform(post("/api/llm/versions/{siteId}/prune", siteId)
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.deleted").value(3));
}
@Test
void returns200WithCustomKeep() throws Exception {
when(llmService.pruneVersions(any(UUID.class), eq(5))).thenReturn(2);
UUID siteId = UUID.randomUUID();
mockMvc.perform(post("/api/llm/versions/{siteId}/prune", siteId)
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(Map.of("keep", 5))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.deleted").value(2));
}
@Test
void returns200WithNoRequestBody() throws Exception {
when(llmService.pruneVersions(any(UUID.class), eq(10))).thenReturn(0);
UUID siteId = UUID.randomUUID();
mockMvc.perform(post("/api/llm/versions/{siteId}/prune", siteId)
.with(authentication(auth)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.deleted").value(0));
}
}
}

View File

@@ -11,7 +11,7 @@ class LLMOptionsTest {
LLMOptions options = LLMOptions.builder().build();
assertEquals(0.9, options.getTemperature());
assertEquals(4096, options.getMaxTokens());
assertEquals(16384, options.getMaxTokens());
assertEquals(60, options.getTimeoutSeconds());
}

View File

@@ -0,0 +1,54 @@
package com.krrishg.model;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class VerificationTokenTest {
@Test
void prePersistSetsIdAndCreatedAt() {
VerificationToken token = new VerificationToken();
token.setUserId(UUID.randomUUID());
token.setToken("verify-token");
token.setExpiryDate(LocalDateTime.now().plusHours(1));
assertNull(token.getId());
assertNull(token.getCreatedAt());
token.onCreate();
assertNotNull(token.getId());
assertNotNull(token.getCreatedAt());
}
@Test
void prePersistDoesNotOverrideExistingId() {
UUID existingId = UUID.randomUUID();
VerificationToken token = VerificationToken.builder()
.id(existingId)
.userId(UUID.randomUUID())
.token("test-token")
.expiryDate(LocalDateTime.now().plusHours(1))
.build();
token.onCreate();
assertEquals(existingId, token.getId());
}
@Test
void builderDefaults() {
VerificationToken token = VerificationToken.builder()
.userId(UUID.randomUUID())
.token("test-token")
.expiryDate(LocalDateTime.now().plusHours(1))
.build();
assertFalse(token.isUsed());
assertFalse(token.isSetupTokenUsed());
}
}

View File

@@ -0,0 +1,119 @@
package com.krrishg.repository;
import com.krrishg.model.VerificationToken;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
@DataJpaTest
class VerificationTokenRepositoryTest {
private final VerificationTokenRepository repository;
VerificationTokenRepositoryTest(@Autowired VerificationTokenRepository repository) {
this.repository = repository;
}
@Test
void saveAndFindByToken() {
VerificationToken token = VerificationToken.builder()
.userId(UUID.randomUUID())
.token("verify-token-123")
.expiryDate(LocalDateTime.now().plusHours(1))
.build();
repository.save(token);
Optional<VerificationToken> found = repository.findByToken("verify-token-123");
assertTrue(found.isPresent());
assertEquals(token.getUserId(), found.get().getUserId());
assertFalse(found.get().isUsed());
}
@Test
void findByTokenReturnsEmptyWhenNotFound() {
Optional<VerificationToken> found = repository.findByToken("nonexistent");
assertTrue(found.isEmpty());
}
@Test
void findBySetupToken() {
VerificationToken token = VerificationToken.builder()
.userId(UUID.randomUUID())
.token("verify-token")
.setupToken("setup-token-456")
.expiryDate(LocalDateTime.now().plusHours(1))
.build();
repository.save(token);
Optional<VerificationToken> found = repository.findBySetupToken("setup-token-456");
assertTrue(found.isPresent());
assertEquals("verify-token", found.get().getToken());
}
@Test
void findBySetupTokenReturnsEmptyWhenNotFound() {
Optional<VerificationToken> found = repository.findBySetupToken("nonexistent");
assertTrue(found.isEmpty());
}
@Test
void deleteByUserIdRemovesToken() {
UUID userId = UUID.randomUUID();
VerificationToken token = VerificationToken.builder()
.userId(userId)
.token("delete-me")
.expiryDate(LocalDateTime.now().plusHours(1))
.build();
repository.save(token);
repository.deleteByUserId(userId);
assertTrue(repository.findByToken("delete-me").isEmpty());
}
@Test
void findByUsedFalseAndExpiryDateBeforeReturnsExpiredTokens() {
VerificationToken expired = VerificationToken.builder()
.userId(UUID.randomUUID())
.token("expired-token")
.expiryDate(LocalDateTime.now().minusHours(1))
.used(false)
.build();
VerificationToken valid = VerificationToken.builder()
.userId(UUID.randomUUID())
.token("valid-token")
.expiryDate(LocalDateTime.now().plusHours(1))
.used(false)
.build();
repository.save(expired);
repository.save(valid);
var expiredTokens = repository.findByUsedFalseAndExpiryDateBefore(LocalDateTime.now());
assertEquals(1, expiredTokens.size());
assertEquals("expired-token", expiredTokens.get(0).getToken());
}
@Test
void savedEntityHasIdAndCreatedAt() {
VerificationToken token = VerificationToken.builder()
.userId(UUID.randomUUID())
.token("new-token")
.expiryDate(LocalDateTime.now().plusHours(1))
.build();
VerificationToken saved = repository.save(token);
assertNotNull(saved.getId());
assertNotNull(saved.getCreatedAt());
}
}

View File

@@ -43,8 +43,6 @@ class AuthServiceTest {
void registerCreatesUserAndSendsVerification() {
RegisterRequest request = new RegisterRequest();
request.setEmail("new@example.com");
request.setPassword("password123");
request.setName("New User");
RegistrationResponse response = authService.register(request);
@@ -53,12 +51,21 @@ class AuthServiceTest {
assertFalse(userRepository.findByEmail("new@example.com").get().isEmailVerified());
}
@Test
void registerDoesNotSetName() {
RegisterRequest request = new RegisterRequest();
request.setEmail("noname@example.com");
authService.register(request);
User user = userRepository.findByEmail("noname@example.com").get();
assertNull(user.getName());
}
@Test
void registerSendsVerificationEmail() {
RegisterRequest request = new RegisterRequest();
request.setEmail("emailtest@example.com");
request.setPassword("password123");
request.setName("Email Test");
authService.register(request);
@@ -79,8 +86,6 @@ class AuthServiceTest {
RegisterRequest request = new RegisterRequest();
request.setEmail("verified@example.com");
request.setPassword("password123");
request.setName("Should Not Matter");
RegistrationResponse response = authService.register(request);
@@ -100,8 +105,6 @@ class AuthServiceTest {
RegisterRequest request = new RegisterRequest();
request.setEmail("unverified@example.com");
request.setPassword("newpassword");
request.setName("New Name");
RegistrationResponse response = authService.register(request);
@@ -114,8 +117,6 @@ class AuthServiceTest {
void registerNormalizesEmailCase() {
RegisterRequest request = new RegisterRequest();
request.setEmail("UpperCase@Example.com");
request.setPassword("password123");
request.setName("User");
authService.register(request);
@@ -126,11 +127,10 @@ class AuthServiceTest {
void loginWithValidCredentialsAndVerifiedEmailReturnsTokens() {
RegisterRequest reg = new RegisterRequest();
reg.setEmail("login@example.com");
reg.setPassword("password123");
reg.setName("Login User");
authService.register(reg);
User user = userRepository.findByEmail("login@example.com").get();
user.setPassword("{stub}password123");
user.setEmailVerified(true);
userRepository.save(user);
@@ -149,8 +149,6 @@ class AuthServiceTest {
void loginThrowsWhenEmailNotVerified() {
RegisterRequest reg = new RegisterRequest();
reg.setEmail("unverified@example.com");
reg.setPassword("password123");
reg.setName("Unverified");
authService.register(reg);
LoginRequest login = new LoginRequest();
@@ -166,11 +164,10 @@ class AuthServiceTest {
void loginThrowsOnWrongPassword() {
RegisterRequest reg = new RegisterRequest();
reg.setEmail("wrongpw@example.com");
reg.setPassword("correctpw");
reg.setName("User");
authService.register(reg);
User user = userRepository.findByEmail("wrongpw@example.com").get();
user.setPassword("{stub}correctpw");
user.setEmailVerified(true);
userRepository.save(user);
@@ -315,11 +312,13 @@ class AuthServiceTest {
SetPasswordRequest request = new SetPasswordRequest();
request.setSetupToken("setup-token-123");
request.setPassword("new-password");
request.setName("New Name");
authService.setPassword(request);
User updatedUser = userRepository.findById(user.getId()).get();
assertTrue(updatedUser.getPassword().contains("new-password"));
assertEquals("New Name", updatedUser.getName());
VerificationToken updatedToken = tokenRepository.findByToken("verify-token").get();
assertTrue(updatedToken.isSetupTokenUsed());
@@ -480,8 +479,6 @@ class AuthServiceTest {
void refreshTokenReturnsNewTokens() {
RegisterRequest reg = new RegisterRequest();
reg.setEmail("refresh@example.com");
reg.setPassword("password123");
reg.setName("Refresh User");
authService.register(reg);
User user = userRepository.findByEmail("refresh@example.com").get();

View File

@@ -0,0 +1,165 @@
package com.krrishg.service;
import com.krrishg.model.User;
import com.krrishg.model.VerificationToken;
import com.krrishg.support.FakeUserRepository;
import com.krrishg.support.FakeVerificationTokenRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
class CleanupServiceTest {
private FakeUserRepository userRepository;
private FakeVerificationTokenRepository tokenRepository;
private CleanupService cleanupService;
@BeforeEach
void setUp() {
userRepository = new FakeUserRepository();
tokenRepository = new FakeVerificationTokenRepository();
cleanupService = new CleanupService(userRepository, tokenRepository);
}
@Test
void deletesUnverifiedUserWithExpiredToken() {
User user = User.builder()
.email("stale@example.com")
.emailVerified(false)
.build();
user.onCreate();
userRepository.save(user);
VerificationToken token = VerificationToken.builder()
.userId(user.getId())
.token("expired-token")
.used(false)
.expiryDate(LocalDateTime.now().minusDays(3))
.build();
tokenRepository.save(token);
cleanupService.cleanupStaleUnverifiedAccounts();
assertTrue(userRepository.findById(user.getId()).isEmpty());
assertTrue(tokenRepository.findByToken("expired-token").isEmpty());
}
@Test
void preservesVerifiedUserWithExpiredToken() {
User user = User.builder()
.email("verified@example.com")
.emailVerified(true)
.build();
user.onCreate();
userRepository.save(user);
VerificationToken token = VerificationToken.builder()
.userId(user.getId())
.token("verified-expired")
.used(false)
.expiryDate(LocalDateTime.now().minusDays(3))
.build();
tokenRepository.save(token);
cleanupService.cleanupStaleUnverifiedAccounts();
assertTrue(userRepository.findById(user.getId()).isPresent());
assertTrue(tokenRepository.findByToken("verified-expired").isPresent());
}
@Test
void preservesUnverifiedUserWithValidToken() {
User user = User.builder()
.email("pending@example.com")
.emailVerified(false)
.build();
user.onCreate();
userRepository.save(user);
VerificationToken token = VerificationToken.builder()
.userId(user.getId())
.token("valid-token")
.used(false)
.expiryDate(LocalDateTime.now().plusDays(1))
.build();
tokenRepository.save(token);
cleanupService.cleanupStaleUnverifiedAccounts();
assertTrue(userRepository.findById(user.getId()).isPresent());
assertTrue(tokenRepository.findByToken("valid-token").isPresent());
}
@Test
void doesNothingWhenNoStaleTokens() {
cleanupService.cleanupStaleUnverifiedAccounts();
assertEquals(0, userRepository.count());
assertEquals(0, tokenRepository.count());
}
@Test
void cleansUpMultipleStaleUsers() {
User user1 = User.builder()
.email("stale1@example.com")
.emailVerified(false)
.build();
user1.onCreate();
userRepository.save(user1);
User user2 = User.builder()
.email("stale2@example.com")
.emailVerified(false)
.build();
user2.onCreate();
userRepository.save(user2);
VerificationToken token1 = VerificationToken.builder()
.userId(user1.getId())
.token("stale-token-1")
.used(false)
.expiryDate(LocalDateTime.now().minusDays(3))
.build();
tokenRepository.save(token1);
VerificationToken token2 = VerificationToken.builder()
.userId(user2.getId())
.token("stale-token-2")
.used(false)
.expiryDate(LocalDateTime.now().minusDays(3))
.build();
tokenRepository.save(token2);
cleanupService.cleanupStaleUnverifiedAccounts();
assertTrue(userRepository.findById(user1.getId()).isEmpty());
assertTrue(userRepository.findById(user2.getId()).isEmpty());
assertTrue(tokenRepository.findByToken("stale-token-1").isEmpty());
assertTrue(tokenRepository.findByToken("stale-token-2").isEmpty());
}
@Test
void skipsUsedToken() {
User user = User.builder()
.email("used-token@example.com")
.emailVerified(false)
.build();
user.onCreate();
userRepository.save(user);
VerificationToken token = VerificationToken.builder()
.userId(user.getId())
.token("used-token")
.used(true)
.expiryDate(LocalDateTime.now().minusDays(3))
.build();
tokenRepository.save(token);
cleanupService.cleanupStaleUnverifiedAccounts();
assertTrue(userRepository.findById(user.getId()).isPresent());
}
}

View File

@@ -0,0 +1,83 @@
package com.krrishg.service;
import jakarta.mail.Session;
import jakarta.mail.internet.MimeMessage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mail.javamail.JavaMailSender;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Properties;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class EmailServiceTest {
@Mock
private JavaMailSender mailSender;
private EmailService emailService;
@BeforeEach
void setUp() {
emailService = new EmailService(mailSender, "http://localhost:4200", "noreply@indie.app");
}
@Test
void sendsEmailWithCorrectRecipientsAndSubject() throws Exception {
MimeMessage message = new MimeMessage(Session.getDefaultInstance(new Properties()));
when(mailSender.createMimeMessage()).thenReturn(message);
emailService.sendVerificationEmail("user@example.com", "User", "token123");
verify(mailSender).send(message);
String raw = writeMessage(message);
assertTrue(raw.contains("From: noreply@indie.app"));
assertTrue(raw.contains("To: user@example.com"));
assertTrue(raw.contains("Subject: Verify your Indie account"));
}
@Test
void emailBodyContainsVerificationLink() throws Exception {
MimeMessage message = new MimeMessage(Session.getDefaultInstance(new Properties()));
when(mailSender.createMimeMessage()).thenReturn(message);
emailService.sendVerificationEmail("user@example.com", "User", "test-token-123");
String raw = writeMessage(message);
assertTrue(raw.contains("http://localhost:4200/verify-email?token=test-token-123"));
assertTrue(raw.contains("Hi User,"));
}
@Test
void usesEmailPrefixWhenNameIsNull() throws Exception {
MimeMessage message = new MimeMessage(Session.getDefaultInstance(new Properties()));
when(mailSender.createMimeMessage()).thenReturn(message);
emailService.sendVerificationEmail("john.doe@example.com", null, "token123");
String raw = writeMessage(message);
assertTrue(raw.contains("Hi john.doe,"));
}
@Test
void propagatesExceptionFromMailSender() {
when(mailSender.createMimeMessage()).thenThrow(new RuntimeException("mail error"));
RuntimeException ex = assertThrows(RuntimeException.class,
() -> emailService.sendVerificationEmail("user@example.com", "User", "token123"));
assertEquals("mail error", ex.getMessage());
}
private String writeMessage(MimeMessage message) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
message.writeTo(baos);
return baos.toString(StandardCharsets.UTF_8);
}
}

View File

@@ -2,6 +2,7 @@ package com.krrishg.service;
import com.krrishg.dto.Reference;
import com.krrishg.dto.SiteStructure;
import com.krrishg.dto.SuggestSectionsResponse;
import com.krrishg.model.GenerationVersion;
import com.krrishg.model.User;
import com.krrishg.support.FakeEncryptionService;
@@ -82,7 +83,9 @@ class LLMServiceTest {
llmService = new LLMService(providerFactory, responseParser, referenceService,
mongoTemplate, userRepository, encryptionService,
resourceLoader, "classpath:prompts/site-generator.txt",
"classpath:prompts/site-refiner.txt");
"classpath:prompts/site-refiner.txt",
"classpath:prompts/suggest-sections.txt",
10, 0.3);
}
@Test
@@ -210,9 +213,11 @@ class LLMServiceTest {
@Test
void refineSiteReturnsRefinedStructure() {
String pageId = UUID.randomUUID().toString();
SiteStructure current = SiteStructure.builder()
.pages(List.of(
SiteStructure.PageStructure.builder()
.id(pageId)
.title("Old")
.slug("old")
.rawHtml("<p>Old content</p>")
@@ -223,16 +228,202 @@ class LLMServiceTest {
geminiProvider.withContent("""
{
"pages": [
{"title": "Refined", "slug": "refined", "rawHtml": "<p>Hero</p>"}
{"id": "__PAGE_ID__", "title": "Refined", "slug": "refined", "rawHtml": "<p>Updated</p>"}
]
}
""");
""".replace("__PAGE_ID__", pageId));
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
SiteStructure result = llmService.refineSite(userId, siteId, current, "Make it better", null, "gemini", null);
assertEquals("Refined", result.getPages().get(0).getTitle());
assertEquals("<p>Updated</p>", result.getPages().get(0).getRawHtml());
verify(mongoTemplate).save(any(GenerationVersion.class));
}
@Test
void refineSitePreservesUnchangedPages() {
String pageId1 = UUID.randomUUID().toString();
String pageId2 = UUID.randomUUID().toString();
SiteStructure current = SiteStructure.builder()
.theme(SiteStructure.ThemeConfig.builder()
.colors(Map.of("primary", "#000", "background", "#fff"))
.build())
.pages(List.of(
SiteStructure.PageStructure.builder()
.id(pageId1)
.title("Home")
.slug("home")
.rawHtml("<p>Home content</p>")
.build(),
SiteStructure.PageStructure.builder()
.id(pageId2)
.title("About")
.slug("about")
.rawHtml("<p>About content</p>")
.build()
))
.build();
geminiProvider.withContent("""
{
"theme": {
"colors": {
"primary": "#1a1a2e",
"accent": "#e94560"
}
},
"pages": [
{"id": "__PAGE_ID__", "rawHtml": "<p>Updated home</p>"}
]
}
""".replace("__PAGE_ID__", pageId1));
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
SiteStructure result = llmService.refineSite(userId, siteId, current, "Change primary color", null, "gemini", null);
assertEquals("#1a1a2e", result.getTheme().getColors().get("primary"));
assertEquals("#fff", result.getTheme().getColors().get("background"));
assertNotNull(result.getTheme().getColors().get("accent"));
assertEquals(2, result.getPages().size());
assertEquals("<p>Updated home</p>", result.getPages().get(0).getRawHtml());
assertEquals("About", result.getPages().get(1).getTitle());
assertEquals("<p>About content</p>", result.getPages().get(1).getRawHtml());
verify(mongoTemplate).save(any(GenerationVersion.class));
}
@Test
void refineSiteDeletesPage() {
String pageId1 = UUID.randomUUID().toString();
String pageId2 = UUID.randomUUID().toString();
SiteStructure current = SiteStructure.builder()
.pages(List.of(
SiteStructure.PageStructure.builder()
.id(pageId1)
.title("Home")
.slug("home")
.rawHtml("<p>Home</p>")
.build(),
SiteStructure.PageStructure.builder()
.id(pageId2)
.title("About")
.slug("about")
.rawHtml("<p>About</p>")
.build()
))
.build();
geminiProvider.withContent("""
{
"pages": [
{"id": "__PAGE_ID__", "deleted": true}
]
}
""".replace("__PAGE_ID__", pageId1));
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
SiteStructure result = llmService.refineSite(userId, siteId, current, "Remove Home page", null, "gemini", null);
assertEquals(1, result.getPages().size());
assertEquals("About", result.getPages().get(0).getTitle());
verify(mongoTemplate).save(any(GenerationVersion.class));
}
@Test
void refineSiteAddsNewPage() {
String existingId = UUID.randomUUID().toString();
SiteStructure current = SiteStructure.builder()
.pages(List.of(
SiteStructure.PageStructure.builder()
.id(existingId)
.title("Home")
.slug("home")
.rawHtml("<p>Home</p>")
.build()
))
.build();
geminiProvider.withContent("""
{
"pages": [
{"title": "Contact", "slug": "contact", "rawHtml": "<p>Contact form</p>"}
]
}
""");
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
SiteStructure result = llmService.refineSite(userId, siteId, current, "Add Contact page", null, "gemini", null);
assertEquals(2, result.getPages().size());
assertEquals("Contact", result.getPages().get(1).getTitle());
verify(mongoTemplate).save(any(GenerationVersion.class));
}
@Test
void pruneVersionsDeletesOldestVersions() {
lenient().when(mongoTemplate.count(any(Query.class), eq(GenerationVersion.class))).thenReturn(15L);
List<GenerationVersion> oldVersions = List.of(
GenerationVersion.builder().id("v1").siteId(siteId).versionNumber(1).build(),
GenerationVersion.builder().id("v2").siteId(siteId).versionNumber(2).build(),
GenerationVersion.builder().id("v3").siteId(siteId).versionNumber(3).build(),
GenerationVersion.builder().id("v4").siteId(siteId).versionNumber(4).build(),
GenerationVersion.builder().id("v5").siteId(siteId).versionNumber(5).build()
);
lenient().when(mongoTemplate.find(any(Query.class), eq(GenerationVersion.class))).thenReturn(oldVersions);
int deleted = llmService.pruneVersions(siteId, 10);
assertEquals(5, deleted);
}
@Test
void pruneVersionsDoesNothingWhenUnderLimit() {
lenient().when(mongoTemplate.count(any(Query.class), eq(GenerationVersion.class))).thenReturn(8L);
int deleted = llmService.pruneVersions(siteId, 10);
assertEquals(0, deleted);
}
@Test
void saveVersionTriggersAutoCleanup() {
String pageId = UUID.randomUUID().toString();
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
lenient().when(mongoTemplate.count(any(Query.class), eq(GenerationVersion.class))).thenReturn(15L);
lenient().when(mongoTemplate.find(any(Query.class), eq(GenerationVersion.class))).thenReturn(List.of(
GenerationVersion.builder().id("old").siteId(siteId).versionNumber(1).build(),
GenerationVersion.builder().id("older").siteId(siteId).versionNumber(2).build(),
GenerationVersion.builder().id("oldest").siteId(siteId).versionNumber(3).build(),
GenerationVersion.builder().id("ancient").siteId(siteId).versionNumber(4).build(),
GenerationVersion.builder().id("prehistoric").siteId(siteId).versionNumber(5).build()
));
geminiProvider.withContent("""
{
"pages": [
{"id": "__PAGE_ID__", "title": "Home", "slug": "home", "rawHtml": "<p>Test</p>"}
]
}
""".replace("__PAGE_ID__", pageId));
SiteStructure current = SiteStructure.builder()
.pages(List.of(
SiteStructure.PageStructure.builder()
.id(pageId)
.title("Home")
.slug("home")
.rawHtml("<p>Old</p>")
.build()
))
.build();
llmService.refineSite(userId, siteId, current, "Update", null, "gemini", null);
verify(mongoTemplate).save(any(GenerationVersion.class));
}
@@ -304,4 +495,57 @@ class LLMServiceTest {
assertThrows(IllegalArgumentException.class, () -> llmService.deleteVersion("nonexistent"));
verify(mongoTemplate, never()).remove(any());
}
@Test
void suggestSectionsReturnsParsedSuggestions() {
geminiProvider.withContent("""
[
{"title": "Home", "description": "Hero section", "slug": "home",
"patterns": [
{"title": "Full-screen hero", "description": "Large image with text overlay"},
{"title": "Split layout", "description": "Text left, image right"}
]},
{"title": "About", "description": "Bio section", "slug": "about",
"patterns": [
{"title": "Timeline", "description": "Vertical story timeline"}
]},
{"title": "Gallery", "description": "Portfolio grid", "slug": "gallery", "patterns": []}
]
""");
SuggestSectionsResponse response = llmService.suggestSections(
userId, "photography portfolio", "gemini", null);
assertEquals(3, response.sections().size());
assertEquals("Home", response.sections().get(0).title());
assertEquals("gallery", response.sections().get(2).slug());
assertEquals(2, response.sections().get(0).patterns().size());
assertEquals("Full-screen hero", response.sections().get(0).patterns().get(0).title());
assertEquals("Timeline", response.sections().get(1).patterns().get(0).title());
assertEquals(0, response.sections().get(2).patterns().size());
}
@Test
void suggestSectionsThrowsWhenEmpty() {
geminiProvider.withContent("[]");
assertThrows(IllegalArgumentException.class,
() -> llmService.suggestSections(userId, "nothing", "gemini", null));
}
@Test
void suggestSectionsUsesProvidedApiKey() {
geminiProvider.withContent("""
[
{"title": "Home", "description": "Hero", "slug": "home", "patterns": []}
]
""");
SuggestSectionsResponse response = llmService.suggestSections(
userId, "site", "gemini", "override-key");
assertEquals(1, response.sections().size());
assertEquals("Home", response.sections().get(0).title());
}
}

View File

@@ -1,13 +1,19 @@
package com.krrishg.service;
import com.krrishg.model.SelfHostedConfig;
import com.krrishg.service.RenderService.SiteOutput;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
@@ -48,6 +54,30 @@ class SshRsyncDeployerTest {
assertEquals("https://example.com/", url);
}
@Test
void buildUrlHandlesDomainWithTrailingSlash() {
SelfHostedConfig config = SelfHostedConfig.builder()
.domain("example.com")
.sslEnabled(true)
.build();
String url = deployer.buildUrl(config);
assertEquals("https://example.com/", url);
}
@Test
void buildUrlHandlesIpAddress() {
SelfHostedConfig config = SelfHostedConfig.builder()
.domain("192.168.1.100")
.sslEnabled(false)
.build();
String url = deployer.buildUrl(config);
assertEquals("http://192.168.1.100/", url);
}
@Test
void buildNginxConfigWithSslIncludesRedirect() throws Exception {
Method method = SshRsyncDeployer.class.getDeclaredMethod(
@@ -76,4 +106,70 @@ class SshRsyncDeployerTest {
assertFalse(config.contains("ssl_certificate"));
assertFalse(config.contains("return 301"));
}
@Test
void buildNginxConfigWithoutSslHasNoSslRedirect() throws Exception {
Method method = SshRsyncDeployer.class.getDeclaredMethod(
"buildNginxConfig", String.class, String.class, boolean.class);
method.setAccessible(true);
String config = (String) method.invoke(deployer, "example.com", "/var/www/public", false);
assertFalse(config.contains("return 301"));
assertFalse(config.contains("ssl_certificate"));
assertFalse(config.contains("listen 443"));
}
@Test
void provisionSslThrowsWhenSslEmailIsNull() throws Exception {
Method method = SshRsyncDeployer.class.getDeclaredMethod(
"provisionSsl", String.class, SelfHostedConfig.class, String.class);
method.setAccessible(true);
SelfHostedConfig config = SelfHostedConfig.builder()
.domain("example.com")
.sslEmail(null)
.build();
var thrown = assertThrows(Exception.class,
() -> method.invoke(deployer, "/path/key", config, "/var/www/public"));
assertTrue(thrown.getCause().getMessage().contains("SSL email is required"));
}
@Test
void writeSiteFilesCreatesDirectoryStructure() throws Exception {
Method method = SshRsyncDeployer.class.getDeclaredMethod(
"writeSiteFiles", Path.class, SiteOutput.class);
method.setAccessible(true);
Path tempDir = Files.createTempDirectory("test-deploy");
try {
Map<String, String> pages = Map.of(
"home", "<h1>Home</h1>",
"about", "<h1>About</h1>"
);
SiteOutput output = new SiteOutput("body {}", "console.log('hi')", pages);
method.invoke(deployer, tempDir, output);
Path siteDir = tempDir.resolve("site");
assertTrue(Files.exists(siteDir.resolve("index.html")));
assertTrue(Files.exists(siteDir.resolve("style.css")));
assertTrue(Files.exists(siteDir.resolve("script.js")));
assertTrue(Files.exists(siteDir.resolve("about/index.html")));
assertTrue(Files.exists(siteDir.resolve("404.html")));
assertEquals("<h1>Home</h1>", Files.readString(siteDir.resolve("index.html")));
assertEquals("<h1>About</h1>", Files.readString(siteDir.resolve("about/index.html")));
} finally {
try (var walk = Files.walk(tempDir)) {
walk.sorted(Comparator.reverseOrder())
.forEach(p -> {
try {
Files.deleteIfExists(p);
} catch (IOException e) {
}
});
}
}
}
}

View File

@@ -38,6 +38,7 @@ class ProviderFactoryTest {
"https://generativelanguage.googleapis.com/v1beta/models",
"gpt-4o-mini",
"https://api.openai.com/v1/chat/completions",
Duration.ofSeconds(180),
restTemplateBuilder
);
}

View File

@@ -14,7 +14,7 @@ public class FakeProviderFactory extends ProviderFactory {
public FakeProviderFactory() {
super("gemini-2.0-flash", "https://generativelanguage.googleapis.com/v1beta/models",
"gpt-4o-mini", "https://api.openai.com/v1/chat/completions",
null);
null, null);
}
public FakeProviderFactory withProvider(String name, FakeLLMProvider provider) {

View File

@@ -9,7 +9,7 @@ public class StubEmailService extends EmailService {
private int sendCount;
public StubEmailService() {
super(null, "http://localhost:4200");
super(null, "http://localhost:4200", "noreply@indie.app");
}
@Override

40
docker-compose.v3.yml Normal file
View File

@@ -0,0 +1,40 @@
version: '3'
services:
postgres:
image: postgres:16-alpine
network_mode: host
environment:
POSTGRES_DB: indie
POSTGRES_USER: indie
POSTGRES_PASSWORD: indie_dev
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U indie"]
interval: 5s
timeout: 5s
retries: 5
mongodb:
image: mongo:7
network_mode: host
environment:
MONGO_INITDB_DATABASE: indie
volumes:
- mongodata:/data/db
minio:
image: minio/minio:latest
network_mode: host
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: indieadmin
MINIO_ROOT_PASSWORD: indiesecret
volumes:
- miniodata:/data
volumes:
pgdata:
mongodata:
miniodata:

View File

@@ -10,5 +10,7 @@ server {
location /api/ {
proxy_pass http://backend:8080;
proxy_read_timeout 180s;
proxy_connect_timeout 30s;
}
}

View File

@@ -18,6 +18,11 @@ export const routes: Routes = [
loadComponent: () => import('./auth/register/register.component').then(m => m.RegisterComponent),
canActivate: [LoginGuard],
},
{
path: 'resend-verification',
loadComponent: () => import('./auth/resend-verification/resend-verification.component').then(m => m.ResendVerificationComponent),
canActivate: [LoginGuard],
},
{
path: 'dashboard',
loadComponent: () => import('./dashboard/dashboard.component').then(m => m.DashboardComponent),

View File

@@ -53,8 +53,10 @@ import { NgIf } from '@angular/common';
</div>
<button mat-flat-button color="primary" type="submit" [disabled]="loading" class="w-full py-2">
<mat-spinner *ngIf="loading" diameter="20" class="inline-block mr-2"></mat-spinner>
<span class="flex items-center justify-center">
<mat-spinner *ngIf="loading" diameter="20" class="mr-2"></mat-spinner>
{{ loading ? 'Signing in...' : 'Sign In' }}
</span>
</button>
</form>
@@ -62,6 +64,9 @@ import { NgIf } from '@angular/common';
Don't have an account?
<a routerLink="/register" class="text-indigo-600 font-medium hover:underline">Register</a>
</p>
<p class="text-center mt-2 text-sm text-gray-500">
<a routerLink="/resend-verification" class="text-indigo-600 font-medium hover:underline">Resend verification email</a>
</p>
</mat-card>
</div>
`,

View File

@@ -26,12 +26,6 @@ import { AuthService } from '../../core/services/auth.service';
</div>
<form [formGroup]="registerForm" (ngSubmit)="onSubmit()" class="flex flex-col gap-4">
<mat-form-field appearance="outline" class="w-full">
<mat-label>Name</mat-label>
<input matInput formControlName="name" placeholder="Your name" />
<mat-error *ngIf="registerForm.get('name')?.hasError('required')">Name is required</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Email</mat-label>
<input matInput type="email" formControlName="email" placeholder="you@example.com" />
@@ -39,18 +33,13 @@ import { AuthService } from '../../core/services/auth.service';
<mat-error *ngIf="registerForm.get('email')?.hasError('email')">Invalid email format</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Password</mat-label>
<input matInput type="password" formControlName="password" />
<mat-error *ngIf="registerForm.get('password')?.hasError('required')">Password is required</mat-error>
<mat-error *ngIf="registerForm.get('password')?.hasError('minlength')">At least 8 characters</mat-error>
</mat-form-field>
<div *ngIf="error" class="text-red-600 text-sm">{{ error }}</div>
<button mat-flat-button color="primary" type="submit" [disabled]="loading" class="w-full py-2">
<mat-spinner *ngIf="loading" diameter="20" class="inline-block mr-2"></mat-spinner>
<span class="flex items-center justify-center">
<mat-spinner *ngIf="loading" diameter="20" class="mr-2"></mat-spinner>
{{ loading ? 'Creating account...' : 'Create Account' }}
</span>
</button>
</form>
@@ -64,9 +53,7 @@ import { AuthService } from '../../core/services/auth.service';
})
export class RegisterComponent {
registerForm = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
loading = false;
error = '';
@@ -81,8 +68,8 @@ export class RegisterComponent {
if (this.registerForm.invalid) return;
this.loading = true;
this.error = '';
const { name, email, password } = this.registerForm.value;
this.authService.register(name!, email!, password!).subscribe({
const { email } = this.registerForm.value;
this.authService.register(email!).subscribe({
next: () => this.router.navigate(['/verify-email'], { queryParams: { email: email! } }),
error: (err) => {
this.error = err.error?.message || err.error?.error || 'Registration failed';

View File

@@ -0,0 +1,83 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { NgIf } from '@angular/common';
import { AuthService } from '../../core/services/auth.service';
@Component({
selector: 'app-resend-verification',
standalone: true,
imports: [
RouterLink, ReactiveFormsModule, NgIf,
MatCardModule, MatFormFieldModule, MatInputModule, MatButtonModule,
MatProgressSpinnerModule,
],
template: `
<div class="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<mat-card class="w-full max-w-md p-8">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-indigo-600">Indie</h1>
<p class="text-gray-500 mt-1">Resend verification email</p>
</div>
<form [formGroup]="resendForm" (ngSubmit)="onSubmit()" class="flex flex-col gap-4">
<mat-form-field appearance="outline" class="w-full">
<mat-label>Email</mat-label>
<input matInput type="email" formControlName="email" placeholder="you@example.com" />
<mat-error *ngIf="resendForm.get('email')?.hasError('required')">Email is required</mat-error>
<mat-error *ngIf="resendForm.get('email')?.hasError('email')">Invalid email format</mat-error>
</mat-form-field>
<div *ngIf="message" [class.text-green-600]="success" [class.text-red-600]="!success" class="text-sm text-center">{{ message }}</div>
<button mat-flat-button color="primary" type="submit" [disabled]="loading" class="w-full py-2">
<span class="flex items-center justify-center">
<mat-spinner *ngIf="loading" diameter="20" class="mr-2"></mat-spinner>
{{ loading ? 'Sending...' : 'Send verification email' }}
</span>
</button>
</form>
<p class="text-center mt-6 text-sm text-gray-500">
<a routerLink="/login" class="text-indigo-600 font-medium hover:underline">Back to login</a>
</p>
</mat-card>
</div>
`,
})
export class ResendVerificationComponent {
resendForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
});
loading = false;
message = '';
success = false;
constructor(
private fb: FormBuilder,
private authService: AuthService,
) {}
onSubmit(): void {
if (this.resendForm.invalid) return;
this.loading = true;
this.message = '';
this.authService.resendVerification(this.resendForm.value.email!).subscribe({
next: () => {
this.message = 'If this email is registered, a verification link has been sent.';
this.success = true;
this.loading = false;
},
error: () => {
this.message = 'Failed to send. Please try again later.';
this.success = false;
this.loading = false;
},
});
}
}

View File

@@ -38,8 +38,10 @@ type PageState = 'check-email' | 'verifying' | 'verified' | 'set-password' | 'er
<div *ngIf="resendError" class="text-red-600 text-sm mb-4">{{ resendError }}</div>
<button mat-stroked-button (click)="resend()" [disabled]="resending" class="w-full mb-3">
<mat-spinner *ngIf="resending" diameter="16" class="inline-block mr-2"></mat-spinner>
<span class="flex items-center justify-center">
<mat-spinner *ngIf="resending" diameter="16" class="mr-2"></mat-spinner>
{{ resending ? 'Sending...' : 'Resend verification email' }}
</span>
</button>
<a routerLink="/login" class="block text-sm text-indigo-600 font-medium hover:underline">Back to login</a>
@@ -60,8 +62,14 @@ type PageState = 'check-email' | 'verifying' | 'verified' | 'set-password' | 'er
</div>
<div *ngIf="state === 'set-password'">
<h2 class="text-xl font-semibold text-gray-800 mb-4 text-center">Set your password</h2>
<h2 class="text-xl font-semibold text-gray-800 mb-4 text-center">Set your name and password</h2>
<form [formGroup]="passwordForm" (ngSubmit)="onSetPassword()" class="flex flex-col gap-4">
<mat-form-field appearance="outline" class="w-full">
<mat-label>Name</mat-label>
<input matInput formControlName="name" placeholder="Your name" />
<mat-error *ngIf="passwordForm.get('name')?.hasError('required')">Name is required</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="w-full">
<mat-label>New Password</mat-label>
<input matInput type="password" formControlName="password" />
@@ -79,8 +87,10 @@ type PageState = 'check-email' | 'verifying' | 'verified' | 'set-password' | 'er
<div *ngIf="passwordError" class="text-red-600 text-sm">{{ passwordError }}</div>
<button mat-flat-button color="primary" type="submit" [disabled]="passwordLoading || passwordForm.invalid" class="w-full py-2">
<mat-spinner *ngIf="passwordLoading" diameter="20" class="inline-block mr-2"></mat-spinner>
<span class="flex items-center justify-center">
<mat-spinner *ngIf="passwordLoading" diameter="20" class="mr-2"></mat-spinner>
{{ passwordLoading ? 'Setting password...' : 'Activate Account' }}
</span>
</button>
</form>
</div>
@@ -116,6 +126,7 @@ export class VerifyEmailComponent implements OnInit {
passwordError = '';
passwordForm = this.fb.group({
name: ['', Validators.required],
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required],
}, { validators: this.passwordsMatch });
@@ -207,7 +218,7 @@ export class VerifyEmailComponent implements OnInit {
this.passwordLoading = true;
this.passwordError = '';
this.authService.setPassword(this.setupToken, this.passwordForm.value.password!).subscribe({
this.authService.setPassword(this.setupToken, this.passwordForm.value.password!, this.passwordForm.value.name!).subscribe({
next: () => {
this.router.navigate(['/login'], { queryParams: { verified: 'true' } });
},

View File

@@ -73,6 +73,40 @@ export interface GenerationVersion {
createdAt: string;
}
export interface PatternSuggestion {
title: string;
description: string;
}
export interface SectionSuggestion {
title: string;
description: string;
slug: string;
patterns: PatternSuggestion[];
}
export interface SuggestSectionsRequest {
briefDescription: string;
provider: string;
apiKey?: string;
baseUrl?: string;
model?: string;
}
export interface SuggestSectionsResponse {
sections: SectionSuggestion[];
}
export interface SelectedSection {
title: string;
description: string;
slug: string;
selected: boolean;
customNotes: string;
selectedPattern: string | null;
customPatternDesc: string;
}
export interface LLMKeyEntry {
configured: boolean;
baseUrl?: string;

View File

@@ -14,8 +14,8 @@ export class AuthService {
constructor(private http: HttpClient) {}
register(name: string, email: string, password: string): Observable<{ message: string }> {
return this.http.post<{ message: string }>('/api/auth/register', { name, email, password });
register(email: string): Observable<{ message: string }> {
return this.http.post<{ message: string }>('/api/auth/register', { email });
}
login(email: string, password: string): Observable<AuthResponse> {
@@ -41,8 +41,8 @@ export class AuthService {
return this.http.post<{ message: string }>('/api/auth/resend-verification', { email });
}
setPassword(setupToken: string, password: string): Observable<void> {
return this.http.post<void>('/api/auth/set-password', { setupToken, password });
setPassword(setupToken: string, password: string, name: string): Observable<void> {
return this.http.post<void>('/api/auth/set-password', { setupToken, password, name });
}
getAccessToken(): string | null {

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { LLMGenerateRequest, LLMRefineRequest, SiteStructure, GenerationVersion } from '../models/llm';
import { LLMGenerateRequest, LLMRefineRequest, SiteStructure, GenerationVersion, SuggestSectionsRequest, SuggestSectionsResponse } from '../models/llm';
@Injectable({ providedIn: 'root' })
export class LlmApiService {
@@ -15,6 +15,10 @@ export class LlmApiService {
return this.http.post<SiteStructure>('/api/llm/refine', request);
}
suggestSections(request: SuggestSectionsRequest): Observable<SuggestSectionsResponse> {
return this.http.post<SuggestSectionsResponse>('/api/llm/suggest-sections', request);
}
getVersions(siteId: string): Observable<GenerationVersion[]> {
return this.http.get<GenerationVersion[]>(`/api/llm/versions/${siteId}`);
}
@@ -26,4 +30,8 @@ export class LlmApiService {
deleteVersion(versionId: string): Observable<void> {
return this.http.delete<void>(`/api/llm/versions/${versionId}`);
}
pruneVersions(siteId: string, keep: number = 10): Observable<{deleted: number}> {
return this.http.post<{deleted: number}>(`/api/llm/versions/${siteId}/prune`, { keep });
}
}

View File

@@ -0,0 +1,93 @@
import { Injectable, inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { BehaviorSubject, Subject, Subscription } from 'rxjs';
import { LlmApiService } from '../core/services/llm-api.service';
import { LlmSessionService } from './llm-session.service';
import { LLMGenerateRequest, LLMRefineRequest, SiteStructure } from '../core/models/llm';
export interface GenerationState {
status: 'idle' | 'generating' | 'completed' | 'error';
result: SiteStructure | null;
error: string | null;
}
@Injectable({ providedIn: 'root' })
export class GenerationService {
private api = inject(LlmApiService);
private session = inject(LlmSessionService);
private snackBar = inject(MatSnackBar);
private generationSubscription: Subscription | null = null;
private stateSubject = new BehaviorSubject<GenerationState>({
status: 'idle', result: null, error: null,
});
private refreshVersionsSubject = new Subject<string>();
readonly state$ = this.stateSubject.asObservable();
readonly refreshVersions$ = this.refreshVersionsSubject.asObservable();
generate(request: LLMGenerateRequest, siteId: string): void {
this.cancel();
this.stateSubject.next({ status: 'generating', result: null, error: null });
this.session.setGenerating(true);
this.session.clearError();
this.generationSubscription = this.api.generate(request).subscribe({
next: (data) => {
this.stateSubject.next({ status: 'completed', result: data, error: null });
this.session.setResult(data);
this.session.setCurrentVersionId(null);
this.refreshVersionsSubject.next(siteId);
this.snackBar.open('Site generated successfully!', 'Close', { duration: 5000 });
this.generationSubscription = null;
},
error: (err) => {
const msg = err.error?.error || err.statusText || 'Failed to connect to server';
this.stateSubject.next({ status: 'error', result: null, error: msg });
this.session.setError(msg);
this.snackBar.open('Generation failed: ' + msg, 'Dismiss', { duration: 8000 });
this.generationSubscription = null;
},
});
}
refine(request: LLMRefineRequest, siteId: string): void {
this.cancel();
this.stateSubject.next({ status: 'generating', result: null, error: null });
this.session.setGenerating(true);
this.session.clearError();
this.generationSubscription = this.api.refine(request).subscribe({
next: (data) => {
this.stateSubject.next({ status: 'completed', result: data, error: null });
this.session.setResult(data);
this.session.setCurrentVersionId(null);
this.refreshVersionsSubject.next(siteId);
this.snackBar.open('Site refined successfully!', 'Close', { duration: 5000 });
this.generationSubscription = null;
},
error: (err) => {
const msg = err.error?.error || err.statusText || 'Failed to connect to server';
this.stateSubject.next({ status: 'error', result: null, error: msg });
this.session.setError(msg);
this.snackBar.open('Refinement failed: ' + msg, 'Dismiss', { duration: 8000 });
this.generationSubscription = null;
},
});
}
cancel(): void {
if (this.generationSubscription) {
this.generationSubscription.unsubscribe();
this.generationSubscription = null;
}
this.stateSubject.next({ status: 'idle', result: null, error: null });
this.session.setGenerating(false);
this.session.clearError();
}
resetState(): void {
this.cancel();
this.stateSubject.next({ status: 'idle', result: null, error: null });
}
}

View File

@@ -19,10 +19,14 @@ import { takeUntil } from 'rxjs/operators';
import { LlmApiService } from '../core/services/llm-api.service';
import { LlmConfigService } from '../core/services/llm-config.service';
import { LlmSessionService } from './llm-session.service';
import { SiteStructure, GenerationVersion, LLMKeyStatus } from '../core/models/llm';
import { GenerationService } from './generation.service';
import { SiteStructure, GenerationVersion, LLMKeyStatus, SectionSuggestion } from '../core/models/llm';
import { SectionConfiguratorComponent, SectionConfig } from './section-configurator/section-configurator.component';
import { PreviewDialogComponent } from './preview-dialog/preview-dialog.component';
import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dialog';
type Phase = 'idle' | 'suggesting' | 'configuring' | 'generating';
@Component({
selector: 'app-llm-workspace',
standalone: true,
@@ -32,6 +36,7 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
MatInputModule, MatFormFieldModule, MatProgressSpinnerModule, MatSelectModule,
MatSnackBarModule, MatCardModule,
MatDividerModule, MatExpansionModule, MatDialogModule,
SectionConfiguratorComponent,
],
styles: [`
.provider-select { width: 140px; }
@@ -51,26 +56,24 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
</button>
</mat-toolbar>
<div class="flex-1 overflow-y-auto">
<div class="px-6 py-6 space-y-6">
<mat-card class="p-4">
<mat-card class="p-4" *ngIf="currentPhase === 'idle'">
<mat-card-content class="!p-0">
<mat-form-field appearance="outline" class="w-full">
<mat-label>Describe your website</mat-label>
<mat-label>Describe your website idea</mat-label>
<textarea
matInput
rows="5"
placeholder="e.g. A personal portfolio for a photographer with a gallery page, about section, and contact form. Dark theme with accent gold."
[(ngModel)]="prompt"
[disabled]="(session.generating$ | async) === true"
rows="4"
placeholder="e.g. A photography portfolio for Alex Chen"
[(ngModel)]="briefDescription"
[disabled]="suggesting"
></textarea>
</mat-form-field>
<div class="flex items-center justify-between mt-2">
<span class="text-xs text-gray-400">
Describe the pages, layout, theme, and content you want.
Briefly describe the type of site you want. The AI will suggest sections you can customize.
</span>
<div class="flex items-center gap-2">
<mat-form-field appearance="outline" class="!mb-0 provider-select" *ngIf="configuredProviders.length > 1">
@@ -83,18 +86,88 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
<button
mat-raised-button
color="primary"
[disabled]="!prompt.trim() || (session.generating$ | async) || configuredProviders.length === 0"
(click)="generate()"
[disabled]="!briefDescription.trim() || suggesting || configuredProviders.length === 0"
(click)="suggestSections()"
>
<mat-icon>auto_awesome</mat-icon>
Generate
Plan My Site
</button>
</div>
<div *ngIf="configuredProviders.length === 0 && !(session.generating$ | async)" class="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p class="text-sm text-yellow-800">
No API keys configured. Go to <a class="underline font-medium cursor-pointer" (click)="goToSettings()">Settings</a> to add an API key for an AI provider.
</div>
</mat-card-content>
</mat-card>
<div *ngIf="suggesting" class="flex flex-col items-center justify-center py-12 space-y-4">
<mat-spinner diameter="48"></mat-spinner>
<div class="text-center">
<p class="text-lg font-medium text-gray-700">Analyzing your idea...</p>
<p class="text-sm text-gray-400">Finding the right sections for your site</p>
</div>
</div>
<!-- Phase 3: Section Configurator -->
<div *ngIf="currentPhase === 'configuring'" class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900">
{{ configStepTitle }}
</h3>
<p class="text-sm text-gray-500">
{{ configStepSubtitle }}
</p>
</div>
<button mat-stroked-button (click)="backToIdle()">
<mat-icon>arrow_back</mat-icon>
Back
</button>
</div>
<app-section-configurator
[suggestions]="sectionSuggestions"
(configComplete)="onConfigComplete($event)"
(stepChange)="onConfigStepChange($event)"
></app-section-configurator>
</div>
<!-- API key setup (shown when no providers configured) -->
<mat-card *ngIf="configuredProviders.length === 0 && !(session.generating$ | async) && currentPhase !== 'generating'" class="p-4" appearance="outlined">
<mat-card-content class="!p-0">
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Configure AI Provider</h4>
<p class="text-sm text-gray-600 mb-3">
Add an API key to enable AI site generation. Keys are encrypted at rest.
</p>
<div class="space-y-3">
<mat-form-field appearance="outline" class="w-full">
<mat-label>Provider</mat-label>
<mat-select [(ngModel)]="setupProvider">
<mat-option value="gemini">Gemini</mat-option>
<mat-option value="openai">OpenAI / Compatible</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="w-full">
<mat-label>API Key</mat-label>
<input matInput [type]="setupKeyVisible ? 'text' : 'password'" [(ngModel)]="setupApiKey" />
<button mat-icon-button matSuffix (click)="setupKeyVisible = !setupKeyVisible" type="button"
[attr.aria-label]="setupKeyVisible ? 'Hide key' : 'Show key'">
<mat-icon>{{ setupKeyVisible ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
</mat-form-field>
<mat-form-field appearance="outline" class="w-full" *ngIf="setupProvider === 'openai'">
<mat-label>Base URL (optional)</mat-label>
<input matInput placeholder="https://api.deepseek.com/v1" [(ngModel)]="setupBaseUrl" />
<mat-hint>For OpenAI-compatible providers like DeepSeek</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Model (optional)</mat-label>
<input matInput placeholder="{{ setupProvider === 'gemini' ? 'gemini-2.0-flash-exp' : 'gpt-4o-mini' }}" [(ngModel)]="setupModel" />
<mat-hint>Override the default model name</mat-hint>
</mat-form-field>
<div class="flex justify-end">
<button mat-flat-button color="primary" [disabled]="!setupApiKey.trim() || setupSaving" (click)="saveSetupKey()">
<mat-icon>save</mat-icon>
{{ setupSaving ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</mat-card-content>
</mat-card>
@@ -107,6 +180,15 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
</div>
</div>
<div *ngIf="suggestError" class="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
<mat-icon class="text-red-500 mt-0.5">error_outline</mat-icon>
<div class="flex-1">
<p class="text-sm font-medium text-red-800">Suggestion failed</p>
<p class="text-sm text-red-600 mt-1">{{ suggestError }}</p>
<button mat-button color="warn" class="mt-2" (click)="suggestError = null">Dismiss</button>
</div>
</div>
<div *ngIf="session.error$ | async as error" class="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
<mat-icon class="text-red-500 mt-0.5">error_outline</mat-icon>
<div class="flex-1">
@@ -122,14 +204,26 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
<mat-card-content class="!p-0">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">History</h4>
<span class="text-xs text-gray-400">{{ versions.length }} version{{ versions.length !== 1 ? 's' : '' }}</span>
<div class="flex items-center gap-2">
<button
*ngIf="((session.versions$ | async)?.length || 0) > 10"
mat-stroked-button
class="!text-[10px] !leading-none !min-w-0 !px-2 !py-0.5 !h-auto"
color="warn"
(click)="pruneOldVersions()"
title="Keep only the 10 most recent versions"
>
Clear old
</button>
<span class="text-xs text-gray-400">{{ (session.versions$ | async)?.length || 0 }} version{{ ((session.versions$ | async)?.length || 0) !== 1 ? 's' : '' }}</span>
</div>
</div>
<div *ngIf="session.loadingVersions$ | async" class="flex justify-center py-4">
<mat-spinner diameter="20"></mat-spinner>
</div>
<div class="space-y-1 max-h-[calc(100vh-20rem)] overflow-y-auto overflow-x-hidden">
<div
*ngFor="let v of versions"
*ngFor="let v of (session.versions$ | async) || []"
(click)="switchToVersion(v)"
class="w-full p-3 rounded-lg border transition-colors duration-150 flex flex-col gap-0.5 cursor-pointer group min-w-0"
[class.border-blue-500]="isCurrentVersion(v.id)"
@@ -151,7 +245,7 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
</div>
<p class="text-xs text-gray-600 truncate">{{ truncatePrompt(v.prompt, 50) }}</p>
</div>
<p *ngIf="versions.length === 0 && !(session.loadingVersions$ | async)" class="text-xs text-gray-400 py-2 text-center">
<p *ngIf="((session.versions$ | async) || []).length === 0 && !(session.loadingVersions$ | async)" class="text-xs text-gray-400 py-2 text-center">
No versions yet
</p>
</div>
@@ -274,11 +368,11 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
</div>
</div>
<div *ngIf="!(session.generating$ | async) && !(session.currentResult$ | async) && !(session.error$ | async)" class="flex flex-col items-center justify-center py-16 text-gray-400">
<div *ngIf="configuredProviders.length > 0 && currentPhase === 'idle' && !(session.currentResult$ | async) && !(session.error$ | async)" class="flex flex-col items-center justify-center py-16 text-gray-400">
<mat-icon class="text-6xl mb-4" style="width: auto; height: auto; font-size: 4rem;">auto_awesome</mat-icon>
<h3 class="text-xl font-medium text-gray-500 mb-2">Describe your dream site</h3>
<p class="text-sm text-gray-400 text-center max-w-md">
Tell the AI what kind of website you want — pages, style, content — and it will generate a complete site structure ready for you to customize.
Tell the AI what kind of website you want. It will suggest pages and sections that you can customize before generating.
</p>
</div>
</div>
@@ -293,31 +387,58 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
private llmConfigService = inject(LlmConfigService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
private generationService = inject(GenerationService);
session = inject(LlmSessionService);
currentPhase: Phase = 'idle';
briefDescription = '';
sectionSuggestions: SectionSuggestion[] = [];
sectionConfig: SectionConfig | null = null;
configStepTitle = 'Customize Your Site';
configStepSubtitle = '';
suggestError: string | null = null;
suggesting = false;
prompt = '';
refinePrompt = '';
showRefine = false;
siteId: string | null = null;
versions: GenerationVersion[] = [];
llmKeyStatus: LLMKeyStatus = {};
llmBaseUrls: Record<string, string> = {};
llmModels: Record<string, string> = {};
configuredProviders: string[] = [];
selectedProvider = '';
setupProvider = 'gemini';
setupApiKey = '';
setupBaseUrl = '';
setupModel = '';
setupSaving = false;
setupKeyVisible = false;
private destroy$ = new Subject<void>();
ngOnInit(): void {
this.generationService.refreshVersions$
.pipe(takeUntil(this.destroy$))
.subscribe(() => this.loadVersions());
this.route.paramMap
.pipe(takeUntil(this.destroy$))
.subscribe(params => {
const siteId = params.get('siteId');
if (siteId !== this.siteId) {
this.siteId = siteId;
this.briefDescription = '';
this.prompt = '';
this.refinePrompt = '';
this.showRefine = false;
this.versions = [];
this.currentPhase = 'idle';
this.sectionSuggestions = [];
this.sectionConfig = null;
this.suggestError = null;
this.session.clearHistory();
this.loadVersions();
this.loadLlmConfig();
@@ -357,7 +478,6 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (v) => {
this.versions = v;
this.session.setVersions(v);
if (v.length > 0 && !this.session.currentResult) {
this.api.restoreVersion(v[0].id)
@@ -378,74 +498,132 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
});
}
suggestSections(): void {
if (!this.briefDescription.trim() || !this.siteId || !this.selectedProvider) return;
generate(): void {
if (!this.prompt.trim() || !this.siteId || !this.selectedProvider) return;
this.showRefine = false;
this.session.setPrompt(this.prompt);
this.session.setGenerating(true);
this.session.clearError();
this.suggesting = true;
this.suggestError = null;
const baseUrl = this.llmBaseUrls[this.selectedProvider];
const model = this.llmModels[this.selectedProvider];
this.api.generate({
prompt: this.prompt,
this.api.suggestSections({
briefDescription: this.briefDescription,
provider: this.selectedProvider,
baseUrl,
model,
}).pipe(takeUntil(this.destroy$)).subscribe({
next: (response) => {
this.sectionSuggestions = response.sections;
this.suggesting = false;
this.currentPhase = 'configuring';
},
error: (err) => {
this.suggesting = false;
this.suggestError = err.error?.error || err.statusText || 'Failed to get section suggestions';
},
});
}
onConfigComplete(config: SectionConfig): void {
this.sectionConfig = config;
this.generate();
}
onConfigStepChange(step: string): void {
if (step.startsWith('section-')) {
const idx = parseInt(step.split('-')[1], 10);
const section = this.sectionSuggestions[idx];
this.configStepTitle = section ? `Configure ${section.title}` : 'Configure Sections';
this.configStepSubtitle = 'Choose a layout pattern or describe your own.';
} else if (step === 'theme') {
this.configStepTitle = 'Choose Theme Direction';
this.configStepSubtitle = 'Pick a general aesthetic for your site, or describe your own.';
} else if (step === 'details') {
this.configStepTitle = 'Additional Details';
this.configStepSubtitle = 'Any final preferences for the generated site.';
}
}
backToIdle(): void {
this.currentPhase = 'idle';
this.sectionSuggestions = [];
this.sectionConfig = null;
}
generate(): void {
if (!this.sectionConfig || !this.siteId || !this.selectedProvider) return;
const compiledPrompt = this.buildPrompt();
this.prompt = compiledPrompt;
this.currentPhase = 'generating';
this.showRefine = false;
this.session.setPrompt(compiledPrompt);
const baseUrl = this.llmBaseUrls[this.selectedProvider];
const model = this.llmModels[this.selectedProvider];
this.generationService.generate({
prompt: compiledPrompt,
siteId: this.siteId,
provider: this.selectedProvider,
baseUrl,
model,
})
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (data) => {
this.session.setResult(data);
this.session.setCurrentVersionId(null);
this.loadVersions();
this.snackBar.open('Site generated successfully!', 'Close', { duration: 3000 });
},
error: (err) => {
const msg = err.error?.error || err.statusText || 'Failed to connect to server';
this.session.setError(msg);
},
});
}, this.siteId);
}
private buildPrompt(): string {
const config = this.sectionConfig;
if (!config) return '';
const lines: string[] = [];
lines.push(`Site type: ${this.briefDescription}`);
if (config.themeVibe !== 'Any') {
lines.push(`Theme direction: ${config.themeVibe}`);
}
lines.push('');
lines.push('Pages:');
const selectedSections = config.sections.filter(s => s.selected);
for (const section of selectedSections) {
let desc = section.description;
if (section.customNotes.trim()) {
desc += ' ' + section.customNotes.trim();
}
lines.push(`- ${section.title}: ${desc}`);
}
if (config.additionalRequirements.trim()) {
lines.push('');
lines.push('Additional requirements:');
lines.push(config.additionalRequirements.trim());
}
return lines.join('\n');
}
refine(): void {
const result = this.session.currentResult;
if (!result || !this.refinePrompt.trim() || !this.siteId || !this.selectedProvider) return;
this.session.setGenerating(true);
this.session.clearError();
const baseUrl = this.llmBaseUrls[this.selectedProvider];
const model = this.llmModels[this.selectedProvider];
this.api.refine({
this.generationService.refine({
prompt: this.refinePrompt,
siteId: this.siteId,
provider: this.selectedProvider,
baseUrl,
model,
currentStructure: result,
})
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (data) => {
this.session.setResult(data);
this.session.setCurrentVersionId(null);
this.loadVersions();
}, this.siteId);
this.refinePrompt = '';
this.showRefine = false;
this.snackBar.open('Site refined successfully!', 'Close', { duration: 3000 });
},
error: (err) => {
const msg = err.error?.error || err.statusText || 'Failed to connect to server';
this.session.setError(msg);
},
});
}
deleteVersion(version: GenerationVersion): void {
if (this.versions.length <= 1) {
if (this.session.versions.length <= 1) {
this.snackBar.open('Cannot delete the last version', 'Close', { duration: 3000 });
return;
}
@@ -472,6 +650,23 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
});
}
pruneOldVersions(): void {
if (!this.siteId) return;
this.api.pruneVersions(this.siteId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (result) => {
this.loadVersions();
if (result.deleted > 0) {
this.snackBar.open(`Cleaned up ${result.deleted} old versions`, 'Close', { duration: 3000 });
}
},
error: () => {
this.snackBar.open('Failed to clean up versions', 'Close', { duration: 3000 });
},
});
}
switchToVersion(version: GenerationVersion): void {
if (version.id === this.session.currentVersionId) return;
this.session.setGenerating(true);
@@ -520,7 +715,7 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
}
get currentVersionPrompt(): string | null {
const version = this.versions.find(v => v.id === this.session.currentVersionId);
const version = this.session.versions.find(v => v.id === this.session.currentVersionId);
return version ? version.prompt : null;
}
@@ -528,6 +723,30 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
return prompt.length > maxLen ? prompt.slice(0, maxLen) + '...' : prompt;
}
saveSetupKey(): void {
const key = this.setupApiKey?.trim();
const baseUrl = this.setupBaseUrl?.trim() || undefined;
const model = this.setupModel?.trim() || undefined;
if (!key) return;
this.setupSaving = true;
this.llmConfigService.saveKey(this.setupProvider, key, baseUrl, model)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.setupSaving = false;
this.setupApiKey = '';
this.setupBaseUrl = '';
this.setupModel = '';
this.snackBar.open(`${this.setupProvider} API key saved`, 'Close', { duration: 2000 });
this.loadLlmConfig();
},
error: () => {
this.setupSaving = false;
this.snackBar.open('Failed to save API key', 'Close', { duration: 3000 });
},
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();

View File

@@ -0,0 +1,328 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { NgFor, NgIf } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { SectionSuggestion, SelectedSection, PatternSuggestion } from '../../core/models/llm';
export interface SectionConfig {
sections: SelectedSection[];
themeVibe: string;
additionalRequirements: string;
}
const THEME_VIBES = [
'Dark & Moody',
'Light & Airy',
'Bold & Vibrant',
'Minimal & Corporate',
'Playful & Fun',
'Luxe & Elegant',
'Earthy & Organic',
'Edgy & Urban',
];
@Component({
selector: 'app-section-configurator',
standalone: true,
imports: [
NgFor, NgIf, FormsModule,
MatButtonModule, MatIconModule, MatInputModule,
MatFormFieldModule, MatSelectModule,
],
styles: [`
.crumb { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8rem; font-weight: 500; padding: 0.25rem 0.75rem; border-radius: 9999px; transition: all 0.2s; }
.crumb.active { background: #3b82f6; color: #fff; }
.crumb.completed { color: #22c55e; }
.crumb.pending { color: #9ca3af; }
.crumb-line { flex: 1; height: 1.5px; background: #e5e7eb; min-width: 2rem; }
.crumb-line.completed { background: #22c55e; }
.pattern-option { display: block; width: 100%; text-align: left; border: 1.5px solid #e5e7eb; border-radius: 0.75rem; padding: 0.875rem 1rem; cursor: pointer; transition: all 0.15s; background: #fff; }
.pattern-option:hover { border-color: #93c5fd; background: #fafcff; }
.pattern-option.selected { border-color: #3b82f6; background: #eff6ff; }
`],
template: `
<div class="flex items-center mb-6">
<div class="crumb" [class.active]="phase === 'sections'" [class.completed]="phase !== 'sections'">Sections</div>
<div class="crumb-line" [class.completed]="phase !== 'sections'"></div>
<div class="crumb" [class.active]="phase === 'theme'" [class.completed]="phase === 'details'">Theme</div>
<div class="crumb-line" [class.completed]="phase === 'details'"></div>
<div class="crumb" [class.active]="phase === 'details'">Details</div>
</div>
<div *ngIf="phase === 'sections' && currentSection" class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h4 class="text-base font-semibold text-gray-900">Configure {{ currentSection.title }}</h4>
<p class="text-sm text-gray-500 mt-0.5">Section {{ sectionIndex + 1 }} of {{ sections.length }}</p>
</div>
</div>
<p class="text-sm text-gray-600">{{ currentSection.description }}</p>
<div *ngIf="!currentSection.selected" class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<mat-icon class="text-yellow-600 text-lg">warning_amber</mat-icon>
<span class="text-sm text-yellow-800">This section won't be included</span>
</div>
<button mat-stroked-button class="!text-xs !h-8 !px-3" (click)="currentSection.selected = true">
Include it
</button>
</div>
<div class="space-y-2.5">
<div
*ngFor="let pattern of currentSectionPatterns; let pi = index"
class="pattern-option"
[class.selected]="currentSection.selectedPattern === pattern.title"
(click)="selectPattern(pattern.title)"
>
<div class="flex items-start gap-3">
<div class="w-4 h-4 rounded-full border-2 mt-0.5 shrink-0 flex items-center justify-center"
[class.border-blue-500]="currentSection.selectedPattern === pattern.title"
[class.border-gray-300]="currentSection.selectedPattern !== pattern.title"
>
<div class="w-2 h-2 rounded-full" [class.bg-blue-500]="currentSection.selectedPattern === pattern.title"></div>
</div>
<div>
<div class="text-sm font-medium text-gray-900">{{ pattern.title }}</div>
<div class="text-xs text-gray-500 mt-0.5">{{ pattern.description }}</div>
</div>
</div>
</div>
<div
class="pattern-option"
[class.selected]="currentSection.selectedPattern === '__custom__'"
(click)="selectPattern('__custom__')"
>
<div class="flex items-start gap-3">
<div class="w-4 h-4 rounded-full border-2 mt-0.5 shrink-0 flex items-center justify-center"
[class.border-blue-500]="currentSection.selectedPattern === '__custom__'"
[class.border-gray-300]="currentSection.selectedPattern !== '__custom__'"
>
<div class="w-2 h-2 rounded-full" [class.bg-blue-500]="currentSection.selectedPattern === '__custom__'"></div>
</div>
<div class="flex-1">
<div class="text-sm font-medium text-gray-900">Custom</div>
<div class="text-xs text-gray-500 mt-0.5">Describe your own layout and design for this section.</div>
<div *ngIf="currentSection.selectedPattern === '__custom__'" class="mt-3">
<mat-form-field appearance="outline" class="w-full !mb-0">
<mat-label>Describe your custom layout</mat-label>
<textarea
matInput
rows="2"
placeholder="e.g. Full-width video background with a floating CTA and social links in the bottom corner"
[(ngModel)]="currentSection.customPatternDesc"
></textarea>
</mat-form-field>
</div>
</div>
</div>
</div>
</div>
<div *ngIf="currentSectionPatterns.length === 0" class="mt-3">
<mat-form-field appearance="outline" class="w-full">
<mat-label>Custom notes for {{ currentSection.title }}</mat-label>
<textarea
matInput
rows="2"
placeholder="Describe what you want in this section..."
[(ngModel)]="currentSection.customNotes"
></textarea>
</mat-form-field>
</div>
<div class="mt-6 pt-4 border-t border-gray-100 text-center">
<button mat-button color="warn" (click)="skipSection()">
<mat-icon>remove_circle_outline</mat-icon>
Don't include this section
</button>
</div>
</div>
<div *ngIf="phase === 'theme'">
<h4 class="text-base font-semibold text-gray-900 mb-1">Choose Theme Direction</h4>
<p class="text-sm text-gray-500 mb-4">Pick a general aesthetic for your site, or describe your own.</p>
<mat-form-field appearance="outline" class="w-full sm:w-72">
<mat-label>Theme vibe</mat-label>
<mat-select [(ngModel)]="selectedVibe">
<mat-option value="Any">Any</mat-option>
<mat-option *ngFor="let vibe of themeVibes" [value]="vibe">{{ vibe }}</mat-option>
<mat-option value="__custom__">Custom...</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="w-full" *ngIf="selectedVibe === '__custom__'">
<mat-label>Describe your custom theme</mat-label>
<input
matInput
placeholder="e.g. Cyberpunk neon with glitch effects"
[(ngModel)]="customVibeText"
/>
</mat-form-field>
</div>
<!-- Phase: Details -->
<div *ngIf="phase === 'details'">
<h4 class="text-base font-semibold text-gray-900 mb-1">Additional Details</h4>
<p class="text-sm text-gray-500 mb-4">Anything else you want the site to have — specific features, layout preferences, content ideas.</p>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Additional requirements (optional)</mat-label>
<textarea
matInput
rows="5"
placeholder="e.g. Include a newsletter signup form, add a sticky navigation bar, use a masonry grid layout for the gallery..."
[(ngModel)]="additionalRequirements"
></textarea>
</mat-form-field>
</div>
<div class="flex items-center justify-between mt-6 pt-4 border-t border-gray-200">
<button
mat-stroked-button
(click)="prev()"
[disabled]="phase === 'sections' && sectionIndex === 0"
>
<mat-icon>arrow_back</mat-icon>
Back
</button>
<div class="text-xs text-gray-400">
{{ navLabel }}
</div>
<button
mat-raised-button
color="primary"
(click)="next()"
>
{{ isLastStep ? 'Done' : 'Next' }}
<mat-icon *ngIf="!isLastStep">arrow_forward</mat-icon>
<mat-icon *ngIf="isLastStep">check</mat-icon>
</button>
</div>
`,
})
export class SectionConfiguratorComponent implements OnInit {
@Input() suggestions: SectionSuggestion[] = [];
@Output() configComplete = new EventEmitter<SectionConfig>();
@Output() stepChange = new EventEmitter<string>();
phase: 'sections' | 'theme' | 'details' = 'sections';
sectionIndex = 0;
sections: SelectedSection[] = [];
themeVibes = THEME_VIBES;
selectedVibe = 'Any';
customVibeText = '';
additionalRequirements = '';
ngOnInit(): void {
this.sections = this.suggestions.map(s => ({
title: s.title,
description: s.description,
slug: s.slug,
selected: true,
customNotes: '',
selectedPattern: s.patterns?.length ? null : null,
customPatternDesc: '',
}));
if (this.currentSection && this.currentSectionPatterns.length > 0) {
this.currentSection.selectedPattern = this.currentSectionPatterns[0].title;
}
}
get currentSection(): SelectedSection | null {
return this.sections[this.sectionIndex] ?? null;
}
get currentSectionPatterns(): PatternSuggestion[] {
const sug = this.suggestions[this.sectionIndex];
return sug?.patterns ?? [];
}
selectPattern(title: string): void {
if (this.currentSection) {
this.currentSection.selectedPattern = title;
if (title !== '__custom__') {
this.currentSection.customPatternDesc = '';
}
}
}
get isLastStep(): boolean {
return this.phase === 'details';
}
get navLabel(): string {
if (this.phase === 'sections') {
return `Section ${this.sectionIndex + 1} of ${this.sections.length}`;
}
if (this.phase === 'theme') {
return 'Theme';
}
return 'Additional details';
}
skipSection(): void {
if (this.currentSection) {
this.currentSection.selected = false;
this.currentSection.selectedPattern = null;
}
if (this.sectionIndex < this.sections.length - 1) {
this.sectionIndex++;
this.stepChange.emit(`section-${this.sectionIndex}`);
} else {
this.phase = 'theme';
this.stepChange.emit('theme');
}
}
prev(): void {
if (this.phase === 'sections') {
if (this.sectionIndex > 0) {
this.sectionIndex--;
this.stepChange.emit(`section-${this.sectionIndex}`);
}
} else if (this.phase === 'theme') {
this.phase = 'sections';
this.sectionIndex = this.sections.length - 1;
this.stepChange.emit('sections');
} else if (this.phase === 'details') {
this.phase = 'theme';
this.stepChange.emit('theme');
}
}
next(): void {
if (this.phase === 'sections') {
if (this.sectionIndex < this.sections.length - 1) {
this.sectionIndex++;
this.stepChange.emit(`section-${this.sectionIndex}`);
} else {
this.phase = 'theme';
this.stepChange.emit('theme');
}
} else if (this.phase === 'theme') {
this.phase = 'details';
this.stepChange.emit('details');
} else if (this.phase === 'details') {
const vibe = this.selectedVibe === '__custom__'
? this.customVibeText.trim() || 'Any'
: this.selectedVibe;
this.configComplete.emit({
sections: this.sections,
themeVibe: vibe,
additionalRequirements: this.additionalRequirements,
});
}
}
}