register, login, create and manage sites and pages

This commit is contained in:
Krrish Ghimire
2026-06-30 11:13:55 +05:45
commit 65f38d68c5
110 changed files with 28049 additions and 0 deletions

63
.gitignore vendored Normal file
View File

@@ -0,0 +1,63 @@
# Java / Gradle (Backend)
backend/.gradle/
backend/build/
backend/bin/
backend/out/
# Node / Angular (Frontend)
frontend/node_modules/
frontend/dist/
frontend/tmp/
frontend/out-tsc/
frontend/.angular/cache/
frontend/coverage/
# Environment files
.env
.env.*
backend/src/main/resources/application-local.yml
backend/src/main/resources/application-dev.yml
# IDE
.idea/
.vscode/
*.iml
*.ipr
*.iws
.project
.classpath
.settings/
*.sublime-workspace
# OS
.DS_Store
Thumbs.db
*.swp
*.swo
*~
# Logs
*.log
# Docker
docker-data/
# Secrets / keys / certificates
backend/config/
*.key
*.pem
*.p8
*.p12
*.pfx
*.crt
*.cert
*.jks
*.keystore
*.secret
# Misc
*.jar
*.war
*.ear
*.tar.gz
*.zip

293
PLAN.md Normal file
View File

@@ -0,0 +1,293 @@
# 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. Users self-host or use free subdomain hosting.
---
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Backend | Java 17, Spring Boot 3.x, Gradle (Groovy) |
| Frontend | Angular 17, Angular Material, Angular CDK (drag-drop) |
| Relational DB | PostgreSQL |
| Document DB | MongoDB (LLM sessions) |
| Object Storage | Cloudflare R2 (S3-compatible) |
| LLM | Gemini (primary), OpenAI (fallback), provider-abstraction for future |
| Auth | Spring Security + OAuth2 Client — email/password, Google OAuth, GitHub OAuth, OpenID Connect |
| Git | JGit — local repo per site, optional GitHub/GitLab remote push |
| Infra | Docker Compose (dev), R2 + Cloudflare (hosting), Caddy/nginx (prod reverse proxy) |
---
## Build Phases
### v0.1 — Auth & Foundation (Target: Weeks 1-3)
**Goal:** User can register, log in via email or OAuth, and create/manage sites and pages.
#### Backend
| File | Action | Details |
|------|--------|---------|
| `backend/build.gradle` | Add | Spring Boot 3.x, deps: web, jpa, security, oauth2-client, oauth2-resource-server, validation, lombok, postgresql, testcontainers, aws-java-sdk-s3 (R2), springdoc-openapi, jgit |
| `backend/settings.gradle` | Add | Project name, plugin management |
| `backend/src/main/resources/application.yml` | Add | DB configs, JWT keys, OAuth client IDs/secrets, LLM keys, R2 credentials, git config |
| `com/indie/IndieApplication.java` | Add | Main class |
| `com/indie/config/SecurityConfig.java` | Add | SecurityFilterChain with JWT filter + OAuth2 login + OpenID Connect. Route permissions: `/api/auth/**` public, `/api/**` authenticated |
| `com/indie/config/JwtConfig.java` | Add | RS256 signing, access token 15min, refresh token 7d |
| `com/indie/config/GitConfig.java` | Add | Repos root path (`/var/git/sites/`), author name/email |
| `com/indie/model/User.java` | Add | id(UUID), email, password(bcrypt, nullable), name, avatarUrl, provider(LOCAL/GOOGLE/GITHUB/OPENID), providerId, emailVerified, createdAt, updatedAt |
| `com/indie/model/Site.java` | Add | id, userId, name, description, subdomain(unique), themeConfig(JSONB), status(DRAFT/PUBLISHED), publishedAt, createdAt, updatedAt |
| `com/indie/model/Page.java` | Add | id, siteId(FK), title, slug, seoTitle, seoDescription, orderIndex, createdAt |
| `com/indie/model/Component.java` | Add | id, pageId(FK), type(enum), config(JSONB), styles(JSONB), orderIndex |
| `com/indie/repository/*.java` | Add | JPA repositories for User, Site, Page, Component |
| `com/indie/service/AuthService.java` | Add | register, login, refreshToken, oauthLogin |
| `com/indie/service/OAuth2UserService.java` | Add | Maps Google/GitHub/OpenID user info to User entity |
| `com/indie/controller/AuthController.java` | Add | `POST /api/auth/register`, `/login`, `/refresh`, `/logout` |
| `com/indie/controller/SiteController.java` | Add | Full CRUD: `GET/POST /api/sites`, `GET/PUT/DELETE /api/sites/{id}` |
| `com/indie/controller/PageController.java` | Add | CRUD for pages within a site |
#### Frontend
| File | Action | Details |
|------|--------|---------|
| `frontend/` | Init | Angular 17 standalone, Angular Material, lazy routes, Tailwind CSS |
| `src/app/auth/` | Add | LoginComponent, RegisterComponent, OAuth callback handler, AuthService, AuthGuard, JWT interceptor |
| `src/app/dashboard/` | Add | DashboardComponent (list site cards), CreateSiteDialog, DeleteSiteDialog |
#### Infrastructure
| File | Action | Details |
|------|--------|---------|
| `docker-compose.yml` | Add | PostgreSQL, MongoDB, MinIO (local R2 mock), backend (8080), frontend (4200) |
---
### v0.2 — Drag-and-Drop Builder (Target: Weeks 4-6)
**Goal:** User can visually build a website by dragging components onto a canvas, editing their properties, styling via theme editor, and previewing live.
#### Backend
| File | Action | Details |
|------|--------|---------|
| `com/indie/controller/BuilderController.java` | Add | `GET /api/sites/{id}/pages` (with nested components), `PUT /api/sites/{id}/pages/{pageId}/components` (batch save), `GET/PUT /api/sites/{id}/theme` |
| `com/indie/service/BuilderService.java` | Add | Validate component tree (type enum, config schema, max 50/page), save in transaction |
#### Frontend
| File | Action | Details |
|------|--------|---------|
| `src/app/builder/builder.component.ts` | Add | 3-panel layout (palette 250px, canvas flex, properties 320px) via CSS Grid or mat-sidenav |
| `src/app/builder/services/builder-state.service.ts` | Add | BehaviorSubject store: `components$`, `selectedComponentId$`, `themeConfig$`, `pages$`. Methods: add, remove, update, reorder. Undo/redo stack (50 snapshots) |
| `src/app/builder/services/auto-save.service.ts` | Add | 2s debounce → `PUT` batch save. "Saving..."/"Saved" indicator |
| `src/app/builder/component-palette/` | Add | PaletteComponent — Material list of 10 draggable component types, organized by category |
| `src/app/builder/canvas/` | Add | CanvasComponent — `cdkDropList` accepting drops, renders components in order, highlight on select, delete on hover |
| `src/app/builder/property-panel/` | Add | PropertyPanelComponent — dynamic form per type (config + styles sections). Material expansion panels |
| `src/app/builder/renderer/` | Add | Render components per type (heading, text, image, button, divider, gallery, video, contact_form, map, custom_html) — each takes `config` + `styles` as `@Input()` |
| `src/app/builder/theme-editor/` | Add | ThemeEditorComponent — color pickers, font selectors, spacing slider. Live preview updates |
| `src/app/builder/preview/` | Add | PreviewComponent — sandboxed `<iframe>` with `srcdoc`, responsive device toggles (desktop/tablet/mobile) |
| `src/app/builder/builder-api.service.ts` | Add | HTTP calls for save/load components, theme, pages |
**Component types:** heading, text, image, button, divider, gallery, video, contact_form, map, custom_html
---
### v0.3 — LLM Prompt-to-Site (Target: Weeks 7-8)
**Goal:** User types a description of their desired website → AI generates the full site structure (pages + components + theme) → user tweaks in builder or refines via another prompt.
#### Backend
| File | Action | Details |
|------|--------|---------|
| `com/indie/service/llm/LLMProvider.java` | Add | Interface: `generate(systemPrompt, userPrompt, options) → LLMResult`. `LLMResult`: content, tokenUsage, model, latency |
| `com/indie/service/llm/GeminiProvider.java` | Add | Implements LLMProvider via Gemini API. Config: `indie.llm.providers.gemini.api-key`, model |
| `com/indie/service/llm/OpenAIProvider.java` | Add | Implements LLMProvider via OpenAI API. Config: `indie.llm.providers.openai.api-key`, model |
| `com/indie/service/llm/LLMFactory.java` | Add | Returns configured provider from `indie.llm.active-provider`, fallback chain |
| `com/indie/dto/LLMOptions.java` | Add | temperature, maxTokens, timeout, responseFormat |
| `com/indie/dto/SiteStructure.java` | Add | Top-level DTO: `List<PageStructure> pages`, `ThemeConfig theme`. Nested types for page and component |
| `com/indie/dto/LLMResult.java` | Add | content(String), tokenUsage, model, latencyMs |
| `com/indie/model/LLMSession.java` | Add | MongoDB document: userId, prompt, generatedStructure, acceptedAt, refinedPrompt, createdAt |
| `com/indie/service/LLMService.java` | Add | Orchestrator: prompt → LLM → parse → validate → return SiteStructure |
| `com/indie/service/LLMResponseParser.java` | Add | Parse LLM JSON output, handle markdown fences, malformed fields, partial extraction |
| `com/indie/controller/LLMController.java` | Add | `POST /api/llm/generate` (prompt→structure), `POST /api/llm/refine` (structure+prompt→refined). Rate limit: 10/hour/user |
| `com/indie/config/RateLimitConfig.java` | Add | Rate limiting via bucket4j |
| `src/main/resources/prompts/site-generator.txt` | Add | System prompt: schema definition + 2-3 few-shot examples |
#### Frontend
| File | Action | Details |
|------|--------|---------|
| `src/app/llm-workspace/` | Add | LLMWorkspaceComponent — textarea, "Generate" button, loading spinner with ETA, result preview summary, "Apply to Builder" button |
| `src/app/llm-workspace/llm-session.service.ts` | Add | Prompt history, refinement flow (original prompt + user edits + refinement → new generation) |
---
### v0.4 — Export, Git & Hosting (Target: Weeks 9-11)
**Goal:** User can publish their site as pure static files. Every publish creates a git commit. User can connect GitHub/GitLab for automatic pushes. Sites served at `*.indie.com` for free.
#### Backend
| File | Action | Details |
|------|--------|---------|
| `com/indie/service/RenderService.java` | Add | Core render engine: `renderSite(site)`, `renderPage(site, page, components) → String`, `renderCss(theme, components) → String`, `renderJs() → String` |
| `com/indie/engine/templates/*.html` | Add | Thymeleaf templates per component type + `layout.html` + `head.html` |
| `com/indie/engine/StyleCompiler.java` | Add | Theme → CSS custom properties at `:root`, component styles → CSS classes, responsive breakpoints |
| `com/indie/engine/JsBundle.java` | Add | Generates `script.js`: nav toggle, smooth scroll, lightbox, contact form handler, comment form handler. Minified |
| `com/indie/service/ExportService.java` | Add | `export(siteId) → ZipOutputStream` — full site directory + `.git/` |
| `com/indie/controller/ExportController.java` | Add | `GET /api/sites/{id}/export``application/zip` download |
| `com/indie/service/GitService.java` | Add | `initRepo(siteId)`, `commit(siteId, message)`, `commitAndPush(siteId, message)`, `getLog(siteId)`, `rollback(siteId, commitHash)`, `exportRepo(siteId)` |
| `com/indie/model/GitRemote.java` | Add | JPA: id, siteId, provider(GITHUB/GITLAB), remoteUrl, encryptedToken, defaultBranch |
| `com/indie/service/KeyManager.java` | Add | SSH keypair generation per site, encrypted storage |
| `com/indie/controller/GitController.java` | Add | `POST /api/sites/{id}/git/connect`, `/disconnect`, `GET /api/sites/{id}/git/status` |
| `com/indie/service/DeploymentService.java` | Add | On publish: render → write to git working dir → `GitService.commit` → upload to R2 → return public URL |
| `com/indie/controller/DeploymentController.java` | Add | `POST /api/sites/{id}/publish`, `/unpublish` |
| `com/indie/service/DomainService.java` | Add | Skeleton: store custom domain mapping in Site entity, prepared for future TLS |
| `com/indie/service/MediaService.java` | Add | Upload to R2, validate type/size, resize |
| `com/indie/controller/MediaController.java` | Add | `POST /api/media/upload`, `GET /api/media/{id}` |
#### Frontend
| File | Action | Details |
|------|--------|---------|
| `src/app/builder/git-settings/` | Add | GitSettingsComponent — connect/disconnect GitHub/GitLab OAuth, select remote repo, connection status |
| `src/app/builder/settings/` | Add | SettingsComponent — site name, subdomain, export zip button, publish/unpublish buttons |
#### Infrastructure
| File | Action | Details |
|------|--------|---------|
| `infra/cloudflare-worker.js` | Add | Routes `*.indie.com` → R2 bucket `indie-sites/{subdomain}/` |
| `docker-compose.prod.yml` | Add | Production services: PostgreSQL, MongoDB, backend (×2), nginx, certbot |
**Git directory structure on server:**
```
/var/git/sites/
└── {siteId}/
├── .git/
├── index.html
├── about/index.html
├── style.css
├── script.js
└── assets/
```
---
### v0.5 — Social Features & Polish (Target: Weeks 12-13)
**Goal:** Minimal social features (comments, RSS) and final polish before public release.
#### Backend
| File | Action | Details |
|------|--------|---------|
| `com/indie/model/Comment.java` | Add | JPA: id(UUID), siteId, pageId, authorName, authorEmail, content, isApproved, parentCommentId, createdAt |
| `com/indie/controller/CommentController.java` | Add | Public: `POST /api/sites/{subdomain}/comments`. Authenticated: `GET/PUT/DELETE` moderation |
| `com/indie/service/RssService.java` | Add | `generateRss(siteId) → String` — RSS 2.0 feed from site pages |
| `com/indie/controller/RssController.java` | Add | `GET /api/feed/{subdomain}``application/rss+xml`, 1h cache |
#### Frontend
| File | Action | Details |
|------|--------|---------|
| `src/app/builder/settings/` | Modify | Add git history panel (commit list, rollback button, download repo) |
| `src/app/builder/components/comments-section/` | Add | In-builder config (enable/disable, moderate) + renderer for generated sites |
---
## Infrastructure Architecture
```
┌──────────────────────┐
│ Cloudflare CDN │
│ *.indie.com → R2 │
└────────┬─────────────┘
┌────────▼─────────────┐
│ Cloudflare R2 │
│ indie-sites bucket │
│ /{subdomain}/* │
└──────────────────────┘
┌────────┴─────────────┐
│ Spring Boot API │
│ (VPS / Docker) │
└────────┬─────────────┘
┌──────────────┼──────────────┐
│ │ │
┌────────▼───┐ ┌──────▼──────┐ ┌───▼────────┐
│ PostgreSQL │ │ MongoDB │ │ Git Repos │
│ (users, │ │ (LLM sess.) │ │ /var/git/ │
│ sites, │ └─────────────┘ └────────────┘
│ pages, │
│ comps) │
└─────────────┘
```
---
## LLM Provider Abstraction
```java
// Adding a new provider = implement this interface + add config
public interface LLMProvider {
LLMResult generate(String systemPrompt, String userPrompt, LLMOptions options);
String getName();
}
// application.yml
indie:
llm:
active-provider: gemini
fallback-provider: openai
providers:
gemini:
api-key: ${GEMINI_API_KEY}
model: gemini-2.0-flash
openai:
api-key: ${OPENAI_API_KEY}
model: gpt-4o-mini
```
---
## Key Design Decisions
1. **Static output only** — Generated sites are pure HTML/CSS/JS. No Angular/React dependency for end users. They own the output forever, no lock-in.
2. **Git as source of truth** — Every site is a git repository. Publish = commit. Rollback is a `git checkout`. Export includes full `.git/` history.
3. **Shared rendering** — The same component renderers drive the builder preview AND the static export. What you see is what you get.
4. **No billing in MVP** — Subdomain hosting is free. No Stripe/PayPal code. Monetization (paid hosting, custom domains) is a post-MVP concern.
5. **Incremental releases** — Each v0.x is independently shippable. If timeline slips, ship what's done.
---
## Validation Strategy
| Release | What to Test |
|---------|-------------|
| v0.1 | Register, login (email + OAuth), refresh token, site CRUD, page CRUD, 401 on unauthenticated |
| v0.2 | Drag → reorder → save → reload matches, theme change reflects in preview, undo/redo, all 10 component types render |
| v0.3 | LLM generates valid SiteStructure JSON, generation loads into builder, refinement updates correctly, error handling (timeout, bad JSON) |
| v0.4 | Exported zip opens in browser identically to preview, git init + commit works, git push to remote works, R2 upload succeeds, subdomain serves content |
| v0.5 | Comment submit → approve → display, RSS validates, image upload + resize, rollback restores previous version |
---
## Risk Register
| Risk | Likelihood | Mitigation |
|------|-----------|------------|
| Scope creep (adding features mid-phase) | High | Strict feature freeze per v0.x; ship as-is |
| Drag-drop performance with many components | Medium | Cap at 50 components/page; virtual scroll if needed |
| LLM output quality inconsistent | Medium | Iterate on system prompt; template gallery fallback |
| Solo developer burnout | Medium | Realistic timeline; celebrate each release |
| LLM API costs | Low | Gemini-2.0-flash is free-tier friendly; rate limit 10/hour/user |

233
README.md Normal file
View File

@@ -0,0 +1,233 @@
# 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. Users self-host or use free subdomain hosting.
---
## Quick Start
### Prerequisites
- 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)
### Setup
1. **Start infrastructure:**
```bash
docker compose up -d
```
2. **Start the backend:**
```bash
cd backend
./gradlew bootRun
```
The API will be available at `http://localhost:8080`.
Swagger UI at `http://localhost:8080/swagger-ui.html`.
3. **Start the frontend (separate terminal):**
```bash
cd frontend
npm install
ng serve
```
The app will be available at `http://localhost:4200`.
API calls are proxied to `http://localhost:8080`.
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `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 | — |
| `INDIE_JWT_ACCESS_EXPIRATION` | Access token TTL (ms) | `900000` (15 min) |
| `INDIE_JWT_REFRESH_EXPIRATION` | Refresh token TTL (ms) | `604800000` (7 days) |
| `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` |
---
## Project Structure
```
backend/
├── build.gradle # Dependencies and build config
├── 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
│ ├── config/
│ │ ├── SecurityConfig.java # HTTP security, JWT filter, OAuth2 login, CORS
│ │ ├── JwtConfig.java # RS256 token generation and validation
│ │ └── GitConfig.java # Git repository settings
│ ├── model/
│ │ ├── User.java # User entity with local + OAuth support
│ │ ├── 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)
│ ├── repository/
│ │ ├── UserRepository.java
│ │ ├── SiteRepository.java
│ │ ├── PageRepository.java
│ │ └── ComponentRepository.java
│ ├── dto/
│ │ ├── RegisterRequest.java
│ │ ├── LoginRequest.java
│ │ ├── RefreshTokenRequest.java
│ │ ├── AuthResponse.java # JWT tokens + user info
│ │ ├── SiteRequest.java
│ │ ├── SiteResponse.java
│ │ ├── PageRequest.java
│ │ └── PageResponse.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
│ └── controller/
│ ├── AuthController.java # POST /api/auth/register, /login, /refresh, /logout
│ ├── SiteController.java # CRUD /api/sites
│ └── PageController.java # CRUD /api/sites/{siteId}/pages
└── resources/
└── application.yml # All configuration
frontend/
├── proxy.conf.json # Dev proxy to backend
├── angular.json # Angular CLI configuration
├── tailwind.config.js # Tailwind CSS configuration
├── 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
│ └── oauth-callback/
│ └── oauth-callback.component.ts # Reads tokens from OAuth redirect
└── 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
```
---
## API Overview
### Authentication (`/api/auth`)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/auth/register` | Register with email + password |
| POST | `/api/auth/login` | Login with email + password |
| POST | `/api/auth/refresh` | Refresh access token |
| POST | `/api/auth/logout` | Logout |
| GET | `/oauth2/authorization/google` | Login with Google |
| GET | `/oauth2/authorization/github` | Login with GitHub |
### Sites (`/api/sites`)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/sites` | List user's sites |
| POST | `/api/sites` | Create a new site |
| GET | `/api/sites/{id}` | Get site details |
| PUT | `/api/sites/{id}` | Update site |
| DELETE | `/api/sites/{id}` | Delete site |
### Pages (`/api/sites/{siteId}/pages`)
| 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 |
---
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Backend | Java 17, Spring Boot 3.2, Gradle |
| Frontend | Angular 17, Angular Material, Tailwind CSS |
| Relational DB | PostgreSQL 16 |
| Document DB | MongoDB 7 |
| Object Storage | MinIO (dev) / Cloudflare R2 (prod) |
| Auth | Spring Security + OAuth2 Client + JWT (RS256) |
| Git | JGit |
| API Docs | Springdoc OpenAPI (Swagger UI) |
---
## Development
```bash
# Backend
cd backend
./gradlew build # Build
./gradlew test # Run tests
./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
```
---
## Build Status
### v0.1 — Auth & Foundation ✅
- [x] User registration (email/password)
- [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

73
backend/build.gradle Normal file
View File

@@ -0,0 +1,73 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.4'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.indie'
version = '0.1.0'
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
ext {
jjwtVersion = '0.12.5'
testcontainersVersion = '1.19.7'
springdocVersion = '2.3.0'
jgitVersion = '6.9.0.202403050737-r'
awsSdkVersion = '2.24.13'
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}"
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocVersion}"
implementation "org.eclipse.jgit:org.eclipse.jgit:${jgitVersion}"
implementation "software.amazon.awssdk:s3:${awsSdkVersion}"
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'com.h2database:h2'
testImplementation "org.testcontainers:testcontainers:${testcontainersVersion}"
testImplementation "org.testcontainers:postgresql:${testcontainersVersion}"
testImplementation "org.testcontainers:junit-jupiter:${testcontainersVersion}"
}
dependencyManagement {
imports {
mavenBom "org.testcontainers:testcontainers-bom:${testcontainersVersion}"
}
}
tasks.named('test') {
useJUnitPlatform()
}
bootJar {
archiveFileName = 'indie-backend.jar'
}

View File

@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
backend/gradlew vendored Executable file
View File

@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/<unknown>/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

82
backend/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%

1
backend/settings.gradle Normal file
View File

@@ -0,0 +1 @@
rootProject.name = 'indie-backend'

View File

@@ -0,0 +1,12 @@
package com.indie;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class IndieApplication {
public static void main(String[] args) {
SpringApplication.run(IndieApplication.class, args);
}
}

View File

@@ -0,0 +1,47 @@
package com.indie.config;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@Component
public class GitConfig {
@Value("${indie.git.repos-path:/home/krrish/git/sites/}")
private String reposPath;
@Value("${indie.git.author-name:Indie Platform}")
private String authorName;
@Value("${indie.git.author-email:git@indie.local}")
private String authorEmail;
@PostConstruct
public void init() throws IOException {
Path path = Paths.get(reposPath);
if (!Files.exists(path)) {
Files.createDirectories(path);
}
}
public Path getReposPath() {
return Paths.get(reposPath);
}
public String getAuthorName() {
return authorName;
}
public String getAuthorEmail() {
return authorEmail;
}
public Path getSiteRepoPath(String siteId) {
return getReposPath().resolve(siteId);
}
}

View File

@@ -0,0 +1,33 @@
package com.indie.config;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ProblemDetail handleBadRequest(IllegalArgumentException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, message);
}
@ExceptionHandler(Exception.class)
public ProblemDetail handleGeneral(Exception ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
"An unexpected error occurred");
}
}

View File

@@ -0,0 +1,28 @@
package com.indie.config;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import java.util.List;
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
private final UserPrincipal principal;
private final String token;
public JwtAuthenticationToken(UserPrincipal principal, String token) {
super(List.of(() -> "ROLE_USER"));
this.principal = principal;
this.token = token;
setAuthenticated(true);
}
@Override
public Object getCredentials() {
return token;
}
@Override
public Object getPrincipal() {
return principal;
}
}

View File

@@ -0,0 +1,128 @@
package com.indie.config;
import com.indie.model.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Date;
import static io.jsonwebtoken.Jwts.SIG.RS256;
@Component
public class JwtConfig implements TokenService {
private static final Logger log = LoggerFactory.getLogger(JwtConfig.class);
@Value("${indie.jwt.access-token-expiration:900000}")
private long accessTokenExpiration;
@Value("${indie.jwt.refresh-token-expiration:604800000}")
private long refreshTokenExpiration;
@Value("${indie.jwt.private-key-path:}")
private String privateKeyPath;
@Value("${indie.jwt.public-key-path:}")
private String publicKeyPath;
private PrivateKey privateKey;
private PublicKey publicKey;
@PostConstruct
public void init() {
if (privateKeyPath != null && !privateKeyPath.isBlank()
&& publicKeyPath != null && !publicKeyPath.isBlank()) {
loadOrGenerateKeys();
} else {
log.info("No key paths configured, generating ephemeral keypair (tokens invalidated on restart)");
KeyPair keyPair = RS256.keyPair().build();
this.privateKey = keyPair.getPrivate();
this.publicKey = keyPair.getPublic();
}
}
private void loadOrGenerateKeys() {
Path privPath = Path.of(privateKeyPath);
Path pubPath = Path.of(publicKeyPath);
if (Files.exists(privPath) && Files.exists(pubPath)) {
try {
byte[] privBytes = Files.readAllBytes(privPath);
byte[] pubBytes = Files.readAllBytes(pubPath);
KeyFactory kf = KeyFactory.getInstance("RSA");
this.privateKey = kf.generatePrivate(new PKCS8EncodedKeySpec(privBytes));
this.publicKey = kf.generatePublic(new X509EncodedKeySpec(pubBytes));
log.info("Loaded JWT keypair from {} and {}", privateKeyPath, publicKeyPath);
return;
} catch (Exception e) {
log.warn("Failed to load JWT keys from files, generating new ones: {}", e.getMessage());
}
}
try {
KeyPair keyPair = RS256.keyPair().build();
this.privateKey = keyPair.getPrivate();
this.publicKey = keyPair.getPublic();
Files.createDirectories(privPath.getParent());
Files.write(privPath, keyPair.getPrivate().getEncoded());
Files.write(pubPath, keyPair.getPublic().getEncoded());
log.info("Generated and saved JWT keypair to {} and {}", privateKeyPath, publicKeyPath);
} catch (Exception e) {
throw new RuntimeException("Failed to generate JWT keypair", e);
}
}
public String generateAccessToken(User user) {
return generateToken(user, accessTokenExpiration);
}
public String generateRefreshToken(User user) {
return generateToken(user, refreshTokenExpiration);
}
private String generateToken(User user, long expiration) {
Date now = new Date();
return Jwts.builder()
.subject(user.getId().toString())
.claim("email", user.getEmail())
.claim("name", user.getName())
.issuedAt(now)
.expiration(new Date(now.getTime() + expiration))
.signWith(privateKey, RS256)
.compact();
}
public Claims validateToken(String token) {
return Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
public long getAccessTokenExpiration() {
return accessTokenExpiration;
}
public long getRefreshTokenExpiration() {
return refreshTokenExpiration;
}
public PublicKey getPublicKey() {
return publicKey;
}
}

View File

@@ -0,0 +1,138 @@
package com.indie.config;
import com.indie.service.OAuth2SuccessHandler;
import com.indie.service.OAuth2UserService;
import io.jsonwebtoken.Claims;
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;
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;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtConfig jwtConfig;
private final OAuth2UserService oAuth2UserService;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
@Value("${indie.cors.allowed-origins:http://localhost:4200}")
private String[] allowedOrigins;
@Value("${spring.security.oauth2.client.registration.google.client-id:}")
private String googleClientId;
@Value("${spring.security.oauth2.client.registration.github.client-id:}")
private String githubClientId;
public SecurityConfig(JwtConfig jwtConfig, OAuth2UserService oAuth2UserService,
OAuth2SuccessHandler oAuth2SuccessHandler) {
this.jwtConfig = jwtConfig;
this.oAuth2UserService = oAuth2UserService;
this.oAuth2SuccessHandler = oAuth2SuccessHandler;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/sites/**").authenticated()
.anyRequest().authenticated()
);
if (isOAuth2Configured()) {
http.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo -> userInfo
.userService(oAuth2UserService)
)
.successHandler(oAuth2SuccessHandler)
);
}
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
private boolean isOAuth2Configured() {
return (googleClientId != null && !googleClientId.isBlank())
|| (githubClientId != null && !githubClientId.isBlank());
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(allowedOrigins));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
private OncePerRequestFilter jwtAuthenticationFilter() {
return new OncePerRequestFilter() {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
try {
Claims claims = jwtConfig.validateToken(token);
UUID userId = UUID.fromString(claims.getSubject());
String email = claims.get("email", String.class);
String name = claims.get("name", String.class);
UserPrincipal principal = new UserPrincipal(userId, email, name);
JwtAuthenticationToken authentication =
new JwtAuthenticationToken(principal, token);
org.springframework.security.core.context.SecurityContextHolder
.getContext().setAuthentication(authentication);
} catch (Exception e) {
org.springframework.security.core.context.SecurityContextHolder
.clearContext();
}
}
filterChain.doFilter(request, response);
}
};
}
}

View File

@@ -0,0 +1,13 @@
package com.indie.config;
import com.indie.model.User;
import io.jsonwebtoken.Claims;
public interface TokenService {
String generateAccessToken(User user);
String generateRefreshToken(User user);
Claims validateToken(String token);
}

View File

@@ -0,0 +1,47 @@
package com.indie.config;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
public record UserPrincipal(UUID id, String email, String name) implements UserDetails {
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_USER"));
}
@Override
public String getPassword() {
return null;
}
@Override
public String getUsername() {
return email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}

View File

@@ -0,0 +1,48 @@
package com.indie.controller;
import com.indie.dto.AuthResponse;
import com.indie.dto.LoginRequest;
import com.indie.dto.RefreshTokenRequest;
import com.indie.dto.RegisterRequest;
import com.indie.service.AuthService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
AuthResponse response = authService.register(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
AuthResponse response = authService.login(request);
return ResponseEntity.ok(response);
}
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
AuthResponse response = authService.refreshToken(request);
return ResponseEntity.ok(response);
}
@PostMapping("/logout")
public ResponseEntity<Void> logout() {
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,124 @@
package com.indie.controller;
import com.indie.config.UserPrincipal;
import com.indie.dto.PageRequest;
import com.indie.dto.PageResponse;
import com.indie.model.Page;
import com.indie.model.Site;
import com.indie.repository.PageRepository;
import com.indie.repository.SiteRepository;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/sites/{siteId}/pages")
public class PageController {
private final PageRepository pageRepository;
private final SiteRepository siteRepository;
public PageController(PageRepository pageRepository, SiteRepository siteRepository) {
this.pageRepository = pageRepository;
this.siteRepository = siteRepository;
}
@GetMapping
public ResponseEntity<List<PageResponse>> listPages(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID siteId) {
Site site = findSiteForUser(siteId, principal.id());
List<Page> pages = pageRepository.findBySiteIdOrderByOrderIndexAsc(site.getId());
List<PageResponse> response = pages.stream()
.map(PageResponse::from)
.toList();
return ResponseEntity.ok(response);
}
@PostMapping
public ResponseEntity<PageResponse> createPage(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID siteId,
@Valid @RequestBody PageRequest request) {
Site site = findSiteForUser(siteId, principal.id());
if (pageRepository.existsBySiteIdAndSlug(site.getId(), request.getSlug())) {
throw new IllegalArgumentException("A page with this slug already exists");
}
Page page = Page.builder()
.siteId(site.getId())
.title(request.getTitle())
.slug(request.getSlug())
.seoTitle(request.getSeoTitle())
.seoDescription(request.getSeoDescription())
.orderIndex(request.getOrderIndex())
.build();
page = pageRepository.save(page);
return ResponseEntity.status(HttpStatus.CREATED).body(PageResponse.from(page));
}
@GetMapping("/{pageId}")
public ResponseEntity<PageResponse> getPage(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID siteId,
@PathVariable UUID pageId) {
Site site = findSiteForUser(siteId, principal.id());
Page page = findPageForSite(pageId, site.getId());
return ResponseEntity.ok(PageResponse.from(page));
}
@PutMapping("/{pageId}")
public ResponseEntity<PageResponse> updatePage(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID siteId,
@PathVariable UUID pageId,
@Valid @RequestBody PageRequest request) {
Site site = findSiteForUser(siteId, principal.id());
Page page = findPageForSite(pageId, site.getId());
if (!page.getSlug().equals(request.getSlug())
&& pageRepository.existsBySiteIdAndSlug(site.getId(), request.getSlug())) {
throw new IllegalArgumentException("A page with this slug already exists");
}
page.setTitle(request.getTitle());
page.setSlug(request.getSlug());
page.setSeoTitle(request.getSeoTitle());
page.setSeoDescription(request.getSeoDescription());
page.setOrderIndex(request.getOrderIndex());
page = pageRepository.save(page);
return ResponseEntity.ok(PageResponse.from(page));
}
@DeleteMapping("/{pageId}")
public ResponseEntity<Void> deletePage(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID siteId,
@PathVariable UUID pageId) {
Site site = findSiteForUser(siteId, principal.id());
Page page = findPageForSite(pageId, site.getId());
pageRepository.delete(page);
return ResponseEntity.noContent().build();
}
private Site findSiteForUser(UUID siteId, UUID userId) {
Site site = siteRepository.findById(siteId)
.orElseThrow(() -> new IllegalArgumentException("Site not found"));
if (!site.getUserId().equals(userId)) {
throw new IllegalArgumentException("Site not found");
}
return site;
}
private Page findPageForSite(UUID pageId, UUID siteId) {
Page page = pageRepository.findById(pageId)
.orElseThrow(() -> new IllegalArgumentException("Page not found"));
if (!page.getSiteId().equals(siteId)) {
throw new IllegalArgumentException("Page not found");
}
return page;
}
}

View File

@@ -0,0 +1,114 @@
package com.indie.controller;
import com.indie.config.UserPrincipal;
import com.indie.dto.SiteRequest;
import com.indie.dto.SiteResponse;
import com.indie.model.Site;
import com.indie.repository.SiteRepository;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/sites")
public class SiteController {
private final SiteRepository siteRepository;
public SiteController(SiteRepository siteRepository) {
this.siteRepository = siteRepository;
}
@GetMapping
public ResponseEntity<List<SiteResponse>> listSites(@AuthenticationPrincipal UserPrincipal principal) {
List<Site> sites = siteRepository.findByUserIdOrderByCreatedAtDesc(principal.id());
List<SiteResponse> response = sites.stream()
.map(SiteResponse::from)
.toList();
return ResponseEntity.ok(response);
}
@PostMapping
public ResponseEntity<SiteResponse> createSite(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody SiteRequest request) {
String subdomain = sanitizeSubdomain(request.getSubdomain());
if (subdomain != null && siteRepository.existsBySubdomain(subdomain)) {
throw new IllegalArgumentException("Subdomain already taken");
}
Site site = Site.builder()
.userId(principal.id())
.name(request.getName())
.description(request.getDescription())
.subdomain(subdomain)
.themeConfig(request.getThemeConfig())
.build();
site = siteRepository.save(site);
return ResponseEntity.status(HttpStatus.CREATED).body(SiteResponse.from(site));
}
@GetMapping("/{id}")
public ResponseEntity<SiteResponse> getSite(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID id) {
Site site = findSiteForUser(id, principal.id());
return ResponseEntity.ok(SiteResponse.from(site));
}
@PutMapping("/{id}")
public ResponseEntity<SiteResponse> updateSite(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID id,
@Valid @RequestBody SiteRequest request) {
Site site = findSiteForUser(id, principal.id());
String newSubdomain = sanitizeSubdomain(request.getSubdomain());
boolean subdomainChanged = (site.getSubdomain() != null && !site.getSubdomain().equals(newSubdomain))
|| (site.getSubdomain() == null && newSubdomain != null);
if (subdomainChanged && newSubdomain != null && siteRepository.existsBySubdomain(newSubdomain)) {
throw new IllegalArgumentException("Subdomain already taken");
}
site.setName(request.getName());
site.setDescription(request.getDescription());
site.setSubdomain(newSubdomain);
if (request.getThemeConfig() != null) {
site.setThemeConfig(request.getThemeConfig());
}
site = siteRepository.save(site);
return ResponseEntity.ok(SiteResponse.from(site));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteSite(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID id) {
Site site = findSiteForUser(id, principal.id());
siteRepository.delete(site);
return ResponseEntity.noContent().build();
}
private Site findSiteForUser(UUID siteId, UUID userId) {
Site site = siteRepository.findById(siteId)
.orElseThrow(() -> new IllegalArgumentException("Site not found"));
if (!site.getUserId().equals(userId)) {
throw new IllegalArgumentException("Site not found");
}
return site;
}
private String sanitizeSubdomain(String subdomain) {
if (subdomain == null || subdomain.isBlank()) {
return null;
}
String cleaned = subdomain.strip().toLowerCase();
if (!cleaned.matches("^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")) {
throw new IllegalArgumentException("Subdomain must be 3-63 characters, lowercase alphanumeric with hyphens");
}
return cleaned;
}
}

View File

@@ -0,0 +1,37 @@
package com.indie.dto;
import com.indie.model.AuthProvider;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import java.util.UUID;
@Data
@AllArgsConstructor
@Builder
public class AuthResponse {
private String accessToken;
private String refreshToken;
private String tokenType;
private UserInfo user;
public AuthResponse(String accessToken, String refreshToken, UserInfo user) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.tokenType = "Bearer";
this.user = user;
}
@Data
@AllArgsConstructor
@Builder
public static class UserInfo {
private UUID id;
private String email;
private String name;
private String avatarUrl;
private AuthProvider provider;
}
}

View File

@@ -0,0 +1,16 @@
package com.indie.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class LoginRequest {
@NotBlank(message = "Email is required")
@Email(message = "Invalid email format")
private String email;
@NotBlank(message = "Password is required")
private String password;
}

View File

@@ -0,0 +1,22 @@
package com.indie.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
@Data
public class PageRequest {
@NotBlank(message = "Page title is required")
private String title;
@NotBlank(message = "Page slug is required")
@Pattern(regexp = "^[a-z0-9-]+$", message = "Slug must be lowercase alphanumeric with hyphens")
private String slug;
private String seoTitle;
private String seoDescription;
private int orderIndex;
}

View File

@@ -0,0 +1,36 @@
package com.indie.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.UUID;
@Data
@AllArgsConstructor
@Builder
public class PageResponse {
private UUID id;
private UUID siteId;
private String title;
private String slug;
private String seoTitle;
private String seoDescription;
private int orderIndex;
private LocalDateTime createdAt;
public static PageResponse from(com.indie.model.Page page) {
return PageResponse.builder()
.id(page.getId())
.siteId(page.getSiteId())
.title(page.getTitle())
.slug(page.getSlug())
.seoTitle(page.getSeoTitle())
.seoDescription(page.getSeoDescription())
.orderIndex(page.getOrderIndex())
.createdAt(page.getCreatedAt())
.build();
}
}

View File

@@ -0,0 +1,11 @@
package com.indie.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class RefreshTokenRequest {
@NotBlank(message = "Refresh token is required")
private String refreshToken;
}

View File

@@ -0,0 +1,21 @@
package com.indie.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
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

@@ -0,0 +1,19 @@
package com.indie.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.util.Map;
@Data
public class SiteRequest {
@NotBlank(message = "Site name is required")
private String name;
private String description;
private String subdomain;
private Map<String, Object> themeConfig;
}

View File

@@ -0,0 +1,42 @@
package com.indie.dto;
import com.indie.model.SiteStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;
@Data
@AllArgsConstructor
@Builder
public class SiteResponse {
private UUID id;
private UUID userId;
private String name;
private String description;
private String subdomain;
private Map<String, Object> themeConfig;
private SiteStatus status;
private LocalDateTime publishedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public static SiteResponse from(com.indie.model.Site site) {
return SiteResponse.builder()
.id(site.getId())
.userId(site.getUserId())
.name(site.getName())
.description(site.getDescription())
.subdomain(site.getSubdomain())
.themeConfig(site.getThemeConfig())
.status(site.getStatus())
.publishedAt(site.getPublishedAt())
.createdAt(site.getCreatedAt())
.updatedAt(site.getUpdatedAt())
.build();
}
}

View File

@@ -0,0 +1,5 @@
package com.indie.model;
public enum AuthProvider {
LOCAL, GOOGLE, GITHUB, OPENID
}

View File

@@ -0,0 +1,47 @@
package com.indie.model;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.util.Map;
import java.util.UUID;
@Entity
@Table(name = "components")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Component {
@Id
private UUID id;
@Column(nullable = false)
private UUID pageId;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ComponentType type;
@JdbcTypeCode(SqlTypes.JSON)
private Map<String, Object> config;
@JdbcTypeCode(SqlTypes.JSON)
private Map<String, Object> styles;
@Column(nullable = false)
private int orderIndex;
@PrePersist
void onCreate() {
if (id == null) {
id = UUID.randomUUID();
}
}
}

View File

@@ -0,0 +1,5 @@
package com.indie.model;
public enum ComponentType {
HEADING, TEXT, IMAGE, BUTTON, DIVIDER, GALLERY, VIDEO, CONTACT_FORM, MAP, CUSTOM_HTML
}

View File

@@ -0,0 +1,50 @@
package com.indie.model;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "pages")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Page {
@Id
private UUID id;
@Column(nullable = false)
private UUID siteId;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String slug;
private String seoTitle;
@Column(length = 500)
private String seoDescription;
@Column(nullable = false)
private int orderIndex;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
void onCreate() {
if (id == null) {
id = UUID.randomUUID();
}
createdAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,67 @@
package com.indie.model;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;
@Entity
@Table(name = "sites")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Site {
@Id
private UUID id;
@Column(nullable = false)
private UUID userId;
@Column(nullable = false)
private String name;
@Column(length = 1000)
private String description;
@Column(unique = true)
private String subdomain;
@JdbcTypeCode(SqlTypes.JSON)
private Map<String, Object> themeConfig;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Builder.Default
private SiteStatus status = SiteStatus.DRAFT;
private LocalDateTime publishedAt;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private LocalDateTime updatedAt;
@PrePersist
void onCreate() {
if (id == null) {
id = UUID.randomUUID();
}
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,5 @@
package com.indie.model;
public enum SiteStatus {
DRAFT, PUBLISHED
}

View File

@@ -0,0 +1,61 @@
package com.indie.model;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "users")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
@Id
private UUID id;
@Column(nullable = false, unique = true)
private String email;
private String password;
@Column(nullable = false)
private String name;
private String avatarUrl;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private AuthProvider provider;
private String providerId;
@Builder.Default
private boolean emailVerified = false;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private LocalDateTime updatedAt;
@PrePersist
void onCreate() {
if (id == null) {
id = UUID.randomUUID();
}
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,14 @@
package com.indie.repository;
import com.indie.model.Component;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface ComponentRepository extends JpaRepository<Component, UUID> {
List<Component> findByPageIdOrderByOrderIndexAsc(UUID pageId);
void deleteByPageId(UUID pageId);
}

View File

@@ -0,0 +1,17 @@
package com.indie.repository;
import com.indie.model.Page;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface PageRepository extends JpaRepository<Page, UUID> {
List<Page> findBySiteIdOrderByOrderIndexAsc(UUID siteId);
Optional<Page> findBySiteIdAndSlug(UUID siteId, String slug);
boolean existsBySiteIdAndSlug(UUID siteId, String slug);
}

View File

@@ -0,0 +1,17 @@
package com.indie.repository;
import com.indie.model.Site;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface SiteRepository extends JpaRepository<Site, UUID> {
List<Site> findByUserIdOrderByCreatedAtDesc(UUID userId);
Optional<Site> findBySubdomain(String subdomain);
boolean existsBySubdomain(String subdomain);
}

View File

@@ -0,0 +1,17 @@
package com.indie.repository;
import com.indie.model.AuthProvider;
import com.indie.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface UserRepository extends JpaRepository<User, UUID> {
Optional<User> findByEmail(String email);
Optional<User> findByProviderAndProviderId(AuthProvider provider, String providerId);
boolean existsByEmail(String email);
}

View File

@@ -0,0 +1,96 @@
package com.indie.service;
import com.indie.config.TokenService;
import com.indie.dto.AuthResponse;
import com.indie.dto.LoginRequest;
import com.indie.dto.RefreshTokenRequest;
import com.indie.dto.RegisterRequest;
import com.indie.model.AuthProvider;
import com.indie.model.User;
import com.indie.repository.UserRepository;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final TokenService jwtConfig;
@Transactional
public AuthResponse register(RegisterRequest request) {
String email = request.getEmail().toLowerCase().strip();
if (userRepository.existsByEmail(email)) {
throw new IllegalArgumentException("Email already registered");
}
User user = User.builder()
.email(email)
.password(passwordEncoder.encode(request.getPassword()))
.name(request.getName())
.provider(AuthProvider.LOCAL)
.emailVerified(false)
.build();
user = userRepository.save(user);
return generateAuthResponse(user);
}
public AuthResponse login(LoginRequest request) {
String email = request.getEmail().toLowerCase().strip();
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("Invalid email or password"));
if (user.getProvider() != AuthProvider.LOCAL) {
throw new IllegalArgumentException("Please sign in with " + user.getProvider().name().toLowerCase());
}
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new IllegalArgumentException("Invalid email or password");
}
return generateAuthResponse(user);
}
public AuthResponse refreshToken(RefreshTokenRequest request) {
try {
Claims claims = jwtConfig.validateToken(request.getRefreshToken());
UUID userId = UUID.fromString(claims.getSubject());
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
return generateAuthResponse(user);
} catch (Exception e) {
throw new IllegalArgumentException("Invalid or expired refresh token");
}
}
private AuthResponse generateAuthResponse(User user) {
String accessToken = jwtConfig.generateAccessToken(user);
String refreshToken = jwtConfig.generateRefreshToken(user);
AuthResponse.UserInfo userInfo = AuthResponse.UserInfo.builder()
.id(user.getId())
.email(user.getEmail())
.name(user.getName())
.avatarUrl(user.getAvatarUrl())
.provider(user.getProvider())
.build();
return AuthResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.tokenType("Bearer")
.user(userInfo)
.build();
}
}

View File

@@ -0,0 +1,40 @@
package com.indie.service;
import com.indie.model.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.core.user.OAuth2User;
import java.util.Collection;
import java.util.List;
import java.util.Map;
public class CustomOAuth2User implements OAuth2User {
private final User user;
private final Map<String, Object> attributes;
public CustomOAuth2User(User user, Map<String, Object> attributes) {
this.user = user;
this.attributes = attributes;
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_USER"));
}
@Override
public String getName() {
return user.getId().toString();
}
public User getUser() {
return user;
}
}

View File

@@ -0,0 +1,42 @@
package com.indie.service;
import com.indie.config.JwtConfig;
import com.indie.model.User;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtConfig jwtConfig;
public OAuth2SuccessHandler(JwtConfig jwtConfig) {
this.jwtConfig = jwtConfig;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
if (authentication.getPrincipal() instanceof CustomOAuth2User oAuth2User) {
User user = oAuth2User.getUser();
String accessToken = jwtConfig.generateAccessToken(user);
String refreshToken = jwtConfig.generateRefreshToken(user);
String redirectUrl = "http://localhost:4200/oauth2/callback"
+ "?accessToken=" + accessToken
+ "&refreshToken=" + refreshToken;
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
} else {
super.onAuthenticationSuccess(request, response, authentication);
}
}
}

View File

@@ -0,0 +1,110 @@
package com.indie.service;
import com.indie.model.AuthProvider;
import com.indie.model.User;
import com.indie.repository.UserRepository;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Service
public class OAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
public OAuth2UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
@Transactional
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
AuthProvider provider = switch (registrationId) {
case "google" -> AuthProvider.GOOGLE;
case "github" -> AuthProvider.GITHUB;
default -> AuthProvider.OPENID;
};
String providerId = getProviderId(oAuth2User, provider);
String email = getAttribute(oAuth2User, "email");
if (email != null) {
email = email.toLowerCase().strip();
}
String name = getAttribute(oAuth2User, "name");
String avatarUrl = getAvatarUrl(oAuth2User, provider);
User user = findOrCreateUser(provider, providerId, email, name, avatarUrl);
return new CustomOAuth2User(user, oAuth2User.getAttributes());
}
String getProviderId(OAuth2User oAuth2User, AuthProvider provider) {
return switch (provider) {
case GOOGLE -> oAuth2User.getAttribute("sub");
case GITHUB -> oAuth2User.getAttribute("id").toString();
default -> oAuth2User.getName();
};
}
String getAttribute(OAuth2User oAuth2User, String key) {
Object value = oAuth2User.getAttribute(key);
return value != null ? value.toString() : null;
}
String getAvatarUrl(OAuth2User oAuth2User, AuthProvider provider) {
return switch (provider) {
case GOOGLE -> oAuth2User.getAttribute("picture");
case GITHUB -> oAuth2User.getAttribute("avatar_url");
default -> null;
};
}
User findOrCreateUser(AuthProvider provider, String providerId,
String email, String name, String avatarUrl) {
Optional<User> existing = userRepository.findByProviderAndProviderId(provider, providerId);
if (existing.isPresent()) {
User user = existing.get();
user.setName(name);
if (avatarUrl != null) {
user.setAvatarUrl(avatarUrl);
}
if (email != null && !email.equals(user.getEmail())) {
user.setEmail(email);
}
return userRepository.save(user);
}
if (email != null) {
Optional<User> emailUser = userRepository.findByEmail(email);
if (emailUser.isPresent()) {
User user = emailUser.get();
user.setProvider(provider);
user.setProviderId(providerId);
user.setEmailVerified(true);
if (avatarUrl != null) {
user.setAvatarUrl(avatarUrl);
}
return userRepository.save(user);
}
}
User newUser = User.builder()
.email(email != null ? email : providerId + "@" + provider.name().toLowerCase() + ".indie")
.name(name != null ? name : "User")
.avatarUrl(avatarUrl)
.provider(provider)
.providerId(providerId)
.emailVerified(true)
.build();
return userRepository.save(newUser);
}
}

View File

@@ -0,0 +1,43 @@
server:
port: 8080
spring:
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/indie}
username: ${SPRING_DATASOURCE_USERNAME:indie}
password: ${SPRING_DATASOURCE_PASSWORD:indie_dev}
hikari:
maximum-pool-size: 10
minimum-idle: 2
jpa:
hibernate:
ddl-auto: update
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
jdbc:
lob:
non_contextual_creation: true
show-sql: true
open-in-view: false
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
operations-sorter: method
indie:
jwt:
access-token-expiration: ${INDIE_JWT_ACCESS_EXPIRATION:900000}
refresh-token-expiration: ${INDIE_JWT_REFRESH_EXPIRATION:604800000}
private-key-path: ${INDIE_JWT_PRIVATE_KEY_PATH:config/jwt-private.key}
public-key-path: ${INDIE_JWT_PUBLIC_KEY_PATH:config/jwt-public.key}
git:
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}

View File

@@ -0,0 +1,45 @@
package com.indie.config;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import static org.junit.jupiter.api.Assertions.*;
class GlobalExceptionHandlerTest {
private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
@Test
void illegalArgumentReturns400() {
ProblemDetail detail = handler.handleBadRequest(new IllegalArgumentException("bad input"));
assertEquals(HttpStatus.BAD_REQUEST.value(), detail.getStatus());
assertEquals("bad input", detail.getDetail());
}
@Test
void validationReturns400WithJoinedMessages() {
BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(new Object(), "obj");
bindingResult.addError(new FieldError("obj", "email", "Email is required"));
bindingResult.addError(new FieldError("obj", "password", "Password is required"));
MethodArgumentNotValidException ex = new MethodArgumentNotValidException(null, bindingResult);
ProblemDetail detail = handler.handleValidation(ex);
assertEquals(HttpStatus.BAD_REQUEST.value(), detail.getStatus());
assertTrue(detail.getDetail().contains("Email is required"));
assertTrue(detail.getDetail().contains("Password is required"));
}
@Test
void generalExceptionReturns500() {
ProblemDetail detail = handler.handleGeneral(new RuntimeException("oops"));
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), detail.getStatus());
assertEquals("An unexpected error occurred", detail.getDetail());
}
}

View File

@@ -0,0 +1,45 @@
package com.indie.config;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class UserPrincipalTest {
private final UUID id = UUID.randomUUID();
private final UserPrincipal principal = new UserPrincipal(id, "test@example.com", "Test User");
@Test
void getUsernameReturnsEmail() {
assertEquals("test@example.com", principal.getUsername());
}
@Test
void getPasswordReturnsNull() {
assertNull(principal.getPassword());
}
@Test
void authoritiesContainUserRole() {
var authorities = principal.getAuthorities();
assertEquals(1, authorities.size());
assertEquals("ROLE_USER", authorities.iterator().next().getAuthority());
}
@Test
void accountIsAlwaysEnabled() {
assertTrue(principal.isEnabled());
assertTrue(principal.isAccountNonExpired());
assertTrue(principal.isAccountNonLocked());
assertTrue(principal.isCredentialsNonExpired());
}
@Test
void recordComponents() {
assertEquals(id, principal.id());
assertEquals("test@example.com", principal.email());
assertEquals("Test User", principal.name());
}
}

View File

@@ -0,0 +1,141 @@
package com.indie.controller;
import com.indie.config.GlobalExceptionHandler;
import com.indie.dto.AuthResponse;
import com.indie.dto.AuthResponse.UserInfo;
import com.indie.dto.LoginRequest;
import com.indie.dto.RefreshTokenRequest;
import com.indie.dto.RegisterRequest;
import com.indie.model.AuthProvider;
import com.indie.service.AuthService;
import com.indie.support.TestSecurityConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(AuthController.class)
@Import({GlobalExceptionHandler.class, TestSecurityConfig.class})
class AuthControllerTest {
private final MockMvc mockMvc;
private final ObjectMapper objectMapper;
@MockBean
private AuthService authService;
AuthControllerTest(@Autowired MockMvc mockMvc, @Autowired ObjectMapper objectMapper) {
this.mockMvc = mockMvc;
this.objectMapper = objectMapper;
}
@Test
void registerReturns201() throws Exception {
AuthResponse response = AuthResponse.builder()
.accessToken("at-123")
.refreshToken("rt-456")
.tokenType("Bearer")
.user(new UserInfo(UUID.randomUUID(), "test@example.com", "Test", null, AuthProvider.LOCAL))
.build();
when(authService.register(any(RegisterRequest.class))).thenReturn(response);
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)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accessToken").value("at-123"))
.andExpect(jsonPath("$.refreshToken").value("rt-456"))
.andExpect(jsonPath("$.tokenType").value("Bearer"))
.andExpect(jsonPath("$.user.email").value("test@example.com"));
}
@Test
void registerReturns400OnValidationError() throws Exception {
RegisterRequest request = new RegisterRequest();
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void loginReturns200() throws Exception {
AuthResponse response = AuthResponse.builder()
.accessToken("at-123")
.refreshToken("rt-456")
.tokenType("Bearer")
.user(new UserInfo(UUID.randomUUID(), "login@example.com", "Login User", null, AuthProvider.LOCAL))
.build();
when(authService.login(any(LoginRequest.class))).thenReturn(response);
LoginRequest request = new LoginRequest();
request.setEmail("login@example.com");
request.setPassword("password123");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.accessToken").value("at-123"))
.andExpect(jsonPath("$.user.email").value("login@example.com"));
}
@Test
void loginReturns400OnInvalidCredentials() throws Exception {
when(authService.login(any(LoginRequest.class)))
.thenThrow(new IllegalArgumentException("Invalid email or password"));
LoginRequest request = new LoginRequest();
request.setEmail("bad@example.com");
request.setPassword("wrong");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.detail").value("Invalid email or password"));
}
@Test
void refreshReturns200() throws Exception {
AuthResponse response = AuthResponse.builder()
.accessToken("new-at")
.refreshToken("new-rt")
.tokenType("Bearer")
.user(new UserInfo(UUID.randomUUID(), "user@example.com", "User", null, AuthProvider.LOCAL))
.build();
when(authService.refreshToken(any(RefreshTokenRequest.class))).thenReturn(response);
RefreshTokenRequest request = new RefreshTokenRequest();
request.setRefreshToken("valid-refresh-token");
mockMvc.perform(post("/api/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.accessToken").value("new-at"));
}
@Test
void logoutReturns204() throws Exception {
mockMvc.perform(post("/api/auth/logout"))
.andExpect(status().isNoContent());
}
}

View File

@@ -0,0 +1,201 @@
package com.indie.controller;
import com.indie.config.GlobalExceptionHandler;
import com.indie.config.JwtAuthenticationToken;
import com.indie.config.UserPrincipal;
import com.indie.dto.PageRequest;
import com.indie.model.Page;
import com.indie.model.Site;
import com.indie.model.SiteStatus;
import com.indie.repository.PageRepository;
import com.indie.repository.SiteRepository;
import com.indie.support.TestSecurityConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(PageController.class)
@Import({GlobalExceptionHandler.class, TestSecurityConfig.class})
class PageControllerTest {
private final MockMvc mockMvc;
private final ObjectMapper objectMapper;
@MockBean
private PageRepository pageRepository;
@MockBean
private SiteRepository siteRepository;
private UUID userId;
PageControllerTest(@Autowired MockMvc mockMvc, @Autowired ObjectMapper objectMapper) {
this.mockMvc = mockMvc;
this.objectMapper = objectMapper;
}
private UUID siteId;
private UserPrincipal principal;
private JwtAuthenticationToken auth;
@BeforeEach
void setUp() {
userId = UUID.randomUUID();
siteId = UUID.randomUUID();
principal = new UserPrincipal(userId, "user@test.com", "Test User");
auth = new JwtAuthenticationToken(principal, "test-token");
}
@Test
void listPagesReturns200() throws Exception {
Site site = Site.builder().id(siteId).userId(userId).name("Site").build();
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
Page page = Page.builder()
.id(UUID.randomUUID())
.siteId(siteId)
.title("Home")
.slug("home")
.orderIndex(0)
.build();
when(pageRepository.findBySiteIdOrderByOrderIndexAsc(siteId))
.thenReturn(List.of(page));
mockMvc.perform(get("/api/sites/{siteId}/pages", siteId)
.with(authentication(auth)))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value("Home"))
.andExpect(jsonPath("$[0].slug").value("home"));
}
@Test
void createPageReturns201() throws Exception {
Site site = Site.builder().id(siteId).userId(userId).name("Site").build();
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
when(pageRepository.existsBySiteIdAndSlug(siteId, "about")).thenReturn(false);
Page saved = Page.builder()
.id(UUID.randomUUID())
.siteId(siteId)
.title("About")
.slug("about")
.orderIndex(1)
.createdAt(LocalDateTime.now())
.build();
when(pageRepository.save(any(Page.class))).thenReturn(saved);
PageRequest request = new PageRequest();
request.setTitle("About");
request.setSlug("about");
request.setOrderIndex(1);
mockMvc.perform(post("/api/sites/{siteId}/pages", siteId)
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.title").value("About"))
.andExpect(jsonPath("$.slug").value("about"));
}
@Test
void createPageReturns400OnDuplicateSlug() throws Exception {
Site site = Site.builder().id(siteId).userId(userId).name("Site").build();
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
when(pageRepository.existsBySiteIdAndSlug(siteId, "home")).thenReturn(true);
PageRequest request = new PageRequest();
request.setTitle("Home");
request.setSlug("home");
request.setOrderIndex(0);
mockMvc.perform(post("/api/sites/{siteId}/pages", siteId)
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.detail").value("A page with this slug already exists"));
}
@Test
void deletePageReturns204() throws Exception {
Site site = Site.builder().id(siteId).userId(userId).name("Site").build();
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
UUID pageId = UUID.randomUUID();
Page page = Page.builder()
.id(pageId)
.siteId(siteId)
.title("To Delete")
.slug("to-delete")
.orderIndex(0)
.build();
when(pageRepository.findById(pageId)).thenReturn(Optional.of(page));
mockMvc.perform(delete("/api/sites/{siteId}/pages/{pageId}", siteId, pageId)
.with(authentication(auth)))
.andExpect(status().isNoContent());
}
@Test
void updatePageReturns200() throws Exception {
Site site = Site.builder().id(siteId).userId(userId).name("Site").build();
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
UUID pageId = UUID.randomUUID();
Page existing = Page.builder()
.id(pageId)
.siteId(siteId)
.title("Old Title")
.slug("old-slug")
.orderIndex(0)
.build();
when(pageRepository.findById(pageId)).thenReturn(Optional.of(existing));
when(pageRepository.existsBySiteIdAndSlug(siteId, "new-slug")).thenReturn(false);
when(pageRepository.save(any(Page.class))).thenAnswer(invocation -> invocation.getArgument(0));
PageRequest request = new PageRequest();
request.setTitle("New Title");
request.setSlug("new-slug");
request.setOrderIndex(1);
mockMvc.perform(put("/api/sites/{siteId}/pages/{pageId}", siteId, pageId)
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("New Title"))
.andExpect(jsonPath("$.slug").value("new-slug"));
}
@Test
void listPagesReturns400OnWrongSiteOwner() throws Exception {
Site site = Site.builder()
.id(siteId)
.userId(UUID.randomUUID())
.name("Not Mine")
.build();
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
mockMvc.perform(get("/api/sites/{siteId}/pages", siteId)
.with(authentication(auth)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.detail").value("Site not found"));
}
}

View File

@@ -0,0 +1,195 @@
package com.indie.controller;
import com.indie.config.GlobalExceptionHandler;
import com.indie.config.JwtAuthenticationToken;
import com.indie.config.UserPrincipal;
import com.indie.dto.SiteRequest;
import com.indie.model.Site;
import com.indie.model.SiteStatus;
import com.indie.repository.SiteRepository;
import com.indie.support.TestSecurityConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(SiteController.class)
@Import({GlobalExceptionHandler.class, TestSecurityConfig.class})
class SiteControllerTest {
private final MockMvc mockMvc;
private final ObjectMapper objectMapper;
@MockBean
private SiteRepository siteRepository;
private UUID userId;
SiteControllerTest(@Autowired MockMvc mockMvc, @Autowired ObjectMapper objectMapper) {
this.mockMvc = mockMvc;
this.objectMapper = objectMapper;
}
private UserPrincipal principal;
private JwtAuthenticationToken auth;
@BeforeEach
void setUp() {
userId = UUID.randomUUID();
principal = new UserPrincipal(userId, "user@test.com", "Test User");
auth = new JwtAuthenticationToken(principal, "test-token");
}
@Test
void listSitesReturns200() throws Exception {
Site site = Site.builder()
.id(UUID.randomUUID())
.userId(userId)
.name("My Site")
.status(SiteStatus.DRAFT)
.build();
when(siteRepository.findByUserIdOrderByCreatedAtDesc(userId))
.thenReturn(List.of(site));
mockMvc.perform(get("/api/sites")
.with(authentication(auth)))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value("My Site"))
.andExpect(jsonPath("$[0].status").value("DRAFT"));
}
@Test
void createSiteReturns201() throws Exception {
Site saved = Site.builder()
.id(UUID.randomUUID())
.userId(userId)
.name("New Site")
.description("Description")
.subdomain("new-site")
.status(SiteStatus.DRAFT)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
when(siteRepository.existsBySubdomain("new-site")).thenReturn(false);
when(siteRepository.save(any(Site.class))).thenReturn(saved);
SiteRequest request = new SiteRequest();
request.setName("New Site");
request.setDescription("Description");
request.setSubdomain("new-site");
mockMvc.perform(post("/api/sites")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("New Site"))
.andExpect(jsonPath("$.subdomain").value("new-site"));
}
@Test
void createSiteReturns400OnDuplicateSubdomain() throws Exception {
when(siteRepository.existsBySubdomain("taken")).thenReturn(true);
SiteRequest request = new SiteRequest();
request.setName("Site");
request.setSubdomain("taken");
mockMvc.perform(post("/api/sites")
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void getSiteReturns200() throws Exception {
UUID siteId = UUID.randomUUID();
Site site = Site.builder()
.id(siteId)
.userId(userId)
.name("My Site")
.status(SiteStatus.PUBLISHED)
.build();
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
mockMvc.perform(get("/api/sites/{id}", siteId)
.with(authentication(auth)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("My Site"))
.andExpect(jsonPath("$.status").value("PUBLISHED"));
}
@Test
void getSiteReturns400OnWrongOwner() throws Exception {
UUID siteId = UUID.randomUUID();
Site site = Site.builder()
.id(siteId)
.userId(UUID.randomUUID())
.name("Not Mine")
.build();
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
mockMvc.perform(get("/api/sites/{id}", siteId)
.with(authentication(auth)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.detail").value("Site not found"));
}
@Test
void deleteSiteReturns204() throws Exception {
UUID siteId = UUID.randomUUID();
Site site = Site.builder()
.id(siteId)
.userId(userId)
.name("To Delete")
.build();
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
mockMvc.perform(delete("/api/sites/{id}", siteId)
.with(authentication(auth)))
.andExpect(status().isNoContent());
}
@Test
void updateSiteReturns200() throws Exception {
UUID siteId = UUID.randomUUID();
Site existing = Site.builder()
.id(siteId)
.userId(userId)
.name("Old Name")
.subdomain("old-sub")
.status(SiteStatus.DRAFT)
.build();
when(siteRepository.findById(siteId)).thenReturn(Optional.of(existing));
when(siteRepository.existsBySubdomain("new-sub")).thenReturn(false);
when(siteRepository.save(any(Site.class))).thenAnswer(invocation -> invocation.getArgument(0));
SiteRequest request = new SiteRequest();
request.setName("New Name");
request.setSubdomain("new-sub");
mockMvc.perform(put("/api/sites/{id}", siteId)
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("New Name"))
.andExpect(jsonPath("$.subdomain").value("new-sub"));
}
}

View File

@@ -0,0 +1,57 @@
package com.indie.dto;
import com.indie.model.Page;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class PageResponseTest {
@Test
void fromMapsAllFields() {
UUID id = UUID.randomUUID();
UUID siteId = UUID.randomUUID();
LocalDateTime now = LocalDateTime.now();
Page page = Page.builder()
.id(id)
.siteId(siteId)
.title("About")
.slug("about")
.seoTitle("About Us")
.seoDescription("Learn about us")
.orderIndex(1)
.createdAt(now)
.build();
PageResponse response = PageResponse.from(page);
assertEquals(id, response.getId());
assertEquals(siteId, response.getSiteId());
assertEquals("About", response.getTitle());
assertEquals("about", response.getSlug());
assertEquals("About Us", response.getSeoTitle());
assertEquals("Learn about us", response.getSeoDescription());
assertEquals(1, response.getOrderIndex());
assertEquals(now, response.getCreatedAt());
}
@Test
void fromHandlesNullOptionalFields() {
Page page = Page.builder()
.id(UUID.randomUUID())
.siteId(UUID.randomUUID())
.title("Home")
.slug("home")
.orderIndex(0)
.build();
PageResponse response = PageResponse.from(page);
assertNull(response.getSeoTitle());
assertNull(response.getSeoDescription());
}
}

View File

@@ -0,0 +1,65 @@
package com.indie.dto;
import com.indie.model.Site;
import com.indie.model.SiteStatus;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class SiteResponseTest {
@Test
void fromMapsAllFields() {
UUID id = UUID.randomUUID();
UUID userId = UUID.randomUUID();
LocalDateTime now = LocalDateTime.now();
Map<String, Object> theme = Map.of("primaryColor", "#000");
Site site = Site.builder()
.id(id)
.userId(userId)
.name("My Site")
.description("A test site")
.subdomain("mysite")
.themeConfig(theme)
.status(SiteStatus.PUBLISHED)
.publishedAt(now)
.createdAt(now)
.updatedAt(now)
.build();
SiteResponse response = SiteResponse.from(site);
assertEquals(id, response.getId());
assertEquals(userId, response.getUserId());
assertEquals("My Site", response.getName());
assertEquals("A test site", response.getDescription());
assertEquals("mysite", response.getSubdomain());
assertEquals(theme, response.getThemeConfig());
assertEquals(SiteStatus.PUBLISHED, response.getStatus());
assertEquals(now, response.getPublishedAt());
assertEquals(now, response.getCreatedAt());
assertEquals(now, response.getUpdatedAt());
}
@Test
void fromHandlesNullOptionalFields() {
Site site = Site.builder()
.id(UUID.randomUUID())
.userId(UUID.randomUUID())
.name("Site")
.status(SiteStatus.DRAFT)
.build();
SiteResponse response = SiteResponse.from(site);
assertNull(response.getDescription());
assertNull(response.getSubdomain());
assertNull(response.getThemeConfig());
assertNull(response.getPublishedAt());
}
}

View File

@@ -0,0 +1,39 @@
package com.indie.model;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class ComponentTest {
@Test
void prePersistSetsId() {
Component component = new Component();
component.setPageId(UUID.randomUUID());
component.setType(ComponentType.HEADING);
component.setOrderIndex(0);
assertNull(component.getId());
component.onCreate();
assertNotNull(component.getId());
}
@Test
void prePersistDoesNotOverrideExistingId() {
UUID existingId = UUID.randomUUID();
Component component = Component.builder()
.id(existingId)
.pageId(UUID.randomUUID())
.type(ComponentType.TEXT)
.orderIndex(0)
.build();
component.onCreate();
assertEquals(existingId, component.getId());
}
}

View File

@@ -0,0 +1,57 @@
package com.indie.model;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class PageTest {
@Test
void prePersistSetsIdAndCreatedAt() {
Page page = new Page();
page.setSiteId(UUID.randomUUID());
page.setTitle("Home");
page.setSlug("home");
page.setOrderIndex(0);
assertNull(page.getId());
assertNull(page.getCreatedAt());
page.onCreate();
assertNotNull(page.getId());
assertNotNull(page.getCreatedAt());
}
@Test
void prePersistDoesNotOverrideExistingId() {
UUID existingId = UUID.randomUUID();
Page page = Page.builder()
.id(existingId)
.siteId(UUID.randomUUID())
.title("Home")
.slug("home")
.orderIndex(0)
.build();
page.onCreate();
assertEquals(existingId, page.getId());
}
@Test
void noUpdatedAtField() {
Page page = Page.builder()
.siteId(UUID.randomUUID())
.title("Home")
.slug("home")
.orderIndex(0)
.build();
page.onCreate();
assertNotNull(page.getCreatedAt());
}
}

View File

@@ -0,0 +1,68 @@
package com.indie.model;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class SiteTest {
@Test
void prePersistSetsIdCreatedAtUpdatedAtAndDefaultStatus() {
Site site = new Site();
site.setUserId(UUID.randomUUID());
site.setName("Test Site");
assertNull(site.getId());
assertNull(site.getCreatedAt());
assertNull(site.getUpdatedAt());
site.onCreate();
assertNotNull(site.getId());
assertNotNull(site.getCreatedAt());
assertNotNull(site.getUpdatedAt());
assertEquals(SiteStatus.DRAFT, site.getStatus());
}
@Test
void prePersistDoesNotOverrideExistingId() {
UUID existingId = UUID.randomUUID();
Site site = Site.builder()
.id(existingId)
.userId(UUID.randomUUID())
.name("Test")
.build();
site.onCreate();
assertEquals(existingId, site.getId());
}
@Test
void preUpdateSetsUpdatedAt() {
Site site = Site.builder()
.userId(UUID.randomUUID())
.name("Test")
.build();
site.onCreate();
LocalDateTime original = site.getUpdatedAt();
site.onUpdate();
assertTrue(site.getUpdatedAt().isAfter(original));
}
@Test
void builderSetsDefaultStatus() {
Site site = Site.builder()
.userId(UUID.randomUUID())
.name("Test")
.build();
assertEquals(SiteStatus.DRAFT, site.getStatus());
}
}

View File

@@ -0,0 +1,71 @@
package com.indie.model;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class UserTest {
@Test
void prePersistSetsIdCreatedAtAndUpdatedAt() {
User user = new User();
user.setEmail("test@example.com");
user.setName("Test User");
user.setProvider(AuthProvider.LOCAL);
assertNull(user.getId());
assertNull(user.getCreatedAt());
assertNull(user.getUpdatedAt());
user.onCreate();
assertNotNull(user.getId());
assertNotNull(user.getCreatedAt());
assertNotNull(user.getUpdatedAt());
}
@Test
void prePersistDoesNotOverrideExistingId() {
User user = User.builder()
.id(UUID.randomUUID())
.email("test@example.com")
.name("Test")
.provider(AuthProvider.LOCAL)
.build();
UUID existingId = user.getId();
user.onCreate();
assertEquals(existingId, user.getId());
}
@Test
void preUpdateSetsUpdatedAt() {
User user = User.builder()
.email("test@example.com")
.name("Test")
.provider(AuthProvider.LOCAL)
.build();
user.onCreate();
LocalDateTime original = user.getUpdatedAt();
user.onUpdate();
assertTrue(user.getUpdatedAt().isAfter(original));
}
@Test
void builderDefaults() {
User user = User.builder()
.email("test@example.com")
.name("Test")
.provider(AuthProvider.LOCAL)
.build();
assertFalse(user.isEmailVerified());
}
}

View File

@@ -0,0 +1,92 @@
package com.indie.repository;
import com.indie.model.Component;
import com.indie.model.ComponentType;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
@DataJpaTest
class ComponentRepositoryTest {
private final ComponentRepository componentRepository;
ComponentRepositoryTest(@Autowired ComponentRepository componentRepository) {
this.componentRepository = componentRepository;
}
@Test
void saveAndFindById() {
Component component = Component.builder()
.pageId(UUID.randomUUID())
.type(ComponentType.HEADING)
.orderIndex(0)
.build();
Component saved = componentRepository.save(component);
assertNotNull(saved.getId());
var found = componentRepository.findById(saved.getId());
assertTrue(found.isPresent());
assertEquals(ComponentType.HEADING, found.get().getType());
}
@Test
void findByPageIdOrderByOrderIndexAsc() {
UUID pageId = UUID.randomUUID();
Component c1 = Component.builder().pageId(pageId).type(ComponentType.TEXT).orderIndex(1).build();
Component c2 = Component.builder().pageId(pageId).type(ComponentType.HEADING).orderIndex(0).build();
componentRepository.save(c1);
componentRepository.save(c2);
List<Component> components = componentRepository.findByPageIdOrderByOrderIndexAsc(pageId);
assertEquals(2, components.size());
assertEquals(ComponentType.HEADING, components.get(0).getType());
assertEquals(ComponentType.TEXT, components.get(1).getType());
}
@Test
void findByPageIdReturnsEmptyForUnknownPage() {
List<Component> components = componentRepository.findByPageIdOrderByOrderIndexAsc(UUID.randomUUID());
assertTrue(components.isEmpty());
}
@Test
void deleteByPageId() {
UUID pageId = UUID.randomUUID();
Component c1 = Component.builder().pageId(pageId).type(ComponentType.BUTTON).orderIndex(0).build();
Component c2 = Component.builder().pageId(pageId).type(ComponentType.TEXT).orderIndex(1).build();
componentRepository.save(c1);
componentRepository.save(c2);
componentRepository.deleteByPageId(pageId);
List<Component> remaining = componentRepository.findByPageIdOrderByOrderIndexAsc(pageId);
assertTrue(remaining.isEmpty());
}
@Test
void deleteByPageIdOnlyDeletesForGivenPage() {
UUID pageId1 = UUID.randomUUID();
UUID pageId2 = UUID.randomUUID();
Component c1 = Component.builder().pageId(pageId1).type(ComponentType.GALLERY).orderIndex(0).build();
Component c2 = Component.builder().pageId(pageId2).type(ComponentType.IMAGE).orderIndex(0).build();
componentRepository.save(c1);
componentRepository.save(c2);
componentRepository.deleteByPageId(pageId1);
assertEquals(1, componentRepository.findByPageIdOrderByOrderIndexAsc(pageId2).size());
assertEquals(0, componentRepository.findByPageIdOrderByOrderIndexAsc(pageId1).size());
}
}

View File

@@ -0,0 +1,101 @@
package com.indie.repository;
import com.indie.model.Page;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
@DataJpaTest
class PageRepositoryTest {
private final PageRepository pageRepository;
PageRepositoryTest(@Autowired PageRepository pageRepository) {
this.pageRepository = pageRepository;
}
@Test
void saveAndFindById() {
Page page = Page.builder()
.siteId(UUID.randomUUID())
.title("Home")
.slug("home")
.orderIndex(0)
.build();
Page saved = pageRepository.save(page);
assertNotNull(saved.getId());
Optional<Page> found = pageRepository.findById(saved.getId());
assertTrue(found.isPresent());
assertEquals("Home", found.get().getTitle());
}
@Test
void findBySiteIdOrderByOrderIndexAsc() {
UUID siteId = UUID.randomUUID();
Page page1 = Page.builder().siteId(siteId).title("About").slug("about").orderIndex(1).build();
Page page2 = Page.builder().siteId(siteId).title("Home").slug("home").orderIndex(0).build();
pageRepository.save(page1);
pageRepository.save(page2);
List<Page> pages = pageRepository.findBySiteIdOrderByOrderIndexAsc(siteId);
assertEquals(2, pages.size());
assertEquals("home", pages.get(0).getSlug());
assertEquals("about", pages.get(1).getSlug());
}
@Test
void findBySiteIdReturnsEmptyForUnknownSite() {
List<Page> pages = pageRepository.findBySiteIdOrderByOrderIndexAsc(UUID.randomUUID());
assertTrue(pages.isEmpty());
}
@Test
void findBySiteIdAndSlug() {
UUID siteId = UUID.randomUUID();
Page page = Page.builder()
.siteId(siteId)
.title("Contact")
.slug("contact")
.orderIndex(2)
.build();
pageRepository.save(page);
Optional<Page> found = pageRepository.findBySiteIdAndSlug(siteId, "contact");
assertTrue(found.isPresent());
assertEquals("Contact", found.get().getTitle());
}
@Test
void findBySiteIdAndSlugReturnsEmptyWhenNotMatching() {
Optional<Page> found = pageRepository.findBySiteIdAndSlug(UUID.randomUUID(), "nonexistent");
assertTrue(found.isEmpty());
}
@Test
void existsBySiteIdAndSlug() {
UUID siteId = UUID.randomUUID();
Page page = Page.builder()
.siteId(siteId)
.title("Blog")
.slug("blog")
.orderIndex(0)
.build();
pageRepository.save(page);
assertTrue(pageRepository.existsBySiteIdAndSlug(siteId, "blog"));
assertFalse(pageRepository.existsBySiteIdAndSlug(siteId, "nonexistent"));
}
}

View File

@@ -0,0 +1,89 @@
package com.indie.repository;
import com.indie.model.Site;
import com.indie.model.SiteStatus;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
@DataJpaTest
class SiteRepositoryTest {
private final SiteRepository siteRepository;
SiteRepositoryTest(@Autowired SiteRepository siteRepository) {
this.siteRepository = siteRepository;
}
@Test
void saveAndFindById() {
Site site = Site.builder()
.userId(UUID.randomUUID())
.name("Test Site")
.build();
Site saved = siteRepository.save(site);
assertNotNull(saved.getId());
assertEquals(SiteStatus.DRAFT, saved.getStatus());
Optional<Site> found = siteRepository.findById(saved.getId());
assertTrue(found.isPresent());
assertEquals("Test Site", found.get().getName());
}
@Test
void findByUserIdOrderByCreatedAtDesc() {
UUID userId = UUID.randomUUID();
Site site1 = Site.builder().userId(userId).name("Site 1").build();
Site site2 = Site.builder().userId(userId).name("Site 2").build();
siteRepository.save(site1);
siteRepository.save(site2);
List<Site> sites = siteRepository.findByUserIdOrderByCreatedAtDesc(userId);
assertEquals(2, sites.size());
assertTrue(sites.get(0).getCreatedAt().isAfter(sites.get(1).getCreatedAt())
|| sites.get(0).getCreatedAt().equals(sites.get(1).getCreatedAt()));
}
@Test
void findByUserIdReturnsEmptyForUnknownUser() {
List<Site> sites = siteRepository.findByUserIdOrderByCreatedAtDesc(UUID.randomUUID());
assertTrue(sites.isEmpty());
}
@Test
void findBySubdomain() {
Site site = Site.builder()
.userId(UUID.randomUUID())
.name("Site")
.subdomain("mysubdomain")
.build();
siteRepository.save(site);
Optional<Site> found = siteRepository.findBySubdomain("mysubdomain");
assertTrue(found.isPresent());
assertEquals("Site", found.get().getName());
}
@Test
void existsBySubdomain() {
Site site = Site.builder()
.userId(UUID.randomUUID())
.name("Site")
.subdomain("exists-sub")
.build();
siteRepository.save(site);
assertTrue(siteRepository.existsBySubdomain("exists-sub"));
assertFalse(siteRepository.existsBySubdomain("no-sub"));
}
}

View File

@@ -0,0 +1,89 @@
package com.indie.repository;
import com.indie.model.AuthProvider;
import com.indie.model.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
@DataJpaTest
class UserRepositoryTest {
private final UserRepository userRepository;
UserRepositoryTest(@Autowired UserRepository userRepository) {
this.userRepository = userRepository;
}
@Test
void saveAndFindById() {
User user = User.builder()
.email("test@example.com")
.name("Test User")
.provider(AuthProvider.LOCAL)
.build();
User saved = userRepository.save(user);
assertNotNull(saved.getId());
Optional<User> found = userRepository.findById(saved.getId());
assertTrue(found.isPresent());
assertEquals("test@example.com", found.get().getEmail());
}
@Test
void findByEmail() {
User user = User.builder()
.email("find@example.com")
.name("Find Me")
.provider(AuthProvider.LOCAL)
.build();
userRepository.save(user);
Optional<User> found = userRepository.findByEmail("find@example.com");
assertTrue(found.isPresent());
assertEquals("Find Me", found.get().getName());
}
@Test
void findByEmailReturnsEmptyWhenNotFound() {
Optional<User> found = userRepository.findByEmail("doesnotexist@example.com");
assertTrue(found.isEmpty());
}
@Test
void findByProviderAndProviderId() {
User user = User.builder()
.email("oauth@example.com")
.name("OAuth User")
.provider(AuthProvider.GOOGLE)
.providerId("google-123")
.build();
userRepository.save(user);
Optional<User> found = userRepository.findByProviderAndProviderId(AuthProvider.GOOGLE, "google-123");
assertTrue(found.isPresent());
assertEquals("oauth@example.com", found.get().getEmail());
}
@Test
void existsByEmail() {
User user = User.builder()
.email("exists@example.com")
.name("Exists")
.provider(AuthProvider.LOCAL)
.build();
userRepository.save(user);
assertTrue(userRepository.existsByEmail("exists@example.com"));
assertFalse(userRepository.existsByEmail("noone@example.com"));
}
}

View File

@@ -0,0 +1,190 @@
package com.indie.service;
import com.indie.dto.AuthResponse;
import com.indie.dto.LoginRequest;
import com.indie.dto.RefreshTokenRequest;
import com.indie.dto.RegisterRequest;
import com.indie.model.AuthProvider;
import com.indie.model.User;
import com.indie.support.FakeJwtConfig;
import com.indie.support.FakeUserRepository;
import com.indie.support.StubPasswordEncoder;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class AuthServiceTest {
private FakeUserRepository userRepository;
private AuthService authService;
@BeforeEach
void setUp() {
userRepository = new FakeUserRepository();
authService = new AuthService(userRepository, new StubPasswordEncoder(), new FakeJwtConfig());
}
@Test
void registerCreatesUserAndReturnsTokens() {
RegisterRequest request = new RegisterRequest();
request.setEmail("new@example.com");
request.setPassword("password123");
request.setName("New User");
AuthResponse response = authService.register(request);
assertNotNull(response.getAccessToken());
assertNotNull(response.getRefreshToken());
assertEquals("Bearer", response.getTokenType());
assertEquals("new@example.com", response.getUser().getEmail());
assertEquals("New User", response.getUser().getName());
assertEquals(AuthProvider.LOCAL, response.getUser().getProvider());
}
@Test
void registerThrowsOnDuplicateEmail() {
RegisterRequest request = new RegisterRequest();
request.setEmail("dup@example.com");
request.setPassword("password123");
request.setName("User");
authService.register(request);
RegisterRequest duplicate = new RegisterRequest();
duplicate.setEmail("dup@example.com");
duplicate.setPassword("password456");
duplicate.setName("Other");
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> authService.register(duplicate));
assertEquals("Email already registered", ex.getMessage());
}
@Test
void registerNormalizesEmailCase() {
RegisterRequest request = new RegisterRequest();
request.setEmail("UpperCase@Example.com");
request.setPassword("password123");
request.setName("User");
AuthResponse response = authService.register(request);
assertEquals("uppercase@example.com", response.getUser().getEmail());
}
@Test
void loginWithValidCredentialsReturnsTokens() {
RegisterRequest reg = new RegisterRequest();
reg.setEmail("login@example.com");
reg.setPassword("password123");
reg.setName("Login User");
authService.register(reg);
LoginRequest login = new LoginRequest();
login.setEmail("login@example.com");
login.setPassword("password123");
AuthResponse response = authService.login(login);
assertNotNull(response.getAccessToken());
assertNotNull(response.getRefreshToken());
assertEquals("login@example.com", response.getUser().getEmail());
}
@Test
void loginThrowsOnWrongPassword() {
RegisterRequest reg = new RegisterRequest();
reg.setEmail("wrongpw@example.com");
reg.setPassword("correctpw");
reg.setName("User");
authService.register(reg);
LoginRequest login = new LoginRequest();
login.setEmail("wrongpw@example.com");
login.setPassword("wrongpw");
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> authService.login(login));
assertEquals("Invalid email or password", ex.getMessage());
}
@Test
void loginThrowsOnNonExistentEmail() {
LoginRequest login = new LoginRequest();
login.setEmail("nonexistent@example.com");
login.setPassword("password123");
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> authService.login(login));
assertEquals("Invalid email or password", ex.getMessage());
}
@Test
void loginThrowsOnOAuthUserTryingPasswordLogin() {
User oauthUser = User.builder()
.email("oauth@example.com")
.name("OAuth User")
.provider(AuthProvider.GOOGLE)
.providerId("google-123")
.build();
userRepository.save(oauthUser);
LoginRequest login = new LoginRequest();
login.setEmail("oauth@example.com");
login.setPassword("anypassword");
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> authService.login(login));
assertEquals("Please sign in with google", ex.getMessage());
}
@Test
void refreshTokenReturnsNewTokens() {
RegisterRequest reg = new RegisterRequest();
reg.setEmail("refresh@example.com");
reg.setPassword("password123");
reg.setName("Refresh User");
AuthResponse initial = authService.register(reg);
RefreshTokenRequest refreshRequest = new RefreshTokenRequest();
refreshRequest.setRefreshToken(initial.getRefreshToken());
AuthResponse response = authService.refreshToken(refreshRequest);
assertNotNull(response.getAccessToken());
assertNotNull(response.getRefreshToken());
assertEquals("refresh@example.com", response.getUser().getEmail());
}
@Test
void refreshTokenThrowsOnInvalidToken() {
RefreshTokenRequest request = new RefreshTokenRequest();
request.setRefreshToken("invalid-token-here");
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> authService.refreshToken(request));
assertEquals("Invalid or expired refresh token", ex.getMessage());
}
@Test
void refreshTokenThrowsWhenUserDeleted() {
User user = User.builder()
.email("deleted@example.com")
.name("Deleted")
.provider(AuthProvider.LOCAL)
.build();
userRepository.save(user);
FakeJwtConfig jwtConfig = new FakeJwtConfig();
String token = jwtConfig.generateRefreshToken(user);
userRepository.deleteById(user.getId());
RefreshTokenRequest request = new RefreshTokenRequest();
request.setRefreshToken(token);
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> authService.refreshToken(request));
assertEquals("Invalid or expired refresh token", ex.getMessage());
}
}

View File

@@ -0,0 +1,129 @@
package com.indie.service;
import com.indie.model.AuthProvider;
import com.indie.model.User;
import com.indie.support.FakeUserRepository;
import com.indie.support.TestOAuth2User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class OAuth2UserServiceTest {
private FakeUserRepository userRepository;
private OAuth2UserService oAuth2UserService;
@BeforeEach
void setUp() {
userRepository = new FakeUserRepository();
oAuth2UserService = new OAuth2UserService(userRepository);
}
@Test
void getProviderIdForGoogleReturnsSub() {
TestOAuth2User oAuth2User = new TestOAuth2User(Map.of("sub", "google-123"));
assertEquals("google-123", oAuth2UserService.getProviderId(oAuth2User, AuthProvider.GOOGLE));
}
@Test
void getProviderIdForGitHubReturnsId() {
TestOAuth2User oAuth2User = new TestOAuth2User(Map.of("id", 456));
assertEquals("456", oAuth2UserService.getProviderId(oAuth2User, AuthProvider.GITHUB));
}
@Test
void getAvatarUrlForGoogleReturnsPicture() {
TestOAuth2User oAuth2User = new TestOAuth2User(Map.of("picture", "https://example.com/avatar.jpg"));
assertEquals("https://example.com/avatar.jpg",
oAuth2UserService.getAvatarUrl(oAuth2User, AuthProvider.GOOGLE));
}
@Test
void getAvatarUrlForGitHubReturnsAvatarUrl() {
TestOAuth2User oAuth2User = new TestOAuth2User(Map.of("avatar_url", "https://github.com/avatar.png"));
assertEquals("https://github.com/avatar.png",
oAuth2UserService.getAvatarUrl(oAuth2User, AuthProvider.GITHUB));
}
@Test
void getAvatarUrlForOpenIdReturnsNull() {
TestOAuth2User oAuth2User = new TestOAuth2User(Map.of());
assertNull(oAuth2UserService.getAvatarUrl(oAuth2User, AuthProvider.OPENID));
}
@Test
void getAttributeReturnsStringValue() {
TestOAuth2User oAuth2User = new TestOAuth2User(Map.of("email", "test@example.com"));
assertEquals("test@example.com", oAuth2UserService.getAttribute(oAuth2User, "email"));
}
@Test
void getAttributeReturnsNullWhenMissing() {
TestOAuth2User oAuth2User = new TestOAuth2User(Map.of());
assertNull(oAuth2UserService.getAttribute(oAuth2User, "missing"));
}
@Test
void findOrCreateUserCreatesNewUser() {
User user = oAuth2UserService.findOrCreateUser(
AuthProvider.GOOGLE, "google-789",
"new@example.com", "New User", "https://example.com/avatar.jpg");
assertNotNull(user.getId());
assertEquals("new@example.com", user.getEmail());
assertEquals("New User", user.getName());
assertEquals(AuthProvider.GOOGLE, user.getProvider());
assertEquals("google-789", user.getProviderId());
assertTrue(user.isEmailVerified());
}
@Test
void findOrCreateUserUpdatesExistingOAuthUser() {
User existing = User.builder()
.email("existing@example.com")
.name("Old Name")
.provider(AuthProvider.GOOGLE)
.providerId("google-456")
.build();
userRepository.save(existing);
User updated = oAuth2UserService.findOrCreateUser(
AuthProvider.GOOGLE, "google-456",
"existing@example.com", "Updated Name", "https://example.com/new-avatar.jpg");
assertEquals("Updated Name", updated.getName());
assertEquals("https://example.com/new-avatar.jpg", updated.getAvatarUrl());
}
@Test
void findOrCreateUserLinksLocalUserToOAuth() {
User localUser = User.builder()
.email("link@example.com")
.name("Link User")
.provider(AuthProvider.LOCAL)
.password("hashedpw")
.build();
userRepository.save(localUser);
User linked = oAuth2UserService.findOrCreateUser(
AuthProvider.GITHUB, "github-789",
"link@example.com", "Link User", "https://github.com/avatar.png");
assertEquals(AuthProvider.GITHUB, linked.getProvider());
assertEquals("github-789", linked.getProviderId());
assertTrue(linked.isEmailVerified());
}
@Test
void findOrCreateUserGeneratesEmailWhenMissing() {
User user = oAuth2UserService.findOrCreateUser(
AuthProvider.GITHUB, "github-nomail",
null, "No Email", null);
assertTrue(user.getEmail().contains("github-nomail"));
assertTrue(user.getEmail().endsWith("@github.indie"));
}
}

View File

@@ -0,0 +1,40 @@
package com.indie.support;
import com.indie.model.Component;
import com.indie.repository.ComponentRepository;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
public class FakeComponentRepository extends InMemoryRepository<Component, UUID> implements ComponentRepository {
@Override
protected UUID getId(Component entity) {
return entity.getId();
}
@Override
protected Component setId(Component entity, UUID id) {
entity.setId(id);
return entity;
}
@Override
protected UUID generateId() {
return UUID.randomUUID();
}
@Override
public List<Component> findByPageIdOrderByOrderIndexAsc(UUID pageId) {
return store.values().stream()
.filter(c -> c.getPageId().equals(pageId))
.sorted(Comparator.comparingInt(Component::getOrderIndex))
.toList();
}
@Override
public void deleteByPageId(UUID pageId) {
store.values().removeIf(c -> c.getPageId().equals(pageId));
}
}

View File

@@ -0,0 +1,49 @@
package com.indie.support;
import com.indie.config.TokenService;
import com.indie.model.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import javax.crypto.SecretKey;
import java.util.Date;
import static io.jsonwebtoken.Jwts.SIG.HS256;
public class FakeJwtConfig implements TokenService {
private static final long ACCESS_EXPIRATION = 900_000;
private static final long REFRESH_EXPIRATION = 604_800_000;
private static final SecretKey SECRET_KEY = HS256.key().build();
@Override
public String generateAccessToken(User user) {
return generateToken(user, ACCESS_EXPIRATION);
}
@Override
public String generateRefreshToken(User user) {
return generateToken(user, REFRESH_EXPIRATION);
}
private String generateToken(User user, long expiration) {
Date now = new Date();
return Jwts.builder()
.subject(user.getId().toString())
.claim("email", user.getEmail())
.claim("name", user.getName())
.issuedAt(now)
.expiration(new Date(now.getTime() + expiration))
.signWith(SECRET_KEY)
.compact();
}
@Override
public Claims validateToken(String token) {
return Jwts.parser()
.verifyWith(SECRET_KEY)
.build()
.parseSignedClaims(token)
.getPayload();
}
}

View File

@@ -0,0 +1,49 @@
package com.indie.support;
import com.indie.model.Page;
import com.indie.repository.PageRepository;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public class FakePageRepository extends InMemoryRepository<Page, UUID> implements PageRepository {
@Override
protected UUID getId(Page entity) {
return entity.getId();
}
@Override
protected Page setId(Page entity, UUID id) {
entity.setId(id);
return entity;
}
@Override
protected UUID generateId() {
return UUID.randomUUID();
}
@Override
public List<Page> findBySiteIdOrderByOrderIndexAsc(UUID siteId) {
return store.values().stream()
.filter(p -> p.getSiteId().equals(siteId))
.sorted(Comparator.comparingInt(Page::getOrderIndex))
.toList();
}
@Override
public Optional<Page> findBySiteIdAndSlug(UUID siteId, String slug) {
return store.values().stream()
.filter(p -> p.getSiteId().equals(siteId) && p.getSlug().equals(slug))
.findFirst();
}
@Override
public boolean existsBySiteIdAndSlug(UUID siteId, String slug) {
return store.values().stream()
.anyMatch(p -> p.getSiteId().equals(siteId) && p.getSlug().equals(slug));
}
}

View File

@@ -0,0 +1,49 @@
package com.indie.support;
import com.indie.model.Site;
import com.indie.repository.SiteRepository;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public class FakeSiteRepository extends InMemoryRepository<Site, UUID> implements SiteRepository {
@Override
protected UUID getId(Site entity) {
return entity.getId();
}
@Override
protected Site setId(Site entity, UUID id) {
entity.setId(id);
return entity;
}
@Override
protected UUID generateId() {
return UUID.randomUUID();
}
@Override
public List<Site> findByUserIdOrderByCreatedAtDesc(UUID userId) {
return store.values().stream()
.filter(s -> s.getUserId().equals(userId))
.sorted(Comparator.comparing(Site::getCreatedAt).reversed())
.toList();
}
@Override
public Optional<Site> findBySubdomain(String subdomain) {
return store.values().stream()
.filter(s -> subdomain.equals(s.getSubdomain()))
.findFirst();
}
@Override
public boolean existsBySubdomain(String subdomain) {
return store.values().stream()
.anyMatch(s -> subdomain.equals(s.getSubdomain()));
}
}

View File

@@ -0,0 +1,47 @@
package com.indie.support;
import com.indie.model.AuthProvider;
import com.indie.model.User;
import com.indie.repository.UserRepository;
import java.util.Optional;
import java.util.UUID;
public class FakeUserRepository extends InMemoryRepository<User, UUID> implements UserRepository {
@Override
protected UUID getId(User entity) {
return entity.getId();
}
@Override
protected User setId(User entity, UUID id) {
entity.setId(id);
return entity;
}
@Override
protected UUID generateId() {
return UUID.randomUUID();
}
@Override
public Optional<User> findByEmail(String email) {
return store.values().stream()
.filter(u -> u.getEmail().equals(email))
.findFirst();
}
@Override
public Optional<User> findByProviderAndProviderId(AuthProvider provider, String providerId) {
return store.values().stream()
.filter(u -> u.getProvider() == provider && providerId.equals(u.getProviderId()))
.findFirst();
}
@Override
public boolean existsByEmail(String email) {
return store.values().stream()
.anyMatch(u -> u.getEmail().equals(email));
}
}

View File

@@ -0,0 +1,209 @@
package com.indie.support;
import org.springframework.data.domain.Example;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.repository.query.FluentQuery;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
public abstract class InMemoryRepository<T, ID> implements JpaRepository<T, ID> {
protected final Map<ID, T> store = new ConcurrentHashMap<>();
protected abstract ID getId(T entity);
protected abstract T setId(T entity, ID id);
protected abstract ID generateId();
@Override
@SuppressWarnings("unchecked")
public <S extends T> S save(S entity) {
ID id = getId(entity);
if (id == null) {
id = generateId();
setId(entity, id);
}
store.put(id, entity);
return entity;
}
@Override
public <S extends T> List<S> saveAll(Iterable<S> entities) {
List<S> saved = new ArrayList<>();
for (S entity : entities) {
saved.add(save(entity));
}
return saved;
}
@Override
public Optional<T> findById(ID id) {
return Optional.ofNullable(store.get(id));
}
@Override
public boolean existsById(ID id) {
return store.containsKey(id);
}
@Override
public List<T> findAll() {
return new ArrayList<>(store.values());
}
@Override
public List<T> findAllById(Iterable<ID> ids) {
List<T> result = new ArrayList<>();
for (ID id : ids) {
T entity = store.get(id);
if (entity != null) {
result.add(entity);
}
}
return result;
}
@Override
public long count() {
return store.size();
}
@Override
public void deleteById(ID id) {
store.remove(id);
}
@Override
public void delete(T entity) {
ID id = getId(entity);
if (id != null) {
store.remove(id);
}
}
@Override
public void deleteAll(Iterable<? extends T> entities) {
for (T entity : entities) {
delete(entity);
}
}
@Override
public void deleteAllById(Iterable<? extends ID> ids) {
for (ID id : ids) {
store.remove(id);
}
}
@Override
public void deleteAll() {
store.clear();
}
@Override
public void deleteAllInBatch() {
store.clear();
}
@Override
public void deleteAllInBatch(Iterable<T> entities) {
for (T entity : entities) {
delete(entity);
}
}
@Override
public void deleteAllByIdInBatch(Iterable<ID> ids) {
for (ID id : ids) {
store.remove(id);
}
}
@Override
public void flush() {
}
@Override
public <S extends T> S saveAndFlush(S entity) {
return save(entity);
}
@Override
public <S extends T> List<S> saveAllAndFlush(Iterable<S> entities) {
return saveAll(entities);
}
@Override
public List<T> findAll(Sort sort) {
throw new UnsupportedOperationException("Not implemented in fake");
}
@Override
public Page<T> findAll(Pageable pageable) {
throw new UnsupportedOperationException("Not implemented in fake");
}
@Override
public T getReferenceById(ID id) {
return store.get(id);
}
@Override
public T getById(ID id) {
return store.get(id);
}
@Override
@Deprecated
public T getOne(ID id) {
return store.get(id);
}
@Override
public <S extends T> Optional<S> findOne(Example<S> example) {
throw new UnsupportedOperationException("Not implemented in fake");
}
@Override
public <S extends T> List<S> findAll(Example<S> example) {
throw new UnsupportedOperationException("Not implemented in fake");
}
@Override
public <S extends T> List<S> findAll(Example<S> example, Sort sort) {
throw new UnsupportedOperationException("Not implemented in fake");
}
@Override
public <S extends T> Page<S> findAll(Example<S> example, Pageable pageable) {
throw new UnsupportedOperationException("Not implemented in fake");
}
@Override
public <S extends T> long count(Example<S> example) {
throw new UnsupportedOperationException("Not implemented in fake");
}
@Override
public <S extends T> boolean exists(Example<S> example) {
throw new UnsupportedOperationException("Not implemented in fake");
}
@Override
public <S extends T, R> R findBy(Example<S> example, Function<FluentQuery.FetchableFluentQuery<S>, R> queryFunction) {
throw new UnsupportedOperationException("Not implemented in fake");
}
public void clear() {
store.clear();
}
}

View File

@@ -0,0 +1,18 @@
package com.indie.support;
import org.springframework.security.crypto.password.PasswordEncoder;
public class StubPasswordEncoder implements PasswordEncoder {
private static final String PREFIX = "{stub}";
@Override
public String encode(CharSequence rawPassword) {
return PREFIX + rawPassword;
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return encodedPassword.equals(PREFIX + rawPassword);
}
}

View File

@@ -0,0 +1,38 @@
package com.indie.support;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.core.user.OAuth2User;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
public class TestOAuth2User implements OAuth2User {
private final Map<String, Object> attributes;
public TestOAuth2User(Map<String, Object> attributes) {
this.attributes = attributes;
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_USER"));
}
@Override
public String getName() {
Object sub = attributes.get("sub");
if (sub != null) return sub.toString();
Object id = attributes.get("id");
if (id != null) return id.toString();
return "test-user";
}
}

View File

@@ -0,0 +1,21 @@
package com.indie.support;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@TestConfiguration
public class TestSecurityConfig {
@Bean
public SecurityFilterChain testFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}

View File

@@ -0,0 +1,16 @@
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
hbm2ddl:
create_namespaces: true
show-sql: true
open-in-view: false

38
docker-compose.yml Normal file
View File

@@ -0,0 +1,38 @@
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:

16
frontend/.editorconfig Normal file
View File

@@ -0,0 +1,16 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
[*.md]
max_line_length = off
trim_trailing_whitespace = false

42
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

12
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx ng build
FROM nginx:alpine
COPY --from=build /app/dist/indie-frontend/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

27
frontend/README.md Normal file
View File

@@ -0,0 +1,27 @@
# IndieFrontend
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 17.3.17.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.

128
frontend/angular.json Normal file
View File

@@ -0,0 +1,128 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"indie-frontend": {
"projectType": "application",
"schematics": {
"@schematics/angular:class": {
"skipTests": true
},
"@schematics/angular:component": {
"skipTests": true
},
"@schematics/angular:directive": {
"skipTests": true
},
"@schematics/angular:guard": {
"skipTests": true
},
"@schematics/angular:interceptor": {
"skipTests": true
},
"@schematics/angular:pipe": {
"skipTests": true
},
"@schematics/angular:resolver": {
"skipTests": true
},
"@schematics/angular:service": {
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/indie-frontend",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"@angular/material/prebuilt-themes/indigo-pink.css",
"src/styles.css"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "indie-frontend:build:production"
},
"development": {
"buildTarget": "indie-frontend:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "indie-frontend:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"@angular/material/prebuilt-themes/indigo-pink.css",
"src/styles.css"
],
"scripts": []
}
}
}
}
},
"cli": {
"analytics": false
}
}

22
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,22 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8080;
}
location /oauth2/ {
proxy_pass http://backend:8080;
}
location /login/ {
proxy_pass http://backend:8080;
}
}

13609
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
frontend/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "indie-frontend",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "^17.3.0",
"@angular/cdk": "^17.3.10",
"@angular/common": "^17.3.0",
"@angular/compiler": "^17.3.0",
"@angular/core": "^17.3.0",
"@angular/forms": "^17.3.0",
"@angular/material": "^17.3.10",
"@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0",
"@angular/router": "^17.3.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.3"
},
"devDependencies": {
"@angular-devkit/build-angular": "^17.3.17",
"@angular/cli": "^17.3.17",
"@angular/compiler-cli": "^17.3.0",
"@types/jasmine": "~5.1.0",
"autoprefixer": "^10.5.2",
"jasmine-core": "~5.1.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"postcss": "^8.5.16",
"tailwindcss": "^3.4.19",
"typescript": "~5.4.2"
}
}

8446
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

14
frontend/proxy.conf.json Normal file
View File

@@ -0,0 +1,14 @@
{
"/api": {
"target": "http://localhost:8080",
"secure": false
},
"/oauth2": {
"target": "http://localhost:8080",
"secure": false
},
"/login": {
"target": "http://localhost:8080",
"secure": false
}
}

View File

View File

@@ -0,0 +1 @@
<router-outlet></router-outlet>

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
template: `<router-outlet></router-outlet>`,
})
export class AppComponent {}

View File

@@ -0,0 +1,16 @@
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { JwtInterceptor } from './core/interceptors/jwt.interceptor';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideAnimationsAsync(),
provideHttpClient(withInterceptorsFromDi()),
{ provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true },
],
};

View File

@@ -0,0 +1,24 @@
import { Routes } from '@angular/router';
import { AuthGuard } from './core/guards/auth.guard';
export const routes: Routes = [
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
{
path: 'login',
loadComponent: () => import('./auth/login/login.component').then(m => m.LoginComponent),
},
{
path: 'register',
loadComponent: () => import('./auth/register/register.component').then(m => m.RegisterComponent),
},
{
path: 'oauth2/callback',
loadComponent: () => import('./auth/oauth-callback/oauth-callback.component').then(m => m.OAuthCallbackComponent),
},
{
path: 'dashboard',
loadComponent: () => import('./dashboard/dashboard.component').then(m => m.DashboardComponent),
canActivate: [AuthGuard],
},
{ path: '**', redirectTo: '/dashboard' },
];

View File

@@ -0,0 +1,99 @@
import { Component } from '@angular/core';
import { Router, 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 { MatDividerModule } from '@angular/material/divider';
import { AuthService } from '../../core/services/auth.service';
import { NgIf } from '@angular/common';
@Component({
selector: 'app-login',
standalone: true,
imports: [
RouterLink, ReactiveFormsModule, NgIf,
MatCardModule, MatFormFieldModule, MatInputModule, MatButtonModule,
MatProgressSpinnerModule, MatDividerModule,
],
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">Sign in to your account</p>
</div>
<form [formGroup]="loginForm" (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="loginForm.get('email')?.hasError('required')">Email is required</mat-error>
<mat-error *ngIf="loginForm.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="loginForm.get('password')?.hasError('required')">Password is required</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>
{{ loading ? 'Signing in...' : 'Sign In' }}
</button>
</form>
<div class="my-4">
<mat-divider></mat-divider>
</div>
<div class="flex flex-col gap-3">
<a mat-stroked-button href="/oauth2/authorization/google" class="w-full">
Sign in with Google
</a>
<a mat-stroked-button href="/oauth2/authorization/github" class="w-full">
Sign in with GitHub
</a>
</div>
<p class="text-center mt-6 text-sm text-gray-500">
Don't have an account?
<a routerLink="/register" class="text-indigo-600 font-medium hover:underline">Register</a>
</p>
</mat-card>
</div>
`,
})
export class LoginComponent {
loginForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', Validators.required],
});
loading = false;
error = '';
constructor(
private fb: FormBuilder,
private authService: AuthService,
private router: Router,
) {}
onSubmit(): void {
if (this.loginForm.invalid) return;
this.loading = true;
this.error = '';
const { email, password } = this.loginForm.value;
this.authService.login(email!, password!).subscribe({
next: () => this.router.navigate(['/dashboard']),
error: (err) => {
this.error = err.error?.message || err.error?.error || 'Invalid email or password';
this.loading = false;
},
});
}
}

View File

@@ -0,0 +1,37 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from '../../core/services/auth.service';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
@Component({
selector: 'app-oauth-callback',
standalone: true,
imports: [MatProgressSpinnerModule],
template: `
<div class="min-h-screen flex items-center justify-center bg-gray-50">
<div class="text-center">
<mat-spinner diameter="40" class="mx-auto mb-4"></mat-spinner>
<p class="text-gray-500">Completing sign in...</p>
</div>
</div>
`,
})
export class OAuthCallbackComponent implements OnInit {
constructor(
private route: ActivatedRoute,
private router: Router,
private authService: AuthService,
) {}
ngOnInit(): void {
const accessToken = this.route.snapshot.queryParamMap.get('accessToken');
const refreshToken = this.route.snapshot.queryParamMap.get('refreshToken');
if (accessToken && refreshToken) {
this.authService.setSessionFromOAuth(accessToken, refreshToken);
this.router.navigate(['/dashboard']);
} else {
this.router.navigate(['/login']);
}
}
}

View File

@@ -0,0 +1,93 @@
import { Component } from '@angular/core';
import { Router, 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-register',
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">Create your account</p>
</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" />
<mat-error *ngIf="registerForm.get('email')?.hasError('required')">Email is required</mat-error>
<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>
{{ loading ? 'Creating account...' : 'Create Account' }}
</button>
</form>
<p class="text-center mt-6 text-sm text-gray-500">
Already have an account?
<a routerLink="/login" class="text-indigo-600 font-medium hover:underline">Sign in</a>
</p>
</mat-card>
</div>
`,
})
export class RegisterComponent {
registerForm = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
loading = false;
error = '';
constructor(
private fb: FormBuilder,
private authService: AuthService,
private router: Router,
) {}
onSubmit(): void {
if (this.registerForm.invalid) return;
this.loading = true;
this.error = '';
const { name, email, password } = this.registerForm.value;
this.authService.register(name!, email!, password!).subscribe({
next: () => this.router.navigate(['/dashboard']),
error: (err) => {
this.error = err.error?.message || err.error?.error || 'Registration failed';
this.loading = false;
},
});
}
}

View File

@@ -0,0 +1,15 @@
import { Injectable } from '@angular/core';
import { CanActivate, Router, UrlTree } from '@angular/router';
import { AuthService } from '../services/auth.service';
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
constructor(private authService: AuthService, private router: Router) {}
canActivate(): boolean | UrlTree {
if (this.authService.isAuthenticated()) {
return true;
}
return this.router.parseUrl('/login');
}
}

View File

@@ -0,0 +1,19 @@
import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http';
import { Observable } from 'rxjs';
import { AuthService } from '../services/auth.service';
@Injectable()
export class JwtInterceptor implements HttpInterceptor {
constructor(private authService: AuthService) {}
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
const token = this.authService.getAccessToken();
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
}
return next.handle(req);
}
}

View File

@@ -0,0 +1,14 @@
export interface AuthResponse {
accessToken: string;
refreshToken: string;
tokenType: string;
user: UserInfo;
}
export interface UserInfo {
id: string;
email: string;
name: string;
avatarUrl: string;
provider: 'LOCAL' | 'GOOGLE' | 'GITHUB' | 'OPENID';
}

View File

@@ -0,0 +1,18 @@
export interface PageResponse {
id: string;
siteId: string;
title: string;
slug: string;
seoTitle: string;
seoDescription: string;
orderIndex: number;
createdAt: string;
}
export interface PageRequest {
title: string;
slug: string;
seoTitle?: string;
seoDescription?: string;
orderIndex: number;
}

View File

@@ -0,0 +1,19 @@
export interface SiteResponse {
id: string;
userId: string;
name: string;
description: string;
subdomain: string;
themeConfig: Record<string, unknown>;
status: 'DRAFT' | 'PUBLISHED';
publishedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface SiteRequest {
name: string;
description: string;
subdomain: string;
themeConfig?: Record<string, unknown>;
}

View File

@@ -0,0 +1,101 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, map, tap } from 'rxjs';
import { AuthResponse, UserInfo } from '../models/auth-response';
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly ACCESS_TOKEN_KEY = 'indie_access_token';
private readonly REFRESH_TOKEN_KEY = 'indie_refresh_token';
private readonly USER_KEY = 'indie_user';
private userSubject = new BehaviorSubject<UserInfo | null>(this.loadUser());
user$ = this.userSubject.asObservable();
constructor(private http: HttpClient) {}
register(name: string, email: string, password: string): Observable<AuthResponse> {
return this.http.post<AuthResponse>('/api/auth/register', { name, email, password })
.pipe(tap(res => this.setSession(res)));
}
login(email: string, password: string): Observable<AuthResponse> {
return this.http.post<AuthResponse>('/api/auth/login', { email, password })
.pipe(tap(res => this.setSession(res)));
}
refreshToken(refreshToken: string): Observable<AuthResponse> {
return this.http.post<AuthResponse>('/api/auth/refresh', { refreshToken })
.pipe(tap(res => this.setSession(res)));
}
logout(): Observable<void> {
return this.http.post<void>('/api/auth/logout', {})
.pipe(tap(() => this.clearSession()));
}
getAccessToken(): string | null {
return localStorage.getItem(this.ACCESS_TOKEN_KEY);
}
getRefreshToken(): string | null {
return localStorage.getItem(this.REFRESH_TOKEN_KEY);
}
getUser(): UserInfo | null {
return this.userSubject.value;
}
isAuthenticated(): boolean {
return !!this.getAccessToken();
}
setSessionFromOAuth(accessToken: string, refreshToken: string): void {
localStorage.setItem(this.ACCESS_TOKEN_KEY, accessToken);
localStorage.setItem(this.REFRESH_TOKEN_KEY, refreshToken);
const payload = this.decodeToken(accessToken);
if (payload) {
const user: UserInfo = {
id: payload.sub,
email: payload.email,
name: payload.name,
avatarUrl: payload.avatarUrl || '',
provider: payload.provider || 'LOCAL',
};
localStorage.setItem(this.USER_KEY, JSON.stringify(user));
this.userSubject.next(user);
}
}
clearSessionForLogout(): void {
this.clearSession();
}
private setSession(res: AuthResponse): void {
localStorage.setItem(this.ACCESS_TOKEN_KEY, res.accessToken);
localStorage.setItem(this.REFRESH_TOKEN_KEY, res.refreshToken);
localStorage.setItem(this.USER_KEY, JSON.stringify(res.user));
this.userSubject.next(res.user);
}
private clearSession(): void {
localStorage.removeItem(this.ACCESS_TOKEN_KEY);
localStorage.removeItem(this.REFRESH_TOKEN_KEY);
localStorage.removeItem(this.USER_KEY);
this.userSubject.next(null);
}
private loadUser(): UserInfo | null {
const data = localStorage.getItem(this.USER_KEY);
return data ? JSON.parse(data) : null;
}
private decodeToken(token: string): any {
try {
const parts = token.split('.');
return JSON.parse(atob(parts[1]));
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { PageResponse, PageRequest } from '../models/page';
@Injectable({ providedIn: 'root' })
export class PageService {
constructor(private http: HttpClient) {}
list(siteId: string): Observable<PageResponse[]> {
return this.http.get<PageResponse[]>(`/api/sites/${siteId}/pages`);
}
create(siteId: string, request: PageRequest): Observable<PageResponse> {
return this.http.post<PageResponse>(`/api/sites/${siteId}/pages`, request);
}
update(siteId: string, pageId: string, request: PageRequest): Observable<PageResponse> {
return this.http.put<PageResponse>(`/api/sites/${siteId}/pages/${pageId}`, request);
}
delete(siteId: string, pageId: string): Observable<void> {
return this.http.delete<void>(`/api/sites/${siteId}/pages/${pageId}`);
}
}

View File

@@ -0,0 +1,29 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { SiteResponse, SiteRequest } from '../models/site';
@Injectable({ providedIn: 'root' })
export class SiteService {
constructor(private http: HttpClient) {}
list(): Observable<SiteResponse[]> {
return this.http.get<SiteResponse[]>('/api/sites');
}
create(request: SiteRequest): Observable<SiteResponse> {
return this.http.post<SiteResponse>('/api/sites', request);
}
get(id: string): Observable<SiteResponse> {
return this.http.get<SiteResponse>(`/api/sites/${id}`);
}
update(id: string, request: SiteRequest): Observable<SiteResponse> {
return this.http.put<SiteResponse>(`/api/sites/${id}`, request);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`/api/sites/${id}`);
}
}

View File

@@ -0,0 +1,72 @@
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { NgIf } from '@angular/common';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { SiteService } from '../../core/services/site.service';
@Component({
selector: 'app-create-site-dialog',
standalone: true,
imports: [NgIf, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, ReactiveFormsModule],
template: `
<h2 mat-dialog-title>Create New Site</h2>
<mat-dialog-content>
<form [formGroup]="form" class="flex flex-col gap-4 pt-2">
<mat-form-field appearance="outline" class="w-full">
<mat-label>Site Name</mat-label>
<input matInput formControlName="name" placeholder="My Awesome Site" />
<mat-error>Name is required</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Subdomain</mat-label>
<span matTextSuffix>.indie.com</span>
<input matInput formControlName="subdomain" placeholder="my-site" />
<mat-error *ngIf="form.get('subdomain')?.hasError('required')">Subdomain is required</mat-error>
<mat-error *ngIf="form.get('subdomain')?.hasError('pattern')">
Lowercase letters, numbers, and hyphens only
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Description (optional)</mat-label>
<textarea matInput formControlName="description" rows="3" placeholder="What's your site about?"></textarea>
</mat-form-field>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Cancel</button>
<button mat-flat-button color="primary" [disabled]="form.invalid || loading" (click)="onCreate()">
{{ loading ? 'Creating...' : 'Create' }}
</button>
</mat-dialog-actions>
`,
})
export class CreateSiteDialog {
private fb = inject(FormBuilder);
private dialogRef = inject(MatDialogRef<CreateSiteDialog>);
private siteService = inject(SiteService);
form = this.fb.group({
name: ['', Validators.required],
subdomain: ['', [Validators.pattern(/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/)]],
description: [''],
});
loading = false;
onCreate(): void {
if (this.form.invalid) return;
this.loading = true;
this.siteService.create(this.form.value as any).subscribe({
next: (site) => {
this.dialogRef.close(site);
},
error: () => {
this.loading = false;
},
});
}
}

View File

@@ -0,0 +1,142 @@
import { Component, OnInit, inject } from '@angular/core';
import { Router } from '@angular/router';
import { NgFor, NgIf, DatePipe } from '@angular/common';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { AuthService } from '../core/services/auth.service';
import { SiteService } from '../core/services/site.service';
import { SiteResponse } from '../core/models/site';
import { CreateSiteDialog } from './create-site-dialog/create-site-dialog';
import { DeleteSiteDialog } from './delete-site-dialog/delete-site-dialog';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [
NgFor, NgIf, DatePipe,
MatToolbarModule, MatButtonModule, MatCardModule, MatIconModule,
MatMenuModule, MatDialogModule, MatProgressSpinnerModule,
],
template: `
<mat-toolbar color="primary" class="flex justify-between">
<span class="text-xl font-bold">Indie</span>
<div class="flex items-center gap-3">
<span class="text-sm">{{ user?.name }}</span>
<button mat-icon-button [matMenuTriggerFor]="menu">
<mat-icon>account_circle</mat-icon>
</button>
<mat-menu #menu="matMenu">
<button mat-menu-item (click)="logout()">
<mat-icon>logout</mat-icon>
<span>Logout</span>
</button>
</mat-menu>
</div>
</mat-toolbar>
<div class="max-w-6xl mx-auto p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-semibold text-gray-800">My Sites</h2>
<button mat-flat-button color="primary" (click)="openCreateDialog()">
<mat-icon class="mr-1">add</mat-icon>
New Site
</button>
</div>
<div *ngIf="loading" class="flex justify-center py-20">
<mat-spinner diameter="40"></mat-spinner>
</div>
<div *ngIf="!loading && sites.length === 0" class="text-center py-20 text-gray-400">
<mat-icon style="font-size: 64px; width: 64px; height: 64px;" class="mb-4">web</mat-icon>
<p class="text-lg">No sites yet. Create your first site!</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<mat-card *ngFor="let site of sites" class="cursor-pointer hover:shadow-lg transition-shadow">
<mat-card-header>
<mat-card-title>{{ site.name }}</mat-card-title>
<mat-card-subtitle *ngIf="site.subdomain">{{ site.subdomain }}.indie.com</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<p class="text-gray-600 text-sm mt-2">{{ site.description || 'No description' }}</p>
<div class="flex items-center gap-2 mt-3">
<span class="text-xs px-2 py-0.5 rounded-full"
[class.bg-green-100]="site.status === 'PUBLISHED'"
[class.text-green-700]="site.status === 'PUBLISHED'"
[class.bg-gray-100]="site.status === 'DRAFT'"
[class.text-gray-600]="site.status === 'DRAFT'">
{{ site.status === 'PUBLISHED' ? 'Published' : 'Draft' }}
</span>
<span class="text-xs text-gray-400">{{ site.createdAt | date:'mediumDate' }}</span>
</div>
</mat-card-content>
<mat-card-actions class="flex justify-end">
<button mat-icon-button color="warn" (click)="$event.stopPropagation(); openDeleteDialog(site)">
<mat-icon>delete</mat-icon>
</button>
</mat-card-actions>
</mat-card>
</div>
</div>
`,
})
export class DashboardComponent implements OnInit {
private authService = inject(AuthService);
private siteService = inject(SiteService);
private dialog = inject(MatDialog);
private router = inject(Router);
user = this.authService.getUser();
sites: SiteResponse[] = [];
loading = true;
ngOnInit(): void {
this.loadSites();
}
loadSites(): void {
this.loading = true;
this.siteService.list().subscribe({
next: (sites) => {
this.sites = sites;
this.loading = false;
},
error: () => this.loading = false,
});
}
openCreateDialog(): void {
const ref = this.dialog.open(CreateSiteDialog, { width: '480px' });
ref.afterClosed().subscribe((site) => {
if (site) this.loadSites();
});
}
openDeleteDialog(site: SiteResponse): void {
const ref = this.dialog.open(DeleteSiteDialog, {
width: '400px',
data: { name: site.name },
});
ref.afterClosed().subscribe((confirmed) => {
if (confirmed) {
this.siteService.delete(site.id).subscribe(() => this.loadSites());
}
});
}
logout(): void {
this.authService.logout().subscribe({
next: () => this.router.navigate(['/login']),
error: () => {
this.authService.clearSessionForLogout();
this.router.navigate(['/login']);
},
});
}
}

Some files were not shown because too many files have changed in this diff Show More