Compare commits
21 Commits
874eb33678
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec4d030c0a | ||
|
|
c362219a40 | ||
|
|
e684154938 | ||
|
|
e705f240d4 | ||
|
|
a3f9cef883 | ||
|
|
bbd6e6d007 | ||
|
|
84316e9c53 | ||
|
|
24ca1521b4 | ||
|
|
3e77b54d71 | ||
|
|
0b6886774e | ||
|
|
87d4acc2e2 | ||
|
|
77c4cfc777 | ||
|
|
212785725f | ||
|
|
708cac6848 | ||
|
|
ea69080710 | ||
|
|
ad15ff8f2e | ||
|
|
25db5b2970 | ||
|
|
a6a9ac6e7e | ||
|
|
a498d55e8c | ||
|
|
51d887a005 | ||
|
|
f70fe81432 |
284
PLAN-byok.md
Normal file
284
PLAN-byok.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# Bring Your Own Key (BYOK) — Execution Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Allow users to provide their own API keys for LLM providers (Gemini, OpenAI) instead of relying on globally configured environment variables. Users can store keys for multiple providers and select which one to use during site generation. Remove all default/global API key configuration.
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
### What will be changed
|
||||
|
||||
| Area | Files |
|
||||
|------|-------|
|
||||
| User entity | `User.java` — add `Map<String, String> llmApiKeys` (JSON column, encrypted values) |
|
||||
| Encryption | `EncryptionService.java` — AES-GCM encrypt/decrypt for stored keys |
|
||||
| LLM providers | `GeminiProvider.java`, `OpenAIProvider.java` — remove `@Component`, remove `@Value` apiKey, accept all params in constructor |
|
||||
| Provider creation | `ProviderFactory.java` (new) — creates provider instances per-request with user's key |
|
||||
| LLM factory | `LLMFactory.java` — delete, replaced by `ProviderFactory` |
|
||||
| LLM service | `LLMService.java` — accept `provider`+`apiKey` params, use `ProviderFactory`, remove fallback logic |
|
||||
| LLM controller | `LLMController.java` — extract `provider`+`apiKey` from request; fall back to user's stored keys |
|
||||
| User settings API | `UserSettingsController.java` (new) — `PUT/GET/DELETE /api/user/llm-config` |
|
||||
| DTOs | `LLMConfigRequest.java` (new) — `{provider, apiKey}` |
|
||||
| Config | `application.yml` — remove `active-provider`, `fallback-provider`, `api-key` entries |
|
||||
| Frontend types | `llm.ts` — add `provider` field to requests; add `LLMKeyStatus` type |
|
||||
| Frontend service | `llm-config.service.ts` (new) — API calls for key CRUD |
|
||||
| Frontend settings | `settings.component.ts` — add LLM API keys card (provider list + key input) |
|
||||
| Frontend workspace | `llm-workspace.component.ts` — load key status, pass provider in generate/refine calls |
|
||||
| Tests | All modified files + new tests for `EncryptionService`, `ProviderFactory`, `UserSettingsController` |
|
||||
|
||||
### What will NOT be changed
|
||||
|
||||
- Storage keys (S3/MinIO) — infrastructure concern
|
||||
- Git remote tokens — separate feature
|
||||
- JWT signing keys — internal auth
|
||||
- Rate limiting — kept as-is
|
||||
- `Site.java`, `GitRemote.java`, `SelfHostedConfig.java` — unrelated
|
||||
- `GenerationVersion.java` — no need to track which key was used
|
||||
- `docker-compose.yml` — unrelated
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
### 1. Multiple provider keys stored per user
|
||||
|
||||
`User.java` gains a JSON column `llmApiKeys` mapping provider names to encrypted API keys:
|
||||
|
||||
```
|
||||
{"gemini": "AES-encrypted-value", "openai": "AES-encrypted-value"}
|
||||
```
|
||||
|
||||
Stored as a `Map<String, String>` with Hibernate 6 `@JdbcTypeCode(SqlTypes.JSON)`. Each value is encrypted individually via `EncryptionService`.
|
||||
|
||||
### 2. No global/default keys
|
||||
|
||||
The env-var-based API keys (`GEMINI_API_KEY`, `OPENAI_API_KEY`) are removed. Every user must configure their own key(s). If no key is available for the requested provider, the API returns a clear error: "No API key configured for {provider}. Add one in Settings."
|
||||
|
||||
### 3. Per-request provider instances
|
||||
|
||||
`LLMProvider` implementations are no longer Spring beans. A `ProviderFactory` creates a fresh instance per request with the user's key. This keeps the `LLMProvider` interface clean (no `apiKey` parameter on `generate()`).
|
||||
|
||||
### 4. Provider selected per request
|
||||
|
||||
Each generation/refine request includes a `provider` field. The user's stored key for that provider is used. The request body can optionally include an `apiKey` to override the stored key on-the-fly.
|
||||
|
||||
### 5. Encryption
|
||||
|
||||
AES-256-GCM via `EncryptionService`. Master key from env var `INDIE_ENCRYPTION_KEY`. If unset, a key is generated and persisted to disk (same pattern as JWT keys in `JwtConfig`).
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Backend — Encryption service
|
||||
|
||||
**New file:** `backend/src/main/java/com/krrishg/config/EncryptionService.java`
|
||||
|
||||
- AES-256-GCM encrypt/decrypt
|
||||
- Master key loaded from `@Value("${indie.encryption.master-key:}")`
|
||||
- If empty, generate random key and save to `config/encryption.key`
|
||||
- Methods: `encrypt(String plaintext) → String`, `decrypt(String ciphertext) → String`
|
||||
|
||||
### Step 2: Backend — Add LLM API keys to User entity
|
||||
|
||||
**Modify:** `backend/src/main/java/com/krrishg/model/User.java`
|
||||
|
||||
```java
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private Map<String, String> llmApiKeys;
|
||||
```
|
||||
|
||||
Import `java.util.Map`, `org.hibernate.type.SqlTypes`, `jakarta.persistence.Column`.
|
||||
|
||||
### Step 3: Backend — LLM config DTO and controller
|
||||
|
||||
**New file:** `backend/src/main/java/com/krrishg/dto/LLMConfigRequest.java`
|
||||
|
||||
```java
|
||||
public record LLMConfigRequest(String provider, String apiKey) {}
|
||||
```
|
||||
|
||||
**New file:** `backend/src/main/java/com/krrishg/controller/UserSettingsController.java`
|
||||
|
||||
- `@RestController @RequestMapping("/api/user")`
|
||||
- `PUT /llm-config` — Accept `LLMConfigRequest`, encrypt the key, store in `User.llmApiKeys`
|
||||
- `GET /llm-config` — Return map of `{provider: boolean}` indicating which keys are configured (never return actual keys)
|
||||
- `DELETE /llm-config/{provider}` — Remove the key for that provider
|
||||
|
||||
### Step 4: Backend — Refactor LLM providers
|
||||
|
||||
**Modify:** `backend/src/main/java/com/krrishg/service/llm/GeminiProvider.java`
|
||||
|
||||
- Remove `@Component`
|
||||
- Constructor: `GeminiProvider(String apiKey, String model, String baseUrl, RestTemplate restTemplate)`
|
||||
- Remove all `@Value` annotations
|
||||
|
||||
**Modify:** `backend/src/main/java/com/krrishg/service/llm/OpenAIProvider.java`
|
||||
|
||||
- Same refactoring as GeminiProvider
|
||||
|
||||
### Step 5: Backend — Create ProviderFactory
|
||||
|
||||
**New file:** `backend/src/main/java/com/krrishg/service/llm/ProviderFactory.java`
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class ProviderFactory {
|
||||
// Injected via @Value: model names, base URLs from application.yml
|
||||
// Methods:
|
||||
// createProvider("gemini", apiKey) → new GeminiProvider(...)
|
||||
// createProvider("openai", apiKey) → new OpenAIProvider(...)
|
||||
// getAvailableProviders() → ["gemini", "openai"]
|
||||
}
|
||||
```
|
||||
|
||||
**Delete:** `backend/src/main/java/com/krrishg/service/llm/LLMFactory.java`
|
||||
|
||||
### Step 6: Backend — Update LLMService
|
||||
|
||||
**Modify:** `backend/src/main/java/com/krrishg/service/LLMService.java`
|
||||
|
||||
- Remove `LLMFactory`, inject `ProviderFactory`, `UserRepository`, `EncryptionService`
|
||||
- `generateSite(UUID userId, UUID siteId, String prompt, List<Reference> references, String provider, String apiKey)` — look up key from user if not provided; call `callLLM()`
|
||||
- `refineSite(...)` — same treatment
|
||||
- `callLLM()` now takes `provider` + `apiKey`:
|
||||
```java
|
||||
private LLMResult callLLM(..., String providerName, String apiKey) {
|
||||
if (apiKey == null || apiKey.isBlank()) {
|
||||
throw new IllegalStateException("No API key configured for " + providerName);
|
||||
}
|
||||
LLMProvider provider = providerFactory.createProvider(providerName, apiKey);
|
||||
return provider.generate(systemPrompt, userPrompt, references, options);
|
||||
}
|
||||
```
|
||||
- Remove the old active/fallback provider logic entirely
|
||||
|
||||
### Step 7: Backend — Update LLMController
|
||||
|
||||
**Modify:** `backend/src/main/java/com/krrishg/controller/LLMController.java`
|
||||
|
||||
- In `/generate` and `/refine`: extract `provider` (required) and optional `apiKey` from request body
|
||||
- If `apiKey` not provided, load from user's stored keys via `UserRepository` + `EncryptionService.decrypt()`
|
||||
- Pass `provider` + `apiKey` to `LLMService`
|
||||
- Add validation: provider must be one of the supported values
|
||||
|
||||
### Step 8: Backend — Update application.yml
|
||||
|
||||
**Modify:** `backend/src/main/resources/application.yml`
|
||||
|
||||
- Remove `indie.llm.active-provider` and `indie.llm.fallback-provider`
|
||||
- Remove `indie.llm.providers.gemini.api-key` and `indie.llm.providers.openai.api-key`
|
||||
- Keep `indie.llm.providers.gemini.model`, `gemini.url`, `openai.model`, `openai.url`
|
||||
- Add `indie.encryption.master-key: ${INDIE_ENCRYPTION_KEY:}`
|
||||
|
||||
### Step 9: Frontend — Update LLM types
|
||||
|
||||
**Modify:** `frontend/src/app/core/models/llm.ts`
|
||||
|
||||
```typescript
|
||||
export interface LLMGenerateRequest {
|
||||
prompt: string;
|
||||
siteId: string;
|
||||
provider: string;
|
||||
apiKey?: string;
|
||||
references?: Reference[];
|
||||
}
|
||||
|
||||
export interface LLMRefineRequest {
|
||||
prompt: string;
|
||||
siteId: string;
|
||||
provider: string;
|
||||
currentStructure: SiteStructure;
|
||||
apiKey?: string;
|
||||
references?: Reference[];
|
||||
}
|
||||
|
||||
export interface LLMKeyStatus {
|
||||
gemini: boolean;
|
||||
openai: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 10: Frontend — LLM config service
|
||||
|
||||
**New file:** `frontend/src/app/core/services/llm-config.service.ts`
|
||||
|
||||
- `getStatus(): Observable<LLMKeyStatus>` — GET `/api/user/llm-config`
|
||||
- `saveKey(provider: string, apiKey: string): Observable<void>` — PUT `/api/user/llm-config`
|
||||
|
||||
### Step 11: Frontend — Add LLM keys UI to settings
|
||||
|
||||
**Modify:** `frontend/src/app/llm-workspace/settings/settings.component.ts`
|
||||
|
||||
Add a new card "LLM API Keys":
|
||||
- For each provider (Gemini, OpenAI): show a row with provider name, status indicator (configured/not configured), and expandable input for the API key
|
||||
- "Save" button per provider
|
||||
- On init, load `LLMKeyStatus` from backend
|
||||
- On save, call `llm-config.service.saveKey()`
|
||||
|
||||
### Step 12: Frontend — Update workspace
|
||||
|
||||
**Modify:** `frontend/src/app/llm-workspace/llm-workspace.component.ts`
|
||||
|
||||
- On init, load `LLMKeyStatus` via `llm-config.service`
|
||||
- If no keys configured, show a notice: "Configure an API key in Settings to generate sites"
|
||||
- If only one provider has a key, auto-select it
|
||||
- If multiple, add a dropdown to select provider before generating
|
||||
- Pass `provider` (and optionally `apiKey`) in generate/refine requests
|
||||
|
||||
### Step 13: Backend — Tests
|
||||
|
||||
| Test file | What to test |
|
||||
|-----------|-------------|
|
||||
| `EncryptionServiceTest.java` (new) | Encrypt → decrypt round-trip; different keys produce different ciphertexts |
|
||||
| `ProviderFactoryTest.java` (new) | Creates correct provider type for each name; throws for unknown provider |
|
||||
| `LLMControllerTest.java` (update) | Request with/without provider, with/without apiKey, missing key returns 400 |
|
||||
| `LLMServiceTest.java` (update) | Uses ProviderFactory instead of LLMFactory; passes key correctly; no fallback logic |
|
||||
| `UserSettingsControllerTest.java` (new) | Save key, get status, delete key |
|
||||
|
||||
### Step 14: Frontend — Tests (optional)
|
||||
|
||||
Add/tests for:
|
||||
- `llm-config.service.ts`
|
||||
- New settings UI code
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
### Test scenarios
|
||||
|
||||
| # | Scenario | Expected Behavior |
|
||||
|---|----------|------------------|
|
||||
| 1 | User has no keys configured | Workspace shows notice; API returns 400 with clear message |
|
||||
| 2 | User saves Gemini key | `PUT /api/user/llm-config` returns success; `GET /api/user/llm-config` shows `gemini: true` |
|
||||
| 3 | User saves OpenAI key | Same as above |
|
||||
| 4 | User saves both keys | `GET` returns both as configured |
|
||||
| 5 | Generate with stored Gemini key | Request with `provider: "gemini"` succeeds |
|
||||
| 6 | Generate with stored OpenAI key | Request with `provider: "openai"` succeeds |
|
||||
| 7 | Generate with per-request key override | Passing `apiKey` in request body uses that key instead of stored |
|
||||
| 8 | Generate with unknown provider | 400 error |
|
||||
| 9 | Generate with provider that has no key stored | 400 error: "No API key configured for {provider}" |
|
||||
| 10 | Delete a stored key | `DELETE` clears it; subsequent generate for that provider fails |
|
||||
| 11 | Encryption round-trip | `decrypt(encrypt("key"))` == `"key"` |
|
||||
| 12 | Provider factory creates correct instances | GeminiProvider for "gemini", OpenAIProvider for "openai" |
|
||||
|
||||
### Risks and mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|-----------|
|
||||
| Encryption master key changes → stored keys unreadable | Generate + persist key to file on first run (like JWT keys); document `INDIE_ENCRYPTION_KEY` as critical env var |
|
||||
| Existing users have no key → immediate errors | Clear error messages in API responses and UI prompts |
|
||||
| Per-request provider creation overhead | `RestTemplate` shared via injected `RestTemplateBuilder`; negligible cost |
|
||||
| Hibernate JSON column compatibility | Use `@JdbcTypeCode(SqlTypes.JSON)` (Hibernate 6); fallback to `columnDefinition = "TEXT"` with manual JSON serialization if needed |
|
||||
|
||||
### Success criteria
|
||||
|
||||
- Users can configure API keys for Gemini and/or OpenAI via settings UI
|
||||
- Users can select which provider to use during site generation
|
||||
- Keys are encrypted at rest in the database
|
||||
- No global/default API key is required to run the application
|
||||
- All existing tests pass with appropriate modifications
|
||||
425
PLAN-selfhosted.md
Normal file
425
PLAN-selfhosted.md
Normal file
@@ -0,0 +1,425 @@
|
||||
# Self-Hosted Deployment Plan
|
||||
|
||||
Deploy sites to a custom server via SSH + rsync + nginx, alongside the existing git-based (GitHub/GitLab Pages) publishing.
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
1. **Git push always happens** when a `GitRemote` is configured — regardless of deployment target.
|
||||
2. **Self-hosted deploy is additive** — rsyncs to the server on top of the git push.
|
||||
3. **User selects `deploymentTarget`** on the Site (`GIT_PAGES`, `SELF_HOSTED`, or `NONE`) — controls which URL is the primary published URL and which prereqs are validated on publish.
|
||||
4. **Custom domain lives in `SelfHostedConfig`**, not on `Site`. Custom domains for git pages are handled outside Indie.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1 — Site Model: Add DeploymentTarget
|
||||
|
||||
**File: `model/Site.java`** — add enum + field:
|
||||
|
||||
```java
|
||||
public enum DeploymentTarget {
|
||||
GIT_PAGES, SELF_HOSTED, NONE
|
||||
}
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private DeploymentTarget deploymentTarget = DeploymentTarget.NONE;
|
||||
```
|
||||
|
||||
**File: `model/SiteStatus.java`** — no change needed.
|
||||
|
||||
### Step 2 — DTOs: Update SiteRequest/SiteResponse
|
||||
|
||||
**File: `dto/SiteRequest.java`** — add:
|
||||
```java
|
||||
private String deploymentTarget; // "GIT_PAGES", "SELF_HOSTED", "NONE"
|
||||
```
|
||||
|
||||
**File: `dto/SiteResponse.java`** — add:
|
||||
```java
|
||||
private String deploymentTarget;
|
||||
```
|
||||
Update `from(Site site)` to map the field.
|
||||
|
||||
### Step 3 — SelfHostedConfig Entity (New)
|
||||
|
||||
**File: `model/SelfHostedConfig.java`:**
|
||||
|
||||
```java
|
||||
package com.krrishg.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "self_hosted_configs")
|
||||
@Data @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
public class SelfHostedConfig {
|
||||
@Id
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private UUID siteId;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private String domain;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String sshHost;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private int sshPort = 22;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String sshUser;
|
||||
|
||||
@Column(nullable = false, length = 8192)
|
||||
private String encryptedSshKey;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private String deployPath = "/var/www/{domain}/public";
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private String nginxSitesPath = "/etc/nginx/sites-available";
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private Boolean nginxSslEnabled = false;
|
||||
|
||||
private LocalDateTime deployedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (id == null) id = UUID.randomUUID();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4 — Repository (New)
|
||||
|
||||
**File: `repository/SelfHostedConfigRepository.java`:**
|
||||
|
||||
```java
|
||||
package com.krrishg.repository;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface SelfHostedConfigRepository extends JpaRepository<SelfHostedConfig, UUID> {
|
||||
Optional<SelfHostedConfig> findBySiteId(UUID siteId);
|
||||
Optional<SelfHostedConfig> findByDomain(String domain);
|
||||
boolean existsBySiteId(UUID siteId);
|
||||
boolean existsByDomain(String domain);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5 — KeyManager: SSH Key Encryption
|
||||
|
||||
**File: `service/KeyManager.java`** — add methods using the same AES-256-GCM pattern as git token encryption:
|
||||
|
||||
```java
|
||||
public String encryptSshKey(String rawKey);
|
||||
public String decryptSshKey(String encryptedKey);
|
||||
```
|
||||
|
||||
### Step 6 — Deployer Interface (New)
|
||||
|
||||
**File: `service/Deployer.java`:**
|
||||
|
||||
```java
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import com.krrishg.service.RenderService.SiteOutput;
|
||||
|
||||
public interface Deployer {
|
||||
String deploy(SiteOutput output, SelfHostedConfig config) throws Exception;
|
||||
void remove(SelfHostedConfig config) throws Exception;
|
||||
String buildUrl(SelfHostedConfig config);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7 — SshRsyncDeployer Implementation (New)
|
||||
|
||||
**File: `service/SshRsyncDeployer.java`:**
|
||||
|
||||
Key operations:
|
||||
|
||||
**`deploy()`:**
|
||||
1. Render site files to temp directory
|
||||
2. Expand `{domain}` placeholder in `deployPath` to `config.getDomain()`
|
||||
3. Decrypt SSH key to temp file (chmod 0600, delete after)
|
||||
4. `ssh <user>@<host> -p <port> -i <keyfile> "mkdir -p <deployPath>"`
|
||||
5. `rsync -avz --delete -e "ssh -p <port> -i <keyfile> -o StrictHostKeyChecking=accept-new" <tempDir>/ <user>@<host>:<deployPath>/`
|
||||
6. Generate nginx config string for the domain
|
||||
7. Write nginx config to temp file, rsync it to `<nginxSitesPath>/<domain>` on server
|
||||
8. `ssh <user>@<host> -i <keyfile> "sudo ln -sf <nginxSitesPath>/<domain> /etc/nginx/sites-enabled/ && sudo nginx -s reload"`
|
||||
9. Clean up temp files
|
||||
10. Return `"https://" + domain + "/"`
|
||||
|
||||
**Nginx config template:**
|
||||
```
|
||||
server {
|
||||
listen 80;
|
||||
server_name <domain>;
|
||||
root <deployPath>;
|
||||
index index.html;
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Optionally support SSL: if `nginxSslEnabled`, also generate a `listen 443 ssl;` block with placeholder cert paths, or skip SSL (user configures separately).
|
||||
|
||||
**`remove()`:**
|
||||
1. SSH `rm -rf <deployPath>`
|
||||
2. SSH `sudo rm <nginxSitesPath>/<domain> /etc/nginx/sites-enabled/<domain>`
|
||||
3. SSH `sudo nginx -s reload`
|
||||
|
||||
### Step 8 — DeploymentService: Dispatch Logic
|
||||
|
||||
**File: `service/DeploymentService.java`** — inject `SelfHostedConfigRepository`, `Deployer`, `SshRsyncDeployer`.
|
||||
|
||||
Update `publish()`:
|
||||
|
||||
```java
|
||||
public String publish(UUID siteId) {
|
||||
Site site = siteRepository.findById(siteId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Site not found"));
|
||||
|
||||
SiteStructure structure = getLatestVersion(siteId);
|
||||
if (structure == null) {
|
||||
throw new IllegalStateException("No generation versions found");
|
||||
}
|
||||
|
||||
SiteOutput output = renderService.renderSite(structure);
|
||||
|
||||
// Step 1: Always push to git if remote is configured
|
||||
GitRemote remote = gitRemoteRepository.findBySiteId(siteId).orElse(null);
|
||||
if (remote != null) {
|
||||
gitService.deployToPages(siteId.toString(), remote, structure);
|
||||
}
|
||||
|
||||
// Step 2: Additionally deploy to custom server if configured
|
||||
SelfHostedConfig selfHostedConfig = selfHostedConfigRepository.findBySiteId(siteId).orElse(null);
|
||||
if (selfHostedConfig != null) {
|
||||
deployer.deploy(output, selfHostedConfig);
|
||||
}
|
||||
|
||||
// Step 3: Validate and set URL based on deployment target
|
||||
String url = null;
|
||||
switch (site.getDeploymentTarget()) {
|
||||
case GIT_PAGES:
|
||||
if (remote == null) {
|
||||
throw new IllegalStateException("Deployment target is GIT_PAGES but no git remote is configured");
|
||||
}
|
||||
PagesProvider provider = PagesProvider.forRemote(remote);
|
||||
url = provider.buildPagesUrl(remote);
|
||||
break;
|
||||
case SELF_HOSTED:
|
||||
if (selfHostedConfig == null) {
|
||||
throw new IllegalStateException("Deployment target is SELF_HOSTED but no self-hosted config exists");
|
||||
}
|
||||
url = deployer.buildUrl(selfHostedConfig);
|
||||
break;
|
||||
case NONE:
|
||||
// Still publish but no primary URL — user might want git push without pages URL
|
||||
break;
|
||||
}
|
||||
|
||||
site.setStatus(SiteStatus.PUBLISHED);
|
||||
site.setPublishedAt(LocalDateTime.now());
|
||||
site.setPublishedUrl(url);
|
||||
siteRepository.save(site);
|
||||
|
||||
log.info("Published site {}: target={}, url={}", siteId, site.getDeploymentTarget(), url);
|
||||
return url;
|
||||
}
|
||||
```
|
||||
|
||||
Update `unpublish()` similarly — still removes git pages branch, and also removes from self-hosted server if configured.
|
||||
|
||||
### Step 9 — SelfHostedController (New)
|
||||
|
||||
**File: `controller/SelfHostedController.java`:**
|
||||
|
||||
`@RestController @RequestMapping("/api/sites/{id}/self-hosted")`
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/sites/{id}/self-hosted` | Get config (sshKey omitted from response) |
|
||||
| `PUT` | `/api/sites/{id}/self-hosted` | Create/update config. Encrypts sshKey via KeyManager. Optionally sets `site.deploymentTarget = SELF_HOSTED` |
|
||||
| `DELETE` | `/api/sites/{id}/self-hosted` | Remove config. Removes files from server via `deployer.remove()`. Resets `site.deploymentTarget` if it was `SELF_HOSTED` |
|
||||
|
||||
All endpoints require ownership check (same pattern as `SiteController.findSiteForUser`).
|
||||
|
||||
### Step 10 — SiteController: Accept deploymentTarget
|
||||
|
||||
**File: `controller/SiteController.java`** — in `createSite()` and `updateSite()`, set `site.setDeploymentTarget(...)` from the request. Validate that if target is `GIT_PAGES`, a GitRemote exists (or at least warn). If `SELF_HOSTED`, a `SelfHostedConfig` should exist.
|
||||
|
||||
### Step 11 — DTOs for Self-Hosted (New)
|
||||
|
||||
**File: `dto/SelfHostedConfigRequest.java`:**
|
||||
```java
|
||||
@Data
|
||||
public class SelfHostedConfigRequest {
|
||||
@NotBlank private String domain;
|
||||
@NotBlank private String sshHost;
|
||||
private int sshPort = 22;
|
||||
@NotBlank private String sshUser;
|
||||
@NotBlank private String sshKey;
|
||||
private String deployPath;
|
||||
private String nginxSitesPath;
|
||||
private Boolean nginxSslEnabled;
|
||||
}
|
||||
```
|
||||
|
||||
**File: `dto/SelfHostedConfigResponse.java`:**
|
||||
```java
|
||||
@Data @Builder
|
||||
public class SelfHostedConfigResponse {
|
||||
private UUID id;
|
||||
private UUID siteId;
|
||||
private String domain;
|
||||
private String sshHost;
|
||||
private int sshPort;
|
||||
private String sshUser;
|
||||
private String deployPath;
|
||||
private String nginxSitesPath;
|
||||
private Boolean nginxSslEnabled;
|
||||
private LocalDateTime deployedAt;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 12 — Frontend: Models
|
||||
|
||||
**File: `frontend/src/app/core/models/site.ts`** — add to `SiteResponse`:
|
||||
```typescript
|
||||
deploymentTarget: 'GIT_PAGES' | 'SELF_HOSTED' | 'NONE';
|
||||
```
|
||||
|
||||
**File: `frontend/src/app/core/models/deployment.ts`** — add:
|
||||
```typescript
|
||||
export interface SelfHostedConfigRequest {
|
||||
domain: string;
|
||||
sshHost: string;
|
||||
sshPort: number;
|
||||
sshUser: string;
|
||||
sshKey: string;
|
||||
deployPath: string;
|
||||
nginxSitesPath: string;
|
||||
nginxSslEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface SelfHostedConfigResponse {
|
||||
id: string;
|
||||
siteId: string;
|
||||
domain: string;
|
||||
sshHost: string;
|
||||
sshPort: number;
|
||||
sshUser: string;
|
||||
deployPath: string;
|
||||
nginxSitesPath: string;
|
||||
nginxSslEnabled: boolean;
|
||||
deployedAt: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 13 — Frontend: Site Service
|
||||
|
||||
**File: `frontend/src/app/core/services/site.service.ts`** — add:
|
||||
```typescript
|
||||
getSelfHostedConfig(siteId: string): Observable<SelfHostedConfigResponse> {
|
||||
return this.http.get<SelfHostedConfigResponse>(`/api/sites/${siteId}/self-hosted`);
|
||||
}
|
||||
|
||||
saveSelfHostedConfig(siteId: string, config: SelfHostedConfigRequest): Observable<SelfHostedConfigResponse> {
|
||||
return this.http.put<SelfHostedConfigResponse>(`/api/sites/${siteId}/self-hosted`, config);
|
||||
}
|
||||
|
||||
deleteSelfHostedConfig(siteId: string): Observable<void> {
|
||||
return this.http.delete<void>(`/api/sites/${siteId}/self-hosted`);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 14 — Frontend: Settings Component
|
||||
|
||||
**File: `frontend/src/app/llm-workspace/settings/settings.component.ts`** — add:
|
||||
|
||||
1. **Deployment Target selector** (dropdown) — between "Git Pages", "Self-Hosted", "None". Shows status indicators for which targets are configured.
|
||||
|
||||
2. **Self-Hosted Config card** — expandable section with form fields for domain, SSH host, SSH port, SSH user, SSH key (textarea, masked), deploy path, nginx sites path. Save and Remove buttons.
|
||||
|
||||
3. **Publishing card update** — show which target will be used and the expected URL. Keep the publish/unpublish buttons.
|
||||
|
||||
---
|
||||
|
||||
## Server-Side Prerequisites
|
||||
|
||||
The SSH user on the target server needs:
|
||||
|
||||
1. **Write access** to the `deployPath` directory
|
||||
2. **sudo NOPASSWD** for:
|
||||
```bash
|
||||
deploy-user ALL=(ALL) NOPASSWD: /usr/sbin/nginx -s reload, \
|
||||
/bin/ln -sf /etc/nginx/sites-available/* /etc/nginx/sites-enabled/, \
|
||||
/bin/rm /etc/nginx/sites-enabled/*, \
|
||||
/bin/rm /etc/nginx/sites-available/*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Summary
|
||||
|
||||
### Backend (existing, modified)
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `model/Site.java` | Add `DeploymentTarget` enum + field |
|
||||
| `model/SiteStatus.java` | No change |
|
||||
| `dto/SiteRequest.java` | Add `deploymentTarget` |
|
||||
| `dto/SiteResponse.java` | Add `deploymentTarget` |
|
||||
| `service/KeyManager.java` | Add SSH key encrypt/decrypt methods |
|
||||
| `service/DeploymentService.java` | Dispatch logic for both targets |
|
||||
| `controller/SiteController.java` | Accept `deploymentTarget` in create/update |
|
||||
| `repository/SiteRepository.java` | No change (optional: add findByDeploymentTarget) |
|
||||
|
||||
### Backend (new files)
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `model/SelfHostedConfig.java` | Entity |
|
||||
| `repository/SelfHostedConfigRepository.java` | JPA repository |
|
||||
| `dto/SelfHostedConfigRequest.java` | Request DTO |
|
||||
| `dto/SelfHostedConfigResponse.java` | Response DTO |
|
||||
| `service/Deployer.java` | Deployer interface |
|
||||
| `service/SshRsyncDeployer.java` | SSH + rsync + nginx implementation |
|
||||
| `controller/SelfHostedController.java` | REST endpoints |
|
||||
|
||||
### Frontend (modified)
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `core/models/site.ts` | Add `deploymentTarget` |
|
||||
| `core/models/deployment.ts` | Add self-hosted config interfaces |
|
||||
| `core/services/site.service.ts` | Add self-hosted API methods |
|
||||
| `llm-workspace/settings/settings.component.ts` | Add target selector + self-hosted config card |
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements (Out of Scope)
|
||||
|
||||
- Automatic TLS via Let's Encrypt / Certbot integration
|
||||
- Multiple server targets per site
|
||||
- Health checks / monitoring
|
||||
- Rollback on server (keep previous deploy versions)
|
||||
- Staged deployments (staging → production)
|
||||
22
PLAN.md
22
PLAN.md
@@ -1,6 +1,6 @@
|
||||
# Indie — Independent Website Platform
|
||||
|
||||
A platform that lets anyone create, own, and publish their own static website through AI prompts. No drag-and-drop builder — just describe what you want, and the AI generates it. Every site is backed by a git repository. Users self-host or use free subdomain hosting.
|
||||
A platform that lets anyone create, own, and publish their own static website through AI prompts. No drag-and-drop builder — just describe what you want, and the AI generates it. Every site is backed by a git repository.
|
||||
|
||||
---
|
||||
|
||||
@@ -75,7 +75,7 @@ Prompt + Reference URLs
|
||||
| `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), status(DRAFT/PUBLISHED), publishedAt, createdAt, updatedAt |
|
||||
| `com/indie/model/Site.java` | Add | id, userId, name, description, status(DRAFT/PUBLISHED), publishedAt, createdAt, updatedAt |
|
||||
| `com/indie/repository/*.java` | Add | JPA repositories for User, Site |
|
||||
| `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 |
|
||||
@@ -151,7 +151,7 @@ Prompt + Reference URLs
|
||||
|
||||
### v0.3 — Export, Git & Hosting (Target: Weeks 8-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.
|
||||
**Goal:** User can publish their site as pure static files. Every publish creates a git commit. User can connect GitHub/GitLab for automatic pushes.
|
||||
|
||||
#### Backend
|
||||
|
||||
@@ -178,13 +178,13 @@ Prompt + Reference URLs
|
||||
| File | Action | Details |
|
||||
|------|--------|---------|
|
||||
| `src/app/workspace/git-settings/` | Add | GitSettingsComponent — connect/disconnect GitHub/GitLab OAuth, select remote repo, connection status indicator |
|
||||
| `src/app/workspace/settings/` | Add | SettingsComponent — site name, subdomain, export zip button, publish/unpublish buttons |
|
||||
| `src/app/workspace/settings/` | Add | SettingsComponent — site name, export zip button, publish/unpublish buttons |
|
||||
|
||||
#### Infrastructure
|
||||
|
||||
| File | Action | Details |
|
||||
|------|--------|---------|
|
||||
| `infra/cloudflare-worker.js` | Add | Routes `*.indie.com` → R2 bucket `indie-sites/{subdomain}/` |
|
||||
| `infra/cloudflare-worker.js` | TBD | Routes custom domains → R2 bucket |
|
||||
| `docker-compose.prod.yml` | Add | Production services: PostgreSQL, MongoDB, backend (×2), nginx, certbot |
|
||||
|
||||
**Git directory structure on server:**
|
||||
@@ -225,9 +225,9 @@ site-export/
|
||||
| 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/controller/CommentController.java` | Add | Public: `POST /api/sites/{siteId}/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 |
|
||||
| `com/indie/controller/RssController.java` | Add | `GET /api/feed/{siteId}` → `application/rss+xml`, 1h cache |
|
||||
|
||||
#### Frontend
|
||||
|
||||
@@ -243,13 +243,13 @@ site-export/
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ Cloudflare CDN │
|
||||
│ *.indie.com → R2 │
|
||||
│ custom domains → R2 │
|
||||
└────────┬─────────────┘
|
||||
│
|
||||
┌────────▼─────────────┐
|
||||
│ Cloudflare R2 │
|
||||
│ indie-sites bucket │
|
||||
│ /{subdomain}/* │
|
||||
│ /{siteId}/* │
|
||||
└──────────────────────┘
|
||||
▲
|
||||
┌────────┴─────────────┐
|
||||
@@ -309,7 +309,7 @@ Adding a new provider = implement `LLMProvider` interface + add config block.
|
||||
|
||||
5. **Content editor** — Text edits happen in a structured form grouped by page, not inside the iframe. Style/structure changes go through prompts.
|
||||
|
||||
6. **No billing in MVP** — Subdomain hosting is free. Monetization is post-MVP.
|
||||
6. **No billing in MVP** — Monetization is post-MVP.
|
||||
|
||||
---
|
||||
|
||||
@@ -319,7 +319,7 @@ Adding a new provider = implement `LLMProvider` interface + add config block.
|
||||
|---------|-------------|
|
||||
| v0.1 | Register, login (email + OAuth), refresh token, site CRUD, 401 on unauthenticated |
|
||||
| v0.2 | Prompt → valid SiteStructure generated, preview renders correctly, version timeline shows history, restore works, content editor updates preview, refinement prompt modifies existing structure, reference URLs included in generation |
|
||||
| v0.3 | Exported zip opens in browser identically to preview, git init + commit works, git push to remote works, R2 upload succeeds, subdomain serves content |
|
||||
| v0.3 | Exported zip opens in browser identically to preview, git init + commit works, git push to remote works, R2 upload succeeds |
|
||||
| v0.4 | Comment submit → approve → display, RSS validates, rollback restores previous version |
|
||||
|
||||
---
|
||||
|
||||
448
README.md
448
README.md
@@ -1,6 +1,6 @@
|
||||
# Indie — Independent Website Platform
|
||||
|
||||
A platform that lets anyone create, own, and publish their own static website without social media gatekeeping. Built with a drag-and-drop builder and LLM prompt-to-site generation. Every site is backed by a git repository. Users self-host or use free subdomain hosting.
|
||||
A platform that lets anyone create, own, and publish their own static website through AI prompts. Describe what you want, and the AI generates it. Every site is backed by a git repository.
|
||||
|
||||
---
|
||||
|
||||
@@ -10,9 +10,8 @@ A platform that lets anyone create, own, and publish their own static website wi
|
||||
|
||||
- Java 17+
|
||||
- Node.js 18+ and npm
|
||||
- Docker & Docker Compose (for PostgreSQL, MongoDB, MinIO)
|
||||
- Angular CLI 17 (`npm install -g @angular/cli@17`)
|
||||
- Gradle wrapper (included)
|
||||
- Docker & Docker Compose (for PostgreSQL, MongoDB)
|
||||
- npm packages: `@angular/cli@17` (`npm install -g @angular/cli@17`)
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -22,6 +21,8 @@ A platform that lets anyone create, own, and publish their own static website wi
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This starts PostgreSQL (port 5432) and MongoDB (port 27017).
|
||||
|
||||
2. **Start the backend:**
|
||||
|
||||
```bash
|
||||
@@ -43,22 +44,54 @@ A platform that lets anyone create, own, and publish their own static website wi
|
||||
The app will be available at `http://localhost:4200`.
|
||||
API calls are proxied to `http://localhost:8080`.
|
||||
|
||||
### Environment Variables
|
||||
---
|
||||
|
||||
## Infrastructure (Docker Compose)
|
||||
|
||||
| Service | Image | Ports | Credentials | Volume |
|
||||
|------------|--------------------|--------------|--------------------------------|---------------------|
|
||||
| PostgreSQL | `postgres:16-alpine` | host:5432 | `indie` / `indie_dev` | `pgdata` |
|
||||
| MongoDB | `mongo:7` | host:27017 | (no auth) | `mongodata` |
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| **Database** | | |
|
||||
| `SPRING_DATASOURCE_URL` | PostgreSQL JDBC URL | `jdbc:postgresql://localhost:5432/indie` |
|
||||
| `SPRING_DATASOURCE_USERNAME` | DB username | `indie` |
|
||||
| `SPRING_DATASOURCE_PASSWORD` | DB password | `indie_dev` |
|
||||
| `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_ID` | Google OAuth 2.0 client ID (omit to disable Google login) | — |
|
||||
| `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_SECRET` | Google OAuth 2.0 client secret | — |
|
||||
| `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GITHUB_CLIENT_ID` | GitHub OAuth App client ID (omit to disable GitHub login) | — |
|
||||
| `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GITHUB_CLIENT_SECRET` | GitHub OAuth App client secret | — |
|
||||
| `MONGODB_URI` | MongoDB connection URI | `mongodb://localhost:27017/indie` |
|
||||
| **JWT** | | |
|
||||
| `INDIE_JWT_ACCESS_EXPIRATION` | Access token TTL (ms) | `900000` (15 min) |
|
||||
| `INDIE_JWT_REFRESH_EXPIRATION` | Refresh token TTL (ms) | `604800000` (7 days) |
|
||||
| `INDIE_JWT_PRIVATE_KEY_PATH` | RS256 private key path | `config/jwt-private.key` |
|
||||
| `INDIE_JWT_PUBLIC_KEY_PATH` | RS256 public key path | `config/jwt-public.key` |
|
||||
| **Git** | | |
|
||||
| `INDIE_GIT_REPOS_PATH` | Git repositories root | `/var/git/sites/` |
|
||||
| `INDIE_GIT_AUTHOR_NAME` | Default git author name | `Indie Platform` |
|
||||
| `INDIE_GIT_AUTHOR_EMAIL` | Default git author email | `git@indie.local` |
|
||||
| **Encryption** | | |
|
||||
| `INDIE_ENCRYPTION_KEY_PATH` | AES-256-GCM master key path | `config/encryption.key` |
|
||||
| **App** | | |
|
||||
| `INDIE_APP_BASE_URL` | Frontend base URL (for verification emails) | `http://localhost:4200` |
|
||||
| `INDIE_VERIFICATION_TOKEN_EXPIRATION` | Email verification token TTL (minutes) | `1440` (24h) |
|
||||
| `INDIE_CORS_ORIGINS` | Allowed CORS origins (comma-separated) | `http://localhost:4200,http://localhost:3000` |
|
||||
| **LLM** | | |
|
||||
| `GEMINI_API_KEY` | Google Gemini API key | — |
|
||||
| `GEMINI_MODEL` | Gemini model name | `gemini-2.0-flash` |
|
||||
| `OPENAI_API_KEY` | OpenAI API key | — |
|
||||
| `OPENAI_MODEL` | OpenAI model name | `gpt-4o-mini` |
|
||||
| `INDIE_LLM_RATE_LIMIT` | Max LLM requests per hour per user | `10` |
|
||||
| **Email (MailHog compatible)** | | |
|
||||
| `EMAIL_HOST` | SMTP host | `localhost` |
|
||||
| `EMAIL_PORT` | SMTP port | `1025` |
|
||||
| `EMAIL_USERNAME` | SMTP username | _(empty)_ |
|
||||
| `EMAIL_PASSWORD` | SMTP password | _(empty)_ |
|
||||
| `EMAIL_SMTP_AUTH` | Enable SMTP auth | `true` |
|
||||
| `EMAIL_SMTP_STARTTLS` | Enable STARTTLS | `true` |
|
||||
|
||||
---
|
||||
|
||||
@@ -70,83 +103,175 @@ backend/
|
||||
├── settings.gradle # Project name
|
||||
├── gradlew / gradlew.bat # Gradle wrapper
|
||||
├── gradle/wrapper/ # Wrapper JAR + properties
|
||||
└── src/main/
|
||||
├── java/com/indie/
|
||||
│ ├── IndieApplication.java # Spring Boot main class
|
||||
└── src/
|
||||
├── main/java/com/krrishg/
|
||||
│ ├── IndieApplication.java # Spring Boot main class (@EnableScheduling)
|
||||
│ ├── config/
|
||||
│ │ ├── SecurityConfig.java # HTTP security, JWT filter, OAuth2 login, CORS
|
||||
│ │ ├── SecurityConfig.java # HTTP security, JWT filter, CORS
|
||||
│ │ ├── JwtConfig.java # RS256 token generation and validation
|
||||
│ │ └── GitConfig.java # Git repository settings
|
||||
│ │ ├── TokenService.java # Interface for token generation/validation
|
||||
│ │ ├── JwtAuthenticationToken.java # Custom AbstractAuthenticationToken
|
||||
│ │ ├── UserPrincipal.java # UserDetails record
|
||||
│ │ ├── GitConfig.java # Git repository settings
|
||||
|
||||
│ │ ├── EncryptionService.java # AES-256/GCM for LLM API key encryption
|
||||
│ │ ├── RateLimitConfig.java # Bucket4j token-bucket rate limiter
|
||||
│ │ ├── SchemaMigration.java # Startup DDL migrations
|
||||
│ │ ├── MapToJsonConverter.java # JPA converter for JSON text columns
|
||||
│ │ └── GlobalExceptionHandler.java # @ControllerAdvice error handling
|
||||
│ ├── model/
|
||||
│ │ ├── User.java # User entity with local + OAuth support
|
||||
│ │ ├── User.java # User entity (email, password, emailVerified)
|
||||
│ │ ├── VerificationToken.java # Email verification / set-password tokens
|
||||
│ │ ├── Site.java # Site entity per user
|
||||
│ │ ├── Page.java # Page within a site
|
||||
│ │ ├── Component.java # Component on a page (heading, text, image, etc.)
|
||||
│ │ ├── JsonbConverter.java # JPA converter for JSONB columns
|
||||
│ │ └── *.java # Enums (AuthProvider, SiteStatus, ComponentType)
|
||||
│ │ ├── SiteStatus.java # Enum: DRAFT, PUBLISHED
|
||||
│ │ ├── AuthProvider.java # Enum: LOCAL only (OAuth planned)
|
||||
│ │ ├── GitRemote.java # Connected git remote (GitHub/GitLab)
|
||||
│ │ ├── SelfHostedConfig.java # SSH/deploy configuration per site
|
||||
│ │ └── GenerationVersion.java # MongoDB: LLM generation history per site
|
||||
│ ├── repository/
|
||||
│ │ ├── UserRepository.java
|
||||
│ │ ├── SiteRepository.java
|
||||
│ │ ├── PageRepository.java
|
||||
│ │ └── ComponentRepository.java
|
||||
│ │ ├── GitRemoteRepository.java
|
||||
│ │ ├── SelfHostedConfigRepository.java
|
||||
│ │ └── VerificationTokenRepository.java
|
||||
│ ├── dto/
|
||||
│ │ ├── RegisterRequest.java
|
||||
│ │ ├── RegistrationResponse.java
|
||||
│ │ ├── LoginRequest.java
|
||||
│ │ ├── RefreshTokenRequest.java
|
||||
│ │ ├── AuthResponse.java # JWT tokens + user info
|
||||
│ │ ├── SiteRequest.java
|
||||
│ │ ├── VerifyEmailRequest.java
|
||||
│ │ ├── VerifyEmailResponse.java
|
||||
│ │ ├── ResendVerificationRequest.java
|
||||
│ │ ├── SetPasswordRequest.java
|
||||
│ │ ├── SiteRequest.java # Includes deploymentTarget field
|
||||
│ │ ├── SiteResponse.java
|
||||
│ │ ├── PageRequest.java
|
||||
│ │ └── PageResponse.java
|
||||
│ │ ├── SiteStructure.java # Top-level DTO: pages + theme
|
||||
│ │ ├── Reference.java # URL/IMAGE reference for LLM context
|
||||
│ │ ├── LLMOptions.java # LLM generation options
|
||||
│ │ ├── LLMResult.java # LLM generation result
|
||||
│ │ ├── LLMConfigRequest.java # User LLM provider config
|
||||
│ │ ├── SelfHostedConfigRequest.java
|
||||
│ │ └── SelfHostedConfigResponse.java
|
||||
│ ├── service/
|
||||
│ │ ├── AuthService.java # Register, login, token refresh
|
||||
│ │ ├── OAuth2UserService.java # Maps Google/GitHub user info to User entity
|
||||
│ │ └── OAuth2SuccessHandler.java # Redirects with JWT after OAuth login
|
||||
│ │ ├── AuthService.java # Register, login, token refresh, verify email
|
||||
│ │ ├── EmailService.java # Verification email sender
|
||||
│ │ ├── CleanupService.java # Scheduled cleanup of stale unverified accounts
|
||||
│ │ ├── LLMService.java # Orchestrates LLM calls, stores/restores versions
|
||||
│ │ ├── LLMResponseParser.java # Parses LLM JSON into SiteStructure
|
||||
│ │ ├── ReferenceService.java # Fetches web page content for LLM context
|
||||
│ │ ├── RenderService.java # Renders SiteStructure into HTML + CSS + JS
|
||||
│ │ ├── DeploymentService.java # Orchestrates publish/unpublish
|
||||
│ │ ├── Deployer.java # Interface for self-hosted deployment
|
||||
│ │ ├── SshRsyncDeployer.java # SSH + rsync + nginx + Let's Encrypt
|
||||
│ │ ├── PagesProvider.java # Interface for git provider Pages
|
||||
│ │ ├── GitHubPagesProvider.java # GitHub Pages implementation
|
||||
│ │ ├── GitLabPagesProvider.java # GitLab Pages implementation
|
||||
│ │ ├── GitService.java # JGit: init, commit, push, log, rollback
|
||||
│ │ ├── KeyManager.java # SSH key-pair generation per site
|
||||
|
||||
│ │ ├── ExportService.java # Exports site structure to ZIP
|
||||
│ │ └── DomainService.java # Custom domain resolution skeleton
|
||||
│ ├── service/llm/
|
||||
│ │ ├── LLMProvider.java # Interface for LLM providers
|
||||
│ │ ├── GeminiProvider.java # Google Gemini implementation
|
||||
│ │ ├── OpenAIProvider.java # OpenAI implementation
|
||||
│ │ └── ProviderFactory.java # Factory creating providers by name + key
|
||||
│ ├── engine/
|
||||
│ │ ├── JsBundle.java # Generates static JS (nav, smooth scroll, lightbox, contact form)
|
||||
│ │ └── StyleCompiler.java # Generates base CSS with custom property fallbacks
|
||||
│ └── controller/
|
||||
│ ├── AuthController.java # POST /api/auth/register, /login, /refresh, /logout
|
||||
│ ├── AuthController.java # POST /api/auth/*
|
||||
│ ├── SiteController.java # CRUD /api/sites
|
||||
│ └── PageController.java # CRUD /api/sites/{siteId}/pages
|
||||
└── resources/
|
||||
└── application.yml # All configuration
|
||||
│ ├── LLMController.java # POST /api/llm/generate, /refine, /versions/*
|
||||
│ ├── GitController.java # POST /api/sites/{id}/git/*
|
||||
│ ├── DeploymentController.java # POST /api/sites/{id}/publish, /unpublish
|
||||
│ ├── ExportController.java # GET /api/sites/{id}/export
|
||||
│ ├── UserSettingsController.java # GET/PUT/DELETE /api/user/llm-config
|
||||
│ └── SelfHostedController.java # CRUD /api/sites/{id}/self-hosted
|
||||
├── main/resources/
|
||||
│ ├── application.yml # All configuration
|
||||
│ └── prompts/
|
||||
│ ├── site-generator.txt # LLM system prompt for site generation
|
||||
│ └── site-refiner.txt # LLM system prompt for refinement
|
||||
└── test/java/com/krrishg/
|
||||
├── config/ # 7 config tests
|
||||
├── controller/ # 9 controller tests
|
||||
├── dto/ # 7 DTO tests
|
||||
├── engine/ # 2 engine tests
|
||||
├── model/ # 7 model tests
|
||||
├── repository/ # 4 repository tests
|
||||
├── service/ # 16 service tests + fakes
|
||||
├── service/llm/ # 3 LLM provider tests
|
||||
└── support/ # 13 test fakes, stubs, and configs
|
||||
|
||||
frontend/
|
||||
├── Dockerfile # Multi-stage production build
|
||||
├── nginx.conf # Nginx config (SPA fallback + /api/ proxy)
|
||||
├── proxy.conf.json # Dev proxy to backend
|
||||
├── angular.json # Angular CLI configuration
|
||||
├── tailwind.config.js # Tailwind CSS configuration
|
||||
├── postcss.config.js # PostCSS (tailwindcss + autoprefixer)
|
||||
├── package.json
|
||||
└── src/
|
||||
├── index.html
|
||||
├── styles.css # Global styles + Tailwind imports
|
||||
└── app/
|
||||
├── app.component.ts # Root component
|
||||
├── app.config.ts # App providers
|
||||
├── app.routes.ts # Lazy-loaded routes
|
||||
├── core/
|
||||
│ ├── models/
|
||||
│ │ ├── auth-response.ts # AuthResponse & UserInfo interfaces
|
||||
│ │ ├── site.ts # SiteResponse interface
|
||||
│ │ └── page.ts # PageResponse interface
|
||||
│ ├── services/
|
||||
│ │ ├── auth.service.ts # Auth HTTP + token storage
|
||||
│ │ ├── site.service.ts # Site CRUD HTTP
|
||||
│ │ └── page.service.ts # Page CRUD HTTP
|
||||
│ ├── guards/
|
||||
│ │ └── auth.guard.ts # Route guard (redirects to /login)
|
||||
│ └── interceptors/
|
||||
│ └── jwt.interceptor.ts # Attaches Bearer token
|
||||
├── auth/
|
||||
│ ├── login/
|
||||
│ │ └── login.component.ts # Email/password login form
|
||||
│ ├── register/
|
||||
│ │ └── register.component.ts # Name/email/password register form
|
||||
│ └── 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
|
||||
├── tsconfig.json / tsconfig.app.json / tsconfig.spec.json
|
||||
├── src/
|
||||
│ ├── index.html
|
||||
│ ├── main.ts # Angular bootstrap
|
||||
│ ├── styles.css # Global styles + Tailwind imports
|
||||
│ ├── favicon.ico
|
||||
│ ├── assets/
|
||||
│ └── app/
|
||||
│ ├── app.component.ts / .html / .css
|
||||
│ ├── app.config.ts # App providers
|
||||
│ ├── app.routes.ts # Lazy-loaded routes
|
||||
│ ├── core/
|
||||
│ │ ├── models/
|
||||
│ │ │ ├── auth-response.ts # AuthResponse & UserInfo
|
||||
│ │ │ ├── site.ts # SiteResponse
|
||||
│ │ │ ├── page.ts # PageResponse
|
||||
│ │ │ ├── llm.ts # LLM request/response types, GenerationVersion
|
||||
│ │ │ └── deployment.ts # Git/self-hosted config types
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── auth.service.ts # Auth HTTP + token storage
|
||||
│ │ │ ├── site.service.ts # Site CRUD HTTP
|
||||
│ │ │ ├── page.service.ts # Page CRUD HTTP
|
||||
│ │ │ ├── deployment.service.ts # Export, git, publish HTTP
|
||||
│ │ │ ├── llm-api.service.ts # LLM generate/refine/version HTTP
|
||||
│ │ │ └── llm-config.service.ts # User LLM key storage HTTP
|
||||
│ │ ├── guards/
|
||||
│ │ │ ├── auth.guard.ts # Redirects unauthenticated to /login
|
||||
│ │ │ └── login.guard.ts # Redirects authenticated to /dashboard
|
||||
│ │ └── interceptors/
|
||||
│ │ └── jwt.interceptor.ts # Attaches Bearer token
|
||||
│ ├── auth/
|
||||
│ │ ├── login/
|
||||
│ │ │ └── login.component.ts
|
||||
│ │ ├── register/
|
||||
│ │ │ └── register.component.ts
|
||||
│ │ └── verify-email/
|
||||
│ │ └── verify-email.component.ts # Email verification + set-password
|
||||
│ ├── dashboard/
|
||||
│ │ ├── dashboard.component.ts # Site cards grid + toolbar
|
||||
│ │ ├── create-site-dialog/
|
||||
│ │ │ └── create-site-dialog.ts
|
||||
│ │ └── delete-site-dialog/
|
||||
│ │ └── delete-site-dialog.ts
|
||||
│ └── llm-workspace/
|
||||
│ ├── llm-workspace.component.ts # Main workspace (prompt, preview, timeline)
|
||||
│ ├── llm-session.service.ts # LLM generation session state
|
||||
│ ├── settings/
|
||||
│ │ └── settings.component.ts # LLM provider key/dialog settings
|
||||
│ ├── git-settings/
|
||||
│ │ └── git-settings.component.ts # Git remote connection settings
|
||||
│ ├── preview-dialog/
|
||||
│ │ └── preview-dialog.component.ts # Preview generated site
|
||||
│ └── delete-version-dialog/
|
||||
│ └── delete-version-dialog.ts # Confirm delete version
|
||||
|
||||
config/ # Auto-generated on first boot
|
||||
├── jwt-private.key # RS256 private key
|
||||
├── jwt-public.key # RS256 public key
|
||||
└── encryption.key # AES-256-GCM master key (base64)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -157,32 +282,79 @@ frontend/
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/auth/register` | Register with email + password |
|
||||
| POST | `/api/auth/register` | Register with name + email + password (returns verification pending) |
|
||||
| POST | `/api/auth/login` | Login with email + password |
|
||||
| POST | `/api/auth/refresh` | Refresh access token |
|
||||
| POST | `/api/auth/logout` | Logout |
|
||||
| GET | `/oauth2/authorization/google` | Login with Google |
|
||||
| GET | `/oauth2/authorization/github` | Login with GitHub |
|
||||
| POST | `/api/auth/verify-email` | Verify email via token, returns setup token |
|
||||
| POST | `/api/auth/resend-verification` | Resend verification email |
|
||||
| POST | `/api/auth/set-password` | Set password after email verification |
|
||||
|
||||
### Sites (`/api/sites`)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/sites` | List user's sites |
|
||||
| POST | `/api/sites` | Create a new site |
|
||||
| POST | `/api/sites` | Create a new site (with `deploymentTarget`: `GIT_PAGES`, `SELF_HOSTED`, or `NONE`) |
|
||||
| GET | `/api/sites/{id}` | Get site details |
|
||||
| PUT | `/api/sites/{id}` | Update site |
|
||||
| DELETE | `/api/sites/{id}` | Delete site |
|
||||
|
||||
### Pages (`/api/sites/{siteId}/pages`)
|
||||
### LLM Generation (`/api/llm`)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/sites/{siteId}/pages` | List pages for a site |
|
||||
| POST | `/api/sites/{siteId}/pages` | Create a new page |
|
||||
| GET | `/api/sites/{siteId}/pages/{pageId}` | Get page details |
|
||||
| PUT | `/api/sites/{siteId}/pages/{pageId}` | Update page |
|
||||
| DELETE | `/api/sites/{siteId}/pages/{pageId}` | Delete page |
|
||||
| POST | `/api/llm/generate` | Generate site from prompt (rate-limited to 10/hour/user) |
|
||||
| POST | `/api/llm/refine` | Refine existing site structure with follow-up prompt |
|
||||
| GET | `/api/llm/versions/{siteId}` | List generation versions for a site |
|
||||
| POST | `/api/llm/versions/{id}/restore` | Restore a specific generation version |
|
||||
| DELETE | `/api/llm/versions/{id}` | Delete a generation version |
|
||||
|
||||
### Git Remotes (`/api/sites/{id}/git`)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/sites/{id}/git/connect` | Connect git remote (GitHub/GitLab) |
|
||||
| POST | `/api/sites/{id}/git/disconnect` | Disconnect git remote |
|
||||
| GET | `/api/sites/{id}/git/status` | Get connection status, SSH keys, commit log |
|
||||
|
||||
### Deployment (`/api/sites/{id}`)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/sites/{id}/publish` | Publish site (git push) |
|
||||
| POST | `/api/sites/{id}/unpublish` | Unpublish site |
|
||||
| GET | `/api/sites/{id}/export` | Download site as `.zip` archive |
|
||||
|
||||
### Self-Hosted Deployment (`/api/sites/{id}/self-hosted`)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/sites/{id}/self-hosted` | Get SSH/deploy configuration |
|
||||
| PUT | `/api/sites/{id}/self-hosted` | Save SSH/deploy configuration |
|
||||
| DELETE | `/api/sites/{id}/self-hosted` | Remove configuration |
|
||||
|
||||
### User Settings (`/api/user`)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/user/llm-config` | Get saved LLM provider configs (keys redacted) |
|
||||
| PUT | `/api/user/llm-config` | Save API key / base URL / model for a provider |
|
||||
| DELETE | `/api/user/llm-config/{provider}` | Delete saved config for a provider |
|
||||
|
||||
---
|
||||
|
||||
## Frontend Routes
|
||||
|
||||
| Path | Component | Auth |
|
||||
|------|-----------|------|
|
||||
| `/login` | LoginComponent | Redirects to /dashboard if already logged in |
|
||||
| `/register` | RegisterComponent | Redirects to /dashboard if already logged in |
|
||||
| `/verify-email` | VerifyEmailComponent | Public (email verification flow) |
|
||||
| `/dashboard` | DashboardComponent | Requires auth |
|
||||
| `/llm/:siteId` | LlmWorkspaceComponent | Requires auth |
|
||||
| `/llm/:siteId/settings` | SettingsComponent | Requires auth |
|
||||
| `/llm/:siteId/git` | GitSettingsComponent | Requires auth |
|
||||
|
||||
---
|
||||
|
||||
@@ -190,44 +362,142 @@ frontend/
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Backend | Java 17, Spring Boot 3.2, Gradle |
|
||||
| Frontend | Angular 17, Angular Material, Tailwind CSS |
|
||||
| Backend | Java 17, Spring Boot 3.2, Gradle (Groovy) |
|
||||
| Frontend | Angular 17, Angular Material, Tailwind CSS, Leaflet |
|
||||
| Relational DB | PostgreSQL 16 |
|
||||
| Document DB | MongoDB 7 |
|
||||
| Object Storage | MinIO (dev) / Cloudflare R2 (prod) |
|
||||
| Auth | Spring Security + OAuth2 Client + JWT (RS256) |
|
||||
| Git | JGit |
|
||||
| Document DB | MongoDB 7 (generation versions, LLM sessions) |
|
||||
|
||||
| Auth | Spring Security + JWT (RS256), email/password |
|
||||
| LLM | Gemini (primary), OpenAI (fallback), provider-abstraction |
|
||||
| Git | JGit — local repo per site, GitHub/GitLab remote push |
|
||||
| Email | Spring Boot Mail (MailHog compatible) |
|
||||
| Rate Limiting | Bucket4j |
|
||||
| API Docs | Springdoc OpenAPI (Swagger UI) |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ Spring Boot API │
|
||||
│ (VPS / Docker) │
|
||||
└────────┬─────────────┘
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
│ │ │
|
||||
┌────────▼───┐ ┌──────▼──────┐ ┌───▼────────┐
|
||||
│ PostgreSQL │ │ MongoDB │ │ Git Repos │
|
||||
│ (users, │ │ (versions, │ │ /var/git/ │
|
||||
│ sites) │ │ sessions) │ └────────────┘
|
||||
└─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
./gradlew build # Build
|
||||
./gradlew test # Run tests
|
||||
./gradlew test # Run tests (unit + slice + integration)
|
||||
./gradlew bootRun # Run locally (requires docker compose up -d first)
|
||||
|
||||
# Frontend
|
||||
cd frontend
|
||||
npm install # Install dependencies
|
||||
ng serve # Dev server at localhost:4200
|
||||
ng build # Production build
|
||||
ng build # Production build (outputs to dist/)
|
||||
ng test # Run unit tests (Jasmine + Karma)
|
||||
```
|
||||
|
||||
### Production Build (Docker)
|
||||
|
||||
```bash
|
||||
docker build -t indie-frontend frontend/
|
||||
docker run -p 80:80 indie-frontend
|
||||
```
|
||||
|
||||
The frontend Docker image serves the app via nginx (port 80) and proxies `/api/` requests to `http://backend:8080`.
|
||||
|
||||
---
|
||||
|
||||
## Build Status
|
||||
## Roadmap
|
||||
|
||||
### v0.1 — Auth & Foundation ✅
|
||||
- [x] User registration (email/password)
|
||||
- [x] User registration (email/password + email verification)
|
||||
- [x] User login (email/password + JWT)
|
||||
- [x] OAuth2 login (Google, GitHub) with JWT redirect
|
||||
- [x] Token refresh with 7-day refresh token
|
||||
- [x] Site CRUD (create, list, get, update, delete)
|
||||
- [x] Page CRUD within a site
|
||||
- [x] Frontend: Login, Register, OAuth callback pages
|
||||
- [x] Frontend: Dashboard with site cards, create/delete dialogs
|
||||
- [x] Frontend: JWT interceptor, auth guard, lazy routes
|
||||
- [x] Frontend: Login, Register, Verify Email, Dashboard
|
||||
- [x] Frontend: JWT interceptor, auth guard, login guard, lazy routes
|
||||
- [x] Scheduled cleanup of unverified accounts
|
||||
|
||||
### v0.2 — LLM Prompt Workspace ✅
|
||||
- [x] Prompt-to-site generation via Gemini/OpenAI
|
||||
- [x] Version timeline with MongoDB storage
|
||||
- [x] Live preview (sandboxed iframe)
|
||||
- [x] Content editor (grouped by page)
|
||||
- [x] Refine via follow-up prompts
|
||||
- [x] Rate limiting (10 generations/hour/user)
|
||||
- [x] Reference URL support for context
|
||||
|
||||
### v0.3 — Export, Git & Hosting ✅
|
||||
- [x] Render engine (HTML + CSS + JS generation)
|
||||
- [x] Git init, commit, push via JGit
|
||||
- [x] SSH key-pair management per site
|
||||
- [x] GitHub Pages / GitLab Pages providers
|
||||
- [x] Export as `.zip` archive
|
||||
- [x] Publish/unpublish flow
|
||||
- [x] Custom domain skeleton
|
||||
- [x] Self-hosted deployment (SSH + rsync + nginx)
|
||||
|
||||
---
|
||||
|
||||
## LLM Provider Abstraction
|
||||
|
||||
```java
|
||||
public interface LLMProvider {
|
||||
LLMResult generate(String systemPrompt, String userPrompt,
|
||||
List<Reference> references, LLMOptions options);
|
||||
String getName();
|
||||
}
|
||||
```
|
||||
|
||||
Adding a new provider = implement `LLMProvider` interface + add config block in `application.yml`.
|
||||
|
||||
Provider API keys can be configured globally via env vars (`GEMINI_API_KEY`, `OPENAI_API_KEY`) or per-user through the `/api/user/llm-config` endpoint (encrypted at rest via AES-256-GCM).
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Pure prompt-based** — No drag-and-drop builder. Every site starts from a prompt. Iteration happens through follow-up prompts and inline text edits.
|
||||
|
||||
2. **Static output only** — Generated sites are pure HTML/CSS/JS. No runtime framework dependency. Users own the output forever.
|
||||
|
||||
3. **Git as source of truth** — Every site is a git repository. Publish = commit. Rollback = checkout. Export includes full `.git/`.
|
||||
|
||||
4. **Version timeline** — Every generation creates a version in MongoDB. Users can browse, preview, and restore any previous version.
|
||||
|
||||
5. **No billing in MVP** — Monetization is post-MVP.
|
||||
|
||||
---
|
||||
|
||||
## config/ Directory
|
||||
|
||||
The following files live in `config/` and are auto-generated on first boot if they don't exist.
|
||||
In production, generate and mount them manually:
|
||||
|
||||
```bash
|
||||
# RS256 key pair for JWT
|
||||
openssl genpkey -algorithm RSA -out config/jwt-private.key -pkeyopt rsa_keygen_bits:2048
|
||||
openssl rsa -pubout -in config/jwt-private.key -out config/jwt-public.key
|
||||
|
||||
# AES-256-GCM master key for LLM API key encryption
|
||||
openssl rand -base64 32 > config/encryption.key
|
||||
```
|
||||
|
||||
Set the `INDIE_JWT_PRIVATE_KEY_PATH`, `INDIE_JWT_PUBLIC_KEY_PATH`, and
|
||||
`INDIE_ENCRYPTION_KEY_PATH` env vars to point to these files.
|
||||
|
||||
@@ -34,9 +34,9 @@ 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 'org.springframework.boot:spring-boot-starter-data-mongodb'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-mail'
|
||||
|
||||
implementation "com.bucket4j:bucket4j-core:8.10.1"
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.krrishg;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class IndieApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
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 javax.crypto.Cipher;
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
@Component
|
||||
public class EncryptionService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(EncryptionService.class);
|
||||
private static final String ALGORITHM = "AES/GCM/NoPadding";
|
||||
private static final int GCM_IV_LENGTH = 12;
|
||||
private static final int GCM_TAG_LENGTH = 128;
|
||||
|
||||
private final String masterKeyPath;
|
||||
private SecretKey key;
|
||||
|
||||
public EncryptionService(
|
||||
@Value("${indie.encryption.master-key-path:config/encryption.key}") String masterKeyPath) {
|
||||
this.masterKeyPath = masterKeyPath;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
Path path = Path.of(masterKeyPath);
|
||||
if (Files.exists(path)) {
|
||||
try {
|
||||
byte[] encoded = Files.readAllBytes(path);
|
||||
this.key = new SecretKeySpec(Base64.getDecoder().decode(encoded), "AES");
|
||||
log.info("Loaded encryption key from {}", masterKeyPath);
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to load encryption key from {}, generating new one: {}", masterKeyPath, e.getMessage());
|
||||
}
|
||||
}
|
||||
try {
|
||||
KeyGenerator kg = KeyGenerator.getInstance("AES");
|
||||
kg.init(256);
|
||||
this.key = kg.generateKey();
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.write(path, Base64.getEncoder().encode(key.getEncoded()));
|
||||
log.info("Generated and saved encryption key to {}", masterKeyPath);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to persist encryption key, using ephemeral key (data lost on restart): {}", e.getMessage());
|
||||
try {
|
||||
KeyGenerator kg = KeyGenerator.getInstance("AES");
|
||||
kg.init(256);
|
||||
this.key = kg.generateKey();
|
||||
} catch (Exception ex) {
|
||||
throw new RuntimeException("Failed to generate encryption key", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String encrypt(String plaintext) {
|
||||
try {
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
SecureRandom.getInstanceStrong().nextBytes(iv);
|
||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
|
||||
byte[] ciphertext = cipher.doFinal(plaintext.getBytes());
|
||||
ByteBuffer buffer = ByteBuffer.allocate(iv.length + ciphertext.length);
|
||||
buffer.put(iv);
|
||||
buffer.put(ciphertext);
|
||||
return Base64.getEncoder().encodeToString(buffer.array());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Encryption failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String decrypt(String encrypted) {
|
||||
try {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(Base64.getDecoder().decode(encrypted));
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
buffer.get(iv);
|
||||
byte[] ciphertext = new byte[buffer.remaining()];
|
||||
buffer.get(ciphertext);
|
||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
|
||||
return new String(cipher.doFinal(ciphertext));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Decryption failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import org.apache.catalina.connector.ClientAbortException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -16,6 +18,11 @@ public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(JwtException.class)
|
||||
public ProblemDetail handleJwtException(JwtException ex) {
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ProblemDetail handleBadRequest(IllegalArgumentException ex) {
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||
@@ -29,6 +36,13 @@ public class GlobalExceptionHandler {
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
|
||||
@ExceptionHandler(ClientAbortException.class)
|
||||
public void handleClientAbort(ClientAbortException ex) {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Client disconnected: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ProblemDetail handleGeneral(Exception ex) {
|
||||
log.error("Unhandled exception", ex);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Converter
|
||||
public class MapToJsonConverter implements AttributeConverter<Map<String, String>, String> {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(Map<String, String> attribute) {
|
||||
if (attribute == null || attribute.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return MAPPER.writeValueAsString(attribute);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to serialize map to JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> convertToEntityAttribute(String dbData) {
|
||||
if (dbData == null || dbData.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
try {
|
||||
return MAPPER.readValue(dbData, new TypeReference<Map<String, String>>() {});
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to deserialize JSON to map", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class SchemaMigration {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SchemaMigration.class);
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public SchemaMigration(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void migrate() {
|
||||
try {
|
||||
jdbcTemplate.execute("ALTER TABLE self_hosted_configs DROP COLUMN IF EXISTS encrypted_ssh_key");
|
||||
log.info("Dropped deprecated column encrypted_ssh_key from self_hosted_configs");
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not drop encrypted_ssh_key column: {}", e.getMessage());
|
||||
}
|
||||
try {
|
||||
jdbcTemplate.execute("ALTER TABLE self_hosted_configs ADD COLUMN IF NOT EXISTS ssl_enabled BOOLEAN DEFAULT FALSE");
|
||||
jdbcTemplate.execute("UPDATE self_hosted_configs SET ssl_enabled = FALSE WHERE ssl_enabled IS NULL");
|
||||
jdbcTemplate.execute("ALTER TABLE self_hosted_configs ALTER COLUMN ssl_enabled SET NOT NULL");
|
||||
log.info("Added column ssl_enabled to self_hosted_configs");
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not add ssl_enabled column: {}", e.getMessage());
|
||||
}
|
||||
try {
|
||||
jdbcTemplate.execute("ALTER TABLE self_hosted_configs ADD COLUMN IF NOT EXISTS ssl_email VARCHAR(255)");
|
||||
log.info("Added column ssl_email to self_hosted_configs");
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not add ssl_email column: {}", e.getMessage());
|
||||
}
|
||||
try {
|
||||
jdbcTemplate.execute("ALTER TABLE users DROP CONSTRAINT IF EXISTS users_provider_check");
|
||||
log.info("Dropped provider check constraint from users");
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not drop provider check constraint: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import com.krrishg.service.OAuth2SuccessHandler;
|
||||
import com.krrishg.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;
|
||||
@@ -15,7 +12,6 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
@@ -34,23 +30,9 @@ import java.util.UUID;
|
||||
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) {
|
||||
public SecurityConfig(JwtConfig jwtConfig) {
|
||||
this.jwtConfig = jwtConfig;
|
||||
this.oAuth2UserService = oAuth2UserService;
|
||||
this.oAuth2SuccessHandler = oAuth2SuccessHandler;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@@ -72,25 +54,11 @@ public class SecurityConfig {
|
||||
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Forbidden"))
|
||||
);
|
||||
|
||||
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();
|
||||
@@ -99,7 +67,7 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOrigins(List.of(allowedOrigins));
|
||||
config.setAllowedOriginPatterns(List.of("*"));
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
@@ -134,9 +102,6 @@ public class SecurityConfig {
|
||||
} catch (Exception e) {
|
||||
org.springframework.security.core.context.SecurityContextHolder
|
||||
.clearContext();
|
||||
response.sendError(HttpServletResponse.SC_UNAUTHORIZED,
|
||||
"Invalid or expired token");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import io.github.bucket4j.Bandwidth;
|
||||
import io.github.bucket4j.Bucket;
|
||||
import io.github.bucket4j.Refill;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Component
|
||||
public class SuggestRateLimitConfig {
|
||||
|
||||
private final Map<UUID, Bucket> cache = new ConcurrentHashMap<>();
|
||||
private final int maxRequestsPerHour;
|
||||
|
||||
public SuggestRateLimitConfig(
|
||||
@Value("${indie.llm.suggest-rate-limit.max-requests-per-hour:30}") int maxRequestsPerHour) {
|
||||
this.maxRequestsPerHour = maxRequestsPerHour;
|
||||
}
|
||||
|
||||
public boolean tryConsume(UUID userId) {
|
||||
Bucket bucket = cache.computeIfAbsent(userId, this::createBucket);
|
||||
return bucket.tryConsume(1);
|
||||
}
|
||||
|
||||
public int getAvailableTokens(UUID userId) {
|
||||
Bucket bucket = cache.computeIfAbsent(userId, this::createBucket);
|
||||
return (int) bucket.getAvailableTokens();
|
||||
}
|
||||
|
||||
private Bucket createBucket(UUID userId) {
|
||||
Bandwidth limit = Bandwidth.classic(maxRequestsPerHour,
|
||||
Refill.greedy(maxRequestsPerHour, Duration.ofHours(1)));
|
||||
return Bucket.builder().addLimit(limit).build();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,11 @@ import com.krrishg.dto.AuthResponse;
|
||||
import com.krrishg.dto.LoginRequest;
|
||||
import com.krrishg.dto.RefreshTokenRequest;
|
||||
import com.krrishg.dto.RegisterRequest;
|
||||
import com.krrishg.dto.RegistrationResponse;
|
||||
import com.krrishg.dto.ResendVerificationRequest;
|
||||
import com.krrishg.dto.SetPasswordRequest;
|
||||
import com.krrishg.dto.VerifyEmailRequest;
|
||||
import com.krrishg.dto.VerifyEmailResponse;
|
||||
import com.krrishg.service.AuthService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -24,8 +29,8 @@ public class AuthController {
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
AuthResponse response = authService.register(request);
|
||||
public ResponseEntity<RegistrationResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
RegistrationResponse response = authService.register(request);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||
}
|
||||
|
||||
@@ -45,4 +50,22 @@ public class AuthController {
|
||||
public ResponseEntity<Void> logout() {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/verify-email")
|
||||
public ResponseEntity<VerifyEmailResponse> verifyEmail(@Valid @RequestBody VerifyEmailRequest request) {
|
||||
VerifyEmailResponse response = authService.verifyEmail(request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping("/resend-verification")
|
||||
public ResponseEntity<RegistrationResponse> resendVerification(@Valid @RequestBody ResendVerificationRequest request) {
|
||||
RegistrationResponse response = authService.resendVerification(request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping("/set-password")
|
||||
public ResponseEntity<Void> setPassword(@Valid @RequestBody SetPasswordRequest request) {
|
||||
authService.setPassword(request);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.model.Site;
|
||||
import com.krrishg.repository.SiteRepository;
|
||||
import com.krrishg.service.DeploymentService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
@@ -14,15 +16,19 @@ import java.util.UUID;
|
||||
public class DeploymentController {
|
||||
|
||||
private final DeploymentService deploymentService;
|
||||
private final SiteRepository siteRepository;
|
||||
|
||||
public DeploymentController(DeploymentService deploymentService) {
|
||||
public DeploymentController(DeploymentService deploymentService,
|
||||
SiteRepository siteRepository) {
|
||||
this.deploymentService = deploymentService;
|
||||
this.siteRepository = siteRepository;
|
||||
}
|
||||
|
||||
@PostMapping("/publish")
|
||||
public ResponseEntity<?> publish(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID id) {
|
||||
findSiteForUser(id, principal.id());
|
||||
try {
|
||||
String url = deploymentService.publish(id);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
@@ -38,7 +44,17 @@ public class DeploymentController {
|
||||
public ResponseEntity<?> unpublish(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID id) {
|
||||
findSiteForUser(id, principal.id());
|
||||
deploymentService.unpublish(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Site unpublished successfully"));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ public class ExportController {
|
||||
SiteStructure structure = llmService.restoreVersion(versions.get(0).getId());
|
||||
try {
|
||||
byte[] zip = exportService.exportSite(structure);
|
||||
String filename = site.getSubdomain() != null ? site.getSubdomain() : site.getName().replaceAll("\\s+", "-");
|
||||
String filename = site.getName().replaceAll("\\s+", "-");
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + sanitizeFilename(filename) + "-export.zip\"")
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.RateLimitConfig;
|
||||
import com.krrishg.config.SuggestRateLimitConfig;
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.dto.Reference;
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import com.krrishg.dto.SuggestSectionsResponse;
|
||||
import com.krrishg.model.GenerationVersion;
|
||||
import com.krrishg.service.LLMService;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
@@ -23,11 +25,14 @@ public class LLMController {
|
||||
|
||||
private final LLMService llmService;
|
||||
private final RateLimitConfig rateLimitConfig;
|
||||
private final SuggestRateLimitConfig suggestRateLimitConfig;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public LLMController(LLMService llmService, RateLimitConfig rateLimitConfig, ObjectMapper objectMapper) {
|
||||
public LLMController(LLMService llmService, RateLimitConfig rateLimitConfig,
|
||||
SuggestRateLimitConfig suggestRateLimitConfig, ObjectMapper objectMapper) {
|
||||
this.llmService = llmService;
|
||||
this.rateLimitConfig = rateLimitConfig;
|
||||
this.suggestRateLimitConfig = suggestRateLimitConfig;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@@ -53,14 +58,62 @@ public class LLMController {
|
||||
}
|
||||
UUID siteId = UUID.fromString(siteIdStr);
|
||||
|
||||
String provider = (String) request.get("provider");
|
||||
if (provider == null || provider.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "provider is required"));
|
||||
}
|
||||
|
||||
String apiKey = (String) request.get("apiKey");
|
||||
String baseUrl = (String) request.get("baseUrl");
|
||||
String model = (String) request.get("model");
|
||||
|
||||
List<Reference> references = null;
|
||||
if (request.containsKey("references")) {
|
||||
references = objectMapper.convertValue(
|
||||
request.get("references"), new TypeReference<List<Reference>>() {});
|
||||
}
|
||||
|
||||
SiteStructure structure = llmService.generateSite(principal.id(), siteId, prompt, references);
|
||||
try {
|
||||
SiteStructure structure = llmService.generateSite(
|
||||
principal.id(), siteId, prompt, references, provider, apiKey, baseUrl, model);
|
||||
return ResponseEntity.ok(structure);
|
||||
} catch (IllegalStateException | IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/suggest-sections")
|
||||
public ResponseEntity<?> suggestSections(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestBody Map<String, Object> request) {
|
||||
if (!suggestRateLimitConfig.tryConsume(principal.id())) {
|
||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.body(Map.of("error", "Suggest rate limit exceeded. Max " +
|
||||
suggestRateLimitConfig.getAvailableTokens(principal.id()) +
|
||||
" requests per hour."));
|
||||
}
|
||||
|
||||
String briefDescription = (String) request.get("briefDescription");
|
||||
if (briefDescription == null || briefDescription.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "briefDescription is required"));
|
||||
}
|
||||
|
||||
String provider = (String) request.get("provider");
|
||||
if (provider == null || provider.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "provider is required"));
|
||||
}
|
||||
|
||||
String apiKey = (String) request.get("apiKey");
|
||||
String baseUrl = (String) request.get("baseUrl");
|
||||
String model = (String) request.get("model");
|
||||
|
||||
try {
|
||||
SuggestSectionsResponse response = llmService.suggestSections(
|
||||
principal.id(), briefDescription, provider, apiKey, baseUrl, model);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (IllegalStateException | IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/refine")
|
||||
@@ -85,6 +138,15 @@ public class LLMController {
|
||||
}
|
||||
UUID siteId = UUID.fromString(siteIdStr);
|
||||
|
||||
String provider = (String) request.get("provider");
|
||||
if (provider == null || provider.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "provider is required"));
|
||||
}
|
||||
|
||||
String apiKey = (String) request.get("apiKey");
|
||||
String baseUrl = (String) request.get("baseUrl");
|
||||
String model = (String) request.get("model");
|
||||
|
||||
SiteStructure currentStructure;
|
||||
try {
|
||||
currentStructure = objectMapper.convertValue(
|
||||
@@ -103,9 +165,13 @@ public class LLMController {
|
||||
request.get("references"), new TypeReference<List<Reference>>() {});
|
||||
}
|
||||
|
||||
try {
|
||||
SiteStructure structure = llmService.refineSite(principal.id(), siteId,
|
||||
currentStructure, prompt, references);
|
||||
currentStructure, prompt, references, provider, apiKey, baseUrl, model);
|
||||
return ResponseEntity.ok(structure);
|
||||
} catch (IllegalStateException | IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/versions/{siteId}")
|
||||
@@ -135,4 +201,17 @@ public class LLMController {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/versions/{siteId}/prune")
|
||||
public ResponseEntity<?> pruneVersions(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID siteId,
|
||||
@RequestBody(required = false) Map<String, Object> request) {
|
||||
int keep = 10;
|
||||
if (request != null && request.containsKey("keep")) {
|
||||
keep = ((Number) request.get("keep")).intValue();
|
||||
}
|
||||
int deleted = llmService.pruneVersions(siteId, keep);
|
||||
return ResponseEntity.ok(Map.of("deleted", deleted));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.dto.SelfHostedConfigRequest;
|
||||
import com.krrishg.dto.SelfHostedConfigResponse;
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import com.krrishg.model.Site;
|
||||
import com.krrishg.repository.SelfHostedConfigRepository;
|
||||
import com.krrishg.repository.SiteRepository;
|
||||
import com.krrishg.service.Deployer;
|
||||
import com.krrishg.service.KeyManager;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/sites/{id}/self-hosted")
|
||||
public class SelfHostedController {
|
||||
|
||||
private final SiteRepository siteRepository;
|
||||
private final SelfHostedConfigRepository selfHostedConfigRepository;
|
||||
private final Deployer deployer;
|
||||
private final KeyManager keyManager;
|
||||
|
||||
public SelfHostedController(SiteRepository siteRepository,
|
||||
SelfHostedConfigRepository selfHostedConfigRepository,
|
||||
Deployer deployer,
|
||||
KeyManager keyManager) {
|
||||
this.siteRepository = siteRepository;
|
||||
this.selfHostedConfigRepository = selfHostedConfigRepository;
|
||||
this.deployer = deployer;
|
||||
this.keyManager = keyManager;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<?> getConfig(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID id) {
|
||||
findSiteForUser(id, principal.id());
|
||||
var config = selfHostedConfigRepository.findBySiteId(id);
|
||||
if (config.isPresent()) {
|
||||
String publicKey = keyManager.getPublicKey(id.toString());
|
||||
return ResponseEntity.ok(SelfHostedConfigResponse.from(config.get(), publicKey));
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("configured", false));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@PutMapping
|
||||
public ResponseEntity<?> saveConfig(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody SelfHostedConfigRequest request) {
|
||||
Site site = findSiteForUser(id, principal.id());
|
||||
|
||||
selfHostedConfigRepository.findBySiteId(id).ifPresent(existing -> {
|
||||
selfHostedConfigRepository.delete(existing);
|
||||
selfHostedConfigRepository.flush();
|
||||
});
|
||||
|
||||
String deployPath = request.getDeployPath() != null && !request.getDeployPath().isBlank()
|
||||
? request.getDeployPath()
|
||||
: "/var/www/{domain}/public";
|
||||
|
||||
String nginxSitesPath = request.getNginxSitesPath() != null && !request.getNginxSitesPath().isBlank()
|
||||
? request.getNginxSitesPath()
|
||||
: "/etc/nginx/sites-available";
|
||||
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.siteId(id)
|
||||
.domain(request.getDomain())
|
||||
.sshHost(request.getSshHost())
|
||||
.sshPort(request.getSshPort())
|
||||
.sshUser(request.getSshUser())
|
||||
.deployPath(deployPath)
|
||||
.nginxSitesPath(nginxSitesPath)
|
||||
.sslEnabled(request.isSslEnabled())
|
||||
.sslEmail(request.getSslEmail())
|
||||
.build();
|
||||
|
||||
config = selfHostedConfigRepository.save(config);
|
||||
|
||||
String publicKey = keyManager.getPublicKey(id.toString());
|
||||
|
||||
return ResponseEntity.ok(SelfHostedConfigResponse.from(config, publicKey));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@DeleteMapping
|
||||
public ResponseEntity<?> removeConfig(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID id) {
|
||||
Site site = findSiteForUser(id, principal.id());
|
||||
|
||||
SelfHostedConfig config = selfHostedConfigRepository.findBySiteId(id).orElse(null);
|
||||
if (config != null) {
|
||||
try {
|
||||
deployer.remove(config);
|
||||
} catch (Exception e) {
|
||||
}
|
||||
selfHostedConfigRepository.delete(config);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("message", "Self-hosted configuration removed"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@PostMapping("/deploy")
|
||||
public ResponseEntity<?> deploy(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID id) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED)
|
||||
.body(Map.of("error", "Use POST /api/sites/{id}/publish instead"));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -36,16 +36,13 @@ public class SiteController {
|
||||
@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.DeploymentTarget target = parseDeploymentTarget(request.getDeploymentTarget());
|
||||
|
||||
Site site = Site.builder()
|
||||
.userId(principal.id())
|
||||
.name(request.getName())
|
||||
.description(request.getDescription())
|
||||
.subdomain(subdomain)
|
||||
.deploymentTarget(target)
|
||||
.build();
|
||||
|
||||
site = siteRepository.save(site);
|
||||
@@ -65,16 +62,12 @@ public class SiteController {
|
||||
@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.getDeploymentTarget() != null) {
|
||||
site.setDeploymentTarget(parseDeploymentTarget(request.getDeploymentTarget()));
|
||||
}
|
||||
|
||||
site = siteRepository.save(site);
|
||||
return ResponseEntity.ok(SiteResponse.from(site));
|
||||
@@ -97,14 +90,14 @@ public class SiteController {
|
||||
return site;
|
||||
}
|
||||
|
||||
private String sanitizeSubdomain(String subdomain) {
|
||||
if (subdomain == null || subdomain.isBlank()) {
|
||||
return null;
|
||||
private Site.DeploymentTarget parseDeploymentTarget(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return Site.DeploymentTarget.NONE;
|
||||
}
|
||||
try {
|
||||
return Site.DeploymentTarget.valueOf(value.toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Site.DeploymentTarget.NONE;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.EncryptionService;
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.dto.LLMConfigRequest;
|
||||
import com.krrishg.model.User;
|
||||
import com.krrishg.repository.UserRepository;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/user")
|
||||
public class UserSettingsController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final EncryptionService encryptionService;
|
||||
|
||||
public UserSettingsController(UserRepository userRepository, EncryptionService encryptionService) {
|
||||
this.userRepository = userRepository;
|
||||
this.encryptionService = encryptionService;
|
||||
}
|
||||
|
||||
@GetMapping("/llm-config")
|
||||
public ResponseEntity<Map<String, Object>> getLlmConfig(
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
User user = userRepository.findById(principal.id())
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
Map<String, String> keys = user.getLlmApiKeys();
|
||||
Map<String, String> urls = user.getLlmBaseUrls();
|
||||
Map<String, String> models = user.getLlmModels();
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (keys != null) {
|
||||
for (String provider : keys.keySet()) {
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
config.put("configured", true);
|
||||
if (urls != null && urls.containsKey(provider)) {
|
||||
config.put("baseUrl", urls.get(provider));
|
||||
}
|
||||
if (models != null && models.containsKey(provider)) {
|
||||
config.put("model", models.get(provider));
|
||||
}
|
||||
result.put(provider, config);
|
||||
}
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@PutMapping("/llm-config")
|
||||
public ResponseEntity<Void> saveLlmConfig(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestBody LLMConfigRequest request) {
|
||||
if (request.provider() == null || request.provider().isBlank()) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
User user = userRepository.findById(principal.id())
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
if (request.apiKey() != null && !request.apiKey().isBlank()) {
|
||||
Map<String, String> keys = new HashMap<>();
|
||||
if (user.getLlmApiKeys() != null) {
|
||||
keys.putAll(user.getLlmApiKeys());
|
||||
}
|
||||
keys.put(request.provider(), encryptionService.encrypt(request.apiKey()));
|
||||
user.setLlmApiKeys(keys);
|
||||
}
|
||||
|
||||
if (request.baseUrl() != null && !request.baseUrl().isBlank()) {
|
||||
Map<String, String> urls = new HashMap<>();
|
||||
if (user.getLlmBaseUrls() != null) {
|
||||
urls.putAll(user.getLlmBaseUrls());
|
||||
}
|
||||
urls.put(request.provider(), request.baseUrl().trim());
|
||||
user.setLlmBaseUrls(urls);
|
||||
}
|
||||
|
||||
if (request.model() != null && !request.model().isBlank()) {
|
||||
Map<String, String> models = new HashMap<>();
|
||||
if (user.getLlmModels() != null) {
|
||||
models.putAll(user.getLlmModels());
|
||||
}
|
||||
models.put(request.provider(), request.model().trim());
|
||||
user.setLlmModels(models);
|
||||
}
|
||||
|
||||
userRepository.save(user);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@DeleteMapping("/llm-config/{provider}")
|
||||
public ResponseEntity<Void> deleteLlmConfig(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable String provider) {
|
||||
User user = userRepository.findById(principal.id())
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
Map<String, String> keys = new HashMap<>();
|
||||
if (user.getLlmApiKeys() != null) {
|
||||
keys.putAll(user.getLlmApiKeys());
|
||||
}
|
||||
keys.remove(provider);
|
||||
user.setLlmApiKeys(keys);
|
||||
|
||||
Map<String, String> urls = new HashMap<>();
|
||||
if (user.getLlmBaseUrls() != null) {
|
||||
urls.putAll(user.getLlmBaseUrls());
|
||||
}
|
||||
urls.remove(provider);
|
||||
user.setLlmBaseUrls(urls);
|
||||
|
||||
Map<String, String> models = new HashMap<>();
|
||||
if (user.getLlmModels() != null) {
|
||||
models.putAll(user.getLlmModels());
|
||||
}
|
||||
models.remove(provider);
|
||||
user.setLlmModels(models);
|
||||
|
||||
userRepository.save(user);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import com.krrishg.model.AuthProvider;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -32,6 +31,5 @@ public class AuthResponse {
|
||||
private String email;
|
||||
private String name;
|
||||
private String avatarUrl;
|
||||
private AuthProvider provider;
|
||||
}
|
||||
}
|
||||
|
||||
12
backend/src/main/java/com/krrishg/dto/LLMConfigRequest.java
Normal file
12
backend/src/main/java/com/krrishg/dto/LLMConfigRequest.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
public record LLMConfigRequest(String provider, String apiKey, String baseUrl, String model) {
|
||||
|
||||
public LLMConfigRequest(String provider, String apiKey) {
|
||||
this(provider, apiKey, null, null);
|
||||
}
|
||||
|
||||
public LLMConfigRequest(String provider, String apiKey, String baseUrl) {
|
||||
this(provider, apiKey, baseUrl, null);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ public class LLMOptions {
|
||||
private double temperature = 0.9;
|
||||
|
||||
@Builder.Default
|
||||
private int maxTokens = 4096;
|
||||
private int maxTokens = 16384;
|
||||
|
||||
@Builder.Default
|
||||
private int timeoutSeconds = 60;
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.krrishg.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@@ -11,11 +10,4 @@ public class RegisterRequest {
|
||||
@NotBlank(message = "Email is required")
|
||||
@Email(message = "Invalid email format")
|
||||
private String email;
|
||||
|
||||
@NotBlank(message = "Password is required")
|
||||
@Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters")
|
||||
private String password;
|
||||
|
||||
@NotBlank(message = "Name is required")
|
||||
private String name;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class RegistrationResponse {
|
||||
private String message;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ResendVerificationRequest {
|
||||
@NotBlank
|
||||
@Email
|
||||
private String email;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class SelfHostedConfigRequest {
|
||||
|
||||
@NotBlank(message = "Domain is required")
|
||||
private String domain;
|
||||
|
||||
@NotBlank(message = "SSH host is required")
|
||||
private String sshHost;
|
||||
|
||||
private int sshPort = 22;
|
||||
|
||||
@NotBlank(message = "SSH user is required")
|
||||
private String sshUser;
|
||||
|
||||
private String deployPath;
|
||||
|
||||
private String nginxSitesPath;
|
||||
|
||||
private boolean sslEnabled;
|
||||
|
||||
private String sslEmail;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class SelfHostedConfigResponse {
|
||||
|
||||
private UUID id;
|
||||
private UUID siteId;
|
||||
private String domain;
|
||||
private String sshHost;
|
||||
private int sshPort;
|
||||
private String sshUser;
|
||||
private String deployPath;
|
||||
private String nginxSitesPath;
|
||||
private boolean sslEnabled;
|
||||
private String sslEmail;
|
||||
private String publicKey;
|
||||
private LocalDateTime deployedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public static SelfHostedConfigResponse from(SelfHostedConfig config, String publicKey) {
|
||||
return SelfHostedConfigResponse.builder()
|
||||
.id(config.getId())
|
||||
.siteId(config.getSiteId())
|
||||
.domain(config.getDomain())
|
||||
.sshHost(config.getSshHost())
|
||||
.sshPort(config.getSshPort())
|
||||
.sshUser(config.getSshUser())
|
||||
.deployPath(config.getDeployPath())
|
||||
.nginxSitesPath(config.getNginxSitesPath())
|
||||
.sslEnabled(config.isSslEnabled())
|
||||
.sslEmail(config.getSslEmail())
|
||||
.publicKey(publicKey)
|
||||
.deployedAt(config.getDeployedAt())
|
||||
.createdAt(config.getCreatedAt())
|
||||
.updatedAt(config.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SetPasswordRequest {
|
||||
@NotBlank
|
||||
private String setupToken;
|
||||
|
||||
@NotBlank
|
||||
@Size(min = 8, max = 100)
|
||||
private String password;
|
||||
|
||||
@NotBlank(message = "Name is required")
|
||||
private String name;
|
||||
}
|
||||
@@ -11,5 +11,5 @@ public class SiteRequest {
|
||||
|
||||
private String description;
|
||||
|
||||
private String subdomain;
|
||||
private String deploymentTarget;
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ public class SiteResponse {
|
||||
private UUID userId;
|
||||
private String name;
|
||||
private String description;
|
||||
private String subdomain;
|
||||
private String publishedUrl;
|
||||
private SiteStatus status;
|
||||
private String deploymentTarget;
|
||||
private LocalDateTime publishedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
@@ -31,9 +31,9 @@ public class SiteResponse {
|
||||
.userId(site.getUserId())
|
||||
.name(site.getName())
|
||||
.description(site.getDescription())
|
||||
.subdomain(site.getSubdomain())
|
||||
.publishedUrl(site.getPublishedUrl())
|
||||
.status(site.getStatus())
|
||||
.deploymentTarget(site.getDeploymentTarget().name())
|
||||
.publishedAt(site.getPublishedAt())
|
||||
.createdAt(site.getCreatedAt())
|
||||
.updatedAt(site.getUpdatedAt())
|
||||
|
||||
@@ -35,5 +35,6 @@ public class SiteStructure {
|
||||
private String seoDescription;
|
||||
private int orderIndex;
|
||||
private String rawHtml;
|
||||
private boolean deleted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
public record SuggestSectionsRequest(
|
||||
String briefDescription,
|
||||
String provider,
|
||||
String apiKey,
|
||||
String baseUrl,
|
||||
String model
|
||||
) {}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record SuggestSectionsResponse(
|
||||
List<SectionSuggestion> sections
|
||||
) {
|
||||
public record SectionSuggestion(
|
||||
String title,
|
||||
String description,
|
||||
String slug,
|
||||
List<PatternSuggestion> patterns
|
||||
) {}
|
||||
|
||||
public record PatternSuggestion(
|
||||
String title,
|
||||
String description
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class VerifyEmailRequest {
|
||||
@NotBlank
|
||||
private String token;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class VerifyEmailResponse {
|
||||
private String message;
|
||||
private String setupToken;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
public enum AuthProvider {
|
||||
LOCAL, GOOGLE, GITHUB, OPENID
|
||||
LOCAL
|
||||
}
|
||||
|
||||
@@ -18,29 +18,30 @@ import java.util.UUID;
|
||||
public class GitRemote {
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "site_id", nullable = false)
|
||||
private UUID siteId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
@Column(name = "provider", nullable = false)
|
||||
private GitProvider provider;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "remote_url", nullable = false)
|
||||
private String remoteUrl;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "encrypted_token", nullable = false)
|
||||
private String encryptedToken;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "default_branch", nullable = false)
|
||||
@Builder.Default
|
||||
private String defaultBranch = "main";
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "self_hosted_configs")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class SelfHostedConfig {
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "site_id", nullable = false, unique = true)
|
||||
private UUID siteId;
|
||||
|
||||
@Column(name = "domain", nullable = false)
|
||||
private String domain;
|
||||
|
||||
@Column(name = "ssh_host", nullable = false)
|
||||
private String sshHost;
|
||||
|
||||
@Column(name = "ssh_port", nullable = false)
|
||||
@Builder.Default
|
||||
private int sshPort = 22;
|
||||
|
||||
@Column(name = "ssh_user", nullable = false)
|
||||
private String sshUser;
|
||||
|
||||
@Column(name = "deploy_path", nullable = false)
|
||||
@Builder.Default
|
||||
private String deployPath = "/var/www/{domain}/public";
|
||||
|
||||
@Column(name = "nginx_sites_path", nullable = false)
|
||||
@Builder.Default
|
||||
private String nginxSitesPath = "/etc/nginx/sites-available";
|
||||
|
||||
@Column(name = "ssl_enabled")
|
||||
@Builder.Default
|
||||
private boolean sslEnabled = false;
|
||||
|
||||
@Column(name = "ssl_email")
|
||||
private String sslEmail;
|
||||
|
||||
@Column(name = "deployed_at")
|
||||
private LocalDateTime deployedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", 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();
|
||||
}
|
||||
}
|
||||
@@ -18,35 +18,48 @@ import java.util.UUID;
|
||||
public class Site {
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "name", nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(length = 1000)
|
||||
@Column(name = "description", length = 1000)
|
||||
private String description;
|
||||
|
||||
@Column(unique = true)
|
||||
private String subdomain;
|
||||
|
||||
@Column(name = "published_url")
|
||||
private String publishedUrl;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
@Column(name = "status", nullable = false)
|
||||
@Builder.Default
|
||||
private SiteStatus status = SiteStatus.DRAFT;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "deployment_target")
|
||||
@Builder.Default
|
||||
private DeploymentTarget deploymentTarget = DeploymentTarget.NONE;
|
||||
|
||||
@Column(name = "published_at")
|
||||
private LocalDateTime publishedAt;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public DeploymentTarget getDeploymentTarget() {
|
||||
return deploymentTarget != null ? deploymentTarget : DeploymentTarget.NONE;
|
||||
}
|
||||
|
||||
public enum DeploymentTarget {
|
||||
GIT_PAGES, SELF_HOSTED, NONE
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (id == null) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
import com.krrishg.config.MapToJsonConverter;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
@@ -7,6 +8,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@@ -18,35 +20,53 @@ import java.util.UUID;
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
@Column(name = "email", nullable = false, unique = true)
|
||||
private String email;
|
||||
|
||||
@Column(name = "password")
|
||||
private String password;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "name")
|
||||
private String name;
|
||||
|
||||
@Column(name = "avatar_url")
|
||||
private String avatarUrl;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private AuthProvider provider;
|
||||
|
||||
private String providerId;
|
||||
|
||||
@Column(name = "email_verified")
|
||||
@Builder.Default
|
||||
private boolean emailVerified = false;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "provider", nullable = false)
|
||||
@Builder.Default
|
||||
private AuthProvider provider = AuthProvider.LOCAL;
|
||||
|
||||
@Column(name = "provider_id")
|
||||
private String providerId;
|
||||
|
||||
@Convert(converter = MapToJsonConverter.class)
|
||||
@Column(name = "llm_api_keys", columnDefinition = "TEXT")
|
||||
private Map<String, String> llmApiKeys;
|
||||
|
||||
@Convert(converter = MapToJsonConverter.class)
|
||||
@Column(name = "llm_base_urls", columnDefinition = "TEXT")
|
||||
private Map<String, String> llmBaseUrls;
|
||||
|
||||
@Convert(converter = MapToJsonConverter.class)
|
||||
@Column(name = "llm_models", columnDefinition = "TEXT")
|
||||
private Map<String, String> llmModels;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
public void onCreate() {
|
||||
if (id == null) {
|
||||
id = UUID.randomUUID();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "verification_tokens")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class VerificationToken {
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
@Column(name = "token", nullable = false, unique = true)
|
||||
private String token;
|
||||
|
||||
@Column(name = "setup_token")
|
||||
private String setupToken;
|
||||
|
||||
@Column(name = "setup_token_used")
|
||||
@Builder.Default
|
||||
private boolean setupTokenUsed = false;
|
||||
|
||||
@Column(name = "expiry_date", nullable = false)
|
||||
private LocalDateTime expiryDate;
|
||||
|
||||
@Column(name = "used")
|
||||
@Builder.Default
|
||||
private boolean used = false;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
public void onCreate() {
|
||||
if (id == null) {
|
||||
id = UUID.randomUUID();
|
||||
}
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.krrishg.repository;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface SelfHostedConfigRepository extends JpaRepository<SelfHostedConfig, UUID> {
|
||||
|
||||
Optional<SelfHostedConfig> findBySiteId(UUID siteId);
|
||||
|
||||
Optional<SelfHostedConfig> findByDomain(String domain);
|
||||
|
||||
boolean existsBySiteId(UUID siteId);
|
||||
|
||||
boolean existsByDomain(String domain);
|
||||
}
|
||||
@@ -4,14 +4,9 @@ import com.krrishg.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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.krrishg.repository;
|
||||
|
||||
import com.krrishg.model.AuthProvider;
|
||||
import com.krrishg.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
@@ -11,7 +10,5 @@ public interface UserRepository extends JpaRepository<User, UUID> {
|
||||
|
||||
Optional<User> findByEmail(String email);
|
||||
|
||||
Optional<User> findByProviderAndProviderId(AuthProvider provider, String providerId);
|
||||
|
||||
boolean existsByEmail(String email);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.krrishg.repository;
|
||||
|
||||
import com.krrishg.model.VerificationToken;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface VerificationTokenRepository extends JpaRepository<VerificationToken, UUID> {
|
||||
|
||||
Optional<VerificationToken> findByToken(String token);
|
||||
|
||||
Optional<VerificationToken> findBySetupToken(String setupToken);
|
||||
|
||||
void deleteByUserId(UUID userId);
|
||||
|
||||
List<VerificationToken> findByUsedFalseAndExpiryDateBefore(LocalDateTime now);
|
||||
}
|
||||
@@ -4,44 +4,81 @@ import com.krrishg.config.TokenService;
|
||||
import com.krrishg.dto.AuthResponse;
|
||||
import com.krrishg.dto.LoginRequest;
|
||||
import com.krrishg.dto.RefreshTokenRequest;
|
||||
import com.krrishg.dto.RegistrationResponse;
|
||||
import com.krrishg.dto.RegisterRequest;
|
||||
import com.krrishg.model.AuthProvider;
|
||||
import com.krrishg.dto.ResendVerificationRequest;
|
||||
import com.krrishg.dto.SetPasswordRequest;
|
||||
import com.krrishg.dto.VerifyEmailRequest;
|
||||
import com.krrishg.dto.VerifyEmailResponse;
|
||||
import com.krrishg.model.User;
|
||||
import com.krrishg.model.VerificationToken;
|
||||
import com.krrishg.repository.UserRepository;
|
||||
import com.krrishg.repository.VerificationTokenRepository;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuthService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final TokenService jwtConfig;
|
||||
private final VerificationTokenRepository verificationTokenRepository;
|
||||
private final EmailService emailService;
|
||||
private final int tokenExpirationMinutes;
|
||||
|
||||
public AuthService(UserRepository userRepository,
|
||||
PasswordEncoder passwordEncoder,
|
||||
TokenService jwtConfig,
|
||||
VerificationTokenRepository verificationTokenRepository,
|
||||
EmailService emailService,
|
||||
@Value("${indie.app.verification-token-expiration-minutes}") int tokenExpirationMinutes) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtConfig = jwtConfig;
|
||||
this.verificationTokenRepository = verificationTokenRepository;
|
||||
this.emailService = emailService;
|
||||
this.tokenExpirationMinutes = tokenExpirationMinutes;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AuthResponse register(RegisterRequest request) {
|
||||
public RegistrationResponse register(RegisterRequest request) {
|
||||
String email = request.getEmail().toLowerCase().strip();
|
||||
|
||||
if (userRepository.existsByEmail(email)) {
|
||||
throw new IllegalArgumentException("Email already registered");
|
||||
Optional<User> existingUser = userRepository.findByEmail(email);
|
||||
|
||||
if (existingUser.isPresent()) {
|
||||
if (existingUser.get().isEmailVerified()) {
|
||||
return new RegistrationResponse("If this email is available, a verification link has been sent.");
|
||||
}
|
||||
|
||||
User user = existingUser.get();
|
||||
verificationTokenRepository.deleteByUserId(user.getId());
|
||||
VerificationToken token = createVerificationToken(user.getId());
|
||||
verificationTokenRepository.save(token);
|
||||
emailService.sendVerificationEmail(user.getEmail(), user.getName(), token.getToken());
|
||||
return new RegistrationResponse("If this email is available, a verification link has been sent.");
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
VerificationToken token = createVerificationToken(user.getId());
|
||||
verificationTokenRepository.save(token);
|
||||
emailService.sendVerificationEmail(user.getEmail(), user.getName(), token.getToken());
|
||||
|
||||
return new RegistrationResponse("If this email is available, a verification link has been sent.");
|
||||
}
|
||||
|
||||
public AuthResponse login(LoginRequest request) {
|
||||
@@ -49,8 +86,8 @@ public class AuthService {
|
||||
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 (!user.isEmailVerified() || user.getPassword() == null) {
|
||||
throw new IllegalArgumentException("Please verify your email address before logging in");
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
|
||||
@@ -60,6 +97,89 @@ public class AuthService {
|
||||
return generateAuthResponse(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public VerifyEmailResponse verifyEmail(VerifyEmailRequest request) {
|
||||
VerificationToken token = verificationTokenRepository.findByToken(request.getToken())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Invalid verification token"));
|
||||
|
||||
if (token.isUsed()) {
|
||||
throw new IllegalArgumentException("Verification token has already been used");
|
||||
}
|
||||
|
||||
if (token.getExpiryDate().isBefore(LocalDateTime.now())) {
|
||||
throw new IllegalArgumentException("Verification token has expired");
|
||||
}
|
||||
|
||||
User user = userRepository.findById(token.getUserId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
user.setEmailVerified(true);
|
||||
userRepository.save(user);
|
||||
|
||||
token.setUsed(true);
|
||||
String setupToken = UUID.randomUUID().toString();
|
||||
token.setSetupToken(setupToken);
|
||||
token.setSetupTokenUsed(false);
|
||||
verificationTokenRepository.save(token);
|
||||
|
||||
return VerifyEmailResponse.builder()
|
||||
.message("Email verified successfully")
|
||||
.setupToken(setupToken)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RegistrationResponse resendVerification(ResendVerificationRequest request) {
|
||||
String email = request.getEmail().toLowerCase().strip();
|
||||
|
||||
Optional<User> existingUser = userRepository.findByEmail(email);
|
||||
|
||||
if (existingUser.isEmpty()) {
|
||||
return new RegistrationResponse("If this email is available, a verification link has been sent.");
|
||||
}
|
||||
|
||||
User user = existingUser.get();
|
||||
|
||||
if (user.isEmailVerified()) {
|
||||
return new RegistrationResponse("If this email is available, a verification link has been sent.");
|
||||
}
|
||||
|
||||
verificationTokenRepository.deleteByUserId(user.getId());
|
||||
VerificationToken token = createVerificationToken(user.getId());
|
||||
verificationTokenRepository.save(token);
|
||||
emailService.sendVerificationEmail(user.getEmail(), user.getName(), token.getToken());
|
||||
|
||||
return new RegistrationResponse("If this email is available, a verification link has been sent.");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void setPassword(SetPasswordRequest request) {
|
||||
VerificationToken token = verificationTokenRepository.findBySetupToken(request.getSetupToken())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Invalid setup token"));
|
||||
|
||||
if (!token.isUsed()) {
|
||||
throw new IllegalArgumentException("Email must be verified first");
|
||||
}
|
||||
|
||||
if (token.isSetupTokenUsed()) {
|
||||
throw new IllegalArgumentException("Setup token has already been used");
|
||||
}
|
||||
|
||||
if (token.getExpiryDate().isBefore(LocalDateTime.now())) {
|
||||
throw new IllegalArgumentException("Setup token has expired");
|
||||
}
|
||||
|
||||
User user = userRepository.findById(token.getUserId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
user.setName(request.getName());
|
||||
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
||||
userRepository.save(user);
|
||||
|
||||
token.setSetupTokenUsed(true);
|
||||
verificationTokenRepository.save(token);
|
||||
}
|
||||
|
||||
public AuthResponse refreshToken(RefreshTokenRequest request) {
|
||||
try {
|
||||
Claims claims = jwtConfig.validateToken(request.getRefreshToken());
|
||||
@@ -74,6 +194,15 @@ public class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
private VerificationToken createVerificationToken(UUID userId) {
|
||||
return VerificationToken.builder()
|
||||
.userId(userId)
|
||||
.token(UUID.randomUUID().toString())
|
||||
.expiryDate(LocalDateTime.now().plusMinutes(tokenExpirationMinutes))
|
||||
.used(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
private AuthResponse generateAuthResponse(User user) {
|
||||
String accessToken = jwtConfig.generateAccessToken(user);
|
||||
String refreshToken = jwtConfig.generateRefreshToken(user);
|
||||
@@ -83,7 +212,6 @@ public class AuthService {
|
||||
.email(user.getEmail())
|
||||
.name(user.getName())
|
||||
.avatarUrl(user.getAvatarUrl())
|
||||
.provider(user.getProvider())
|
||||
.build();
|
||||
|
||||
return AuthResponse.builder()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.model.VerificationToken;
|
||||
import com.krrishg.repository.UserRepository;
|
||||
import com.krrishg.repository.VerificationTokenRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class CleanupService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CleanupService.class);
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final VerificationTokenRepository verificationTokenRepository;
|
||||
|
||||
public CleanupService(UserRepository userRepository,
|
||||
VerificationTokenRepository verificationTokenRepository) {
|
||||
this.userRepository = userRepository;
|
||||
this.verificationTokenRepository = verificationTokenRepository;
|
||||
}
|
||||
|
||||
@Scheduled(cron = "0 0 */6 * * ?")
|
||||
@Transactional
|
||||
public void cleanupStaleUnverifiedAccounts() {
|
||||
LocalDateTime cutoff = LocalDateTime.now().minusHours(24);
|
||||
List<VerificationToken> staleTokens = verificationTokenRepository
|
||||
.findByUsedFalseAndExpiryDateBefore(cutoff);
|
||||
|
||||
if (staleTokens.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<UUID> userIds = staleTokens.stream()
|
||||
.map(VerificationToken::getUserId)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
for (UUID userId : userIds) {
|
||||
userRepository.findById(userId).ifPresent(user -> {
|
||||
if (!user.isEmailVerified()) {
|
||||
userRepository.delete(user);
|
||||
verificationTokenRepository.deleteByUserId(userId);
|
||||
log.info("Cleaned up unverified account: {} ({})", user.getEmail(), userId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.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;
|
||||
}
|
||||
}
|
||||
13
backend/src/main/java/com/krrishg/service/Deployer.java
Normal file
13
backend/src/main/java/com/krrishg/service/Deployer.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import com.krrishg.service.RenderService.SiteOutput;
|
||||
|
||||
public interface Deployer {
|
||||
|
||||
String deploy(SiteOutput output, SelfHostedConfig config) throws Exception;
|
||||
|
||||
void remove(SelfHostedConfig config) throws Exception;
|
||||
|
||||
String buildUrl(SelfHostedConfig config);
|
||||
}
|
||||
@@ -2,10 +2,14 @@ package com.krrishg.service;
|
||||
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import com.krrishg.model.GitRemote;
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import com.krrishg.model.Site;
|
||||
import com.krrishg.model.Site.DeploymentTarget;
|
||||
import com.krrishg.model.SiteStatus;
|
||||
import com.krrishg.repository.GitRemoteRepository;
|
||||
import com.krrishg.repository.SelfHostedConfigRepository;
|
||||
import com.krrishg.repository.SiteRepository;
|
||||
import com.krrishg.service.RenderService.SiteOutput;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -23,17 +27,23 @@ public class DeploymentService {
|
||||
private final SiteRepository siteRepository;
|
||||
private final LLMService llmService;
|
||||
private final GitRemoteRepository gitRemoteRepository;
|
||||
private final SelfHostedConfigRepository selfHostedConfigRepository;
|
||||
private final Deployer deployer;
|
||||
|
||||
public DeploymentService(RenderService renderService,
|
||||
GitService gitService,
|
||||
SiteRepository siteRepository,
|
||||
LLMService llmService,
|
||||
GitRemoteRepository gitRemoteRepository) {
|
||||
GitRemoteRepository gitRemoteRepository,
|
||||
SelfHostedConfigRepository selfHostedConfigRepository,
|
||||
Deployer deployer) {
|
||||
this.renderService = renderService;
|
||||
this.gitService = gitService;
|
||||
this.siteRepository = siteRepository;
|
||||
this.llmService = llmService;
|
||||
this.gitRemoteRepository = gitRemoteRepository;
|
||||
this.selfHostedConfigRepository = selfHostedConfigRepository;
|
||||
this.deployer = deployer;
|
||||
}
|
||||
|
||||
public String publish(UUID siteId) {
|
||||
@@ -45,19 +55,46 @@ public class DeploymentService {
|
||||
throw new IllegalStateException("No generation versions found for site " + siteId);
|
||||
}
|
||||
|
||||
SiteOutput output = renderService.renderSite(structure);
|
||||
|
||||
GitRemote remote = gitRemoteRepository.findBySiteId(siteId).orElse(null);
|
||||
if (remote == null) {
|
||||
throw new IllegalStateException("No git remote configured. Connect a GitHub or GitLab remote first.");
|
||||
if (remote != null) {
|
||||
gitService.deployToPages(siteId.toString(), remote, structure);
|
||||
}
|
||||
|
||||
String url = gitService.deployToPages(siteId.toString(), remote, structure);
|
||||
SelfHostedConfig selfHostedConfig = selfHostedConfigRepository.findBySiteId(siteId).orElse(null);
|
||||
if (selfHostedConfig != null) {
|
||||
try {
|
||||
deployer.deploy(output, selfHostedConfig);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to deploy to custom server: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
String url = switch (site.getDeploymentTarget()) {
|
||||
case GIT_PAGES -> {
|
||||
if (remote == null) {
|
||||
throw new IllegalStateException(
|
||||
"Deployment target is GIT_PAGES but no git remote is configured");
|
||||
}
|
||||
yield PagesProvider.forRemote(remote).buildPagesUrl(remote);
|
||||
}
|
||||
case SELF_HOSTED -> {
|
||||
if (selfHostedConfig == null) {
|
||||
throw new IllegalStateException(
|
||||
"Deployment target is SELF_HOSTED but no self-hosted config exists");
|
||||
}
|
||||
yield deployer.buildUrl(selfHostedConfig);
|
||||
}
|
||||
case NONE -> null;
|
||||
};
|
||||
|
||||
site.setStatus(SiteStatus.PUBLISHED);
|
||||
site.setPublishedAt(LocalDateTime.now());
|
||||
site.setPublishedUrl(url);
|
||||
siteRepository.save(site);
|
||||
|
||||
log.info("Published site {} to {} at {}", siteId, remote.getProvider(), url);
|
||||
log.info("Published site {}: target={}, url={}", siteId, site.getDeploymentTarget(), url);
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -65,7 +102,19 @@ public class DeploymentService {
|
||||
Site site = siteRepository.findById(siteId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Site not found"));
|
||||
|
||||
GitRemote remote = gitRemoteRepository.findBySiteId(siteId).orElse(null);
|
||||
if (remote != null) {
|
||||
gitService.removePages(siteId.toString());
|
||||
}
|
||||
|
||||
SelfHostedConfig selfHostedConfig = selfHostedConfigRepository.findBySiteId(siteId).orElse(null);
|
||||
if (selfHostedConfig != null) {
|
||||
try {
|
||||
deployer.remove(selfHostedConfig);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to remove self-hosted deployment for site {}: {}", siteId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
site.setStatus(SiteStatus.DRAFT);
|
||||
site.setPublishedAt(null);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import com.krrishg.model.Site;
|
||||
import com.krrishg.repository.SelfHostedConfigRepository;
|
||||
import com.krrishg.repository.SiteRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Optional;
|
||||
@@ -12,49 +11,27 @@ import java.util.Optional;
|
||||
@Service
|
||||
public class DomainService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DomainService.class);
|
||||
|
||||
private final SiteRepository siteRepository;
|
||||
private final String siteDomain;
|
||||
private final SelfHostedConfigRepository selfHostedConfigRepository;
|
||||
|
||||
public DomainService(SiteRepository siteRepository,
|
||||
@Value("${indie.domain}") String siteDomain) {
|
||||
SelfHostedConfigRepository selfHostedConfigRepository) {
|
||||
this.siteRepository = siteRepository;
|
||||
this.siteDomain = siteDomain;
|
||||
this.selfHostedConfigRepository = selfHostedConfigRepository;
|
||||
}
|
||||
|
||||
public Optional<Site> resolveSite(String hostname) {
|
||||
String subdomain = extractSubdomain(hostname);
|
||||
if (subdomain != null) {
|
||||
return siteRepository.findBySubdomain(subdomain);
|
||||
}
|
||||
if (hostname == null || hostname.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private String extractSubdomain(String hostname) {
|
||||
if (hostname == null || hostname.isBlank()) {
|
||||
return null;
|
||||
String lower = hostname.toLowerCase().strip();
|
||||
|
||||
Optional<SelfHostedConfig> config = selfHostedConfigRepository.findByDomain(lower);
|
||||
if (config.isPresent()) {
|
||||
return siteRepository.findById(config.get().getSiteId());
|
||||
}
|
||||
|
||||
String lower = hostname.toLowerCase();
|
||||
String suffix = "." + siteDomain;
|
||||
if (lower.endsWith(suffix)) {
|
||||
String subdomain = lower.substring(0, lower.indexOf(suffix));
|
||||
if (!subdomain.isEmpty() && !subdomain.contains(".")) {
|
||||
return subdomain;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean isSubdomainAvailable(String subdomain) {
|
||||
return !siteRepository.existsBySubdomain(subdomain);
|
||||
}
|
||||
|
||||
public String getSiteUrl(Site site) {
|
||||
if (site.getSubdomain() != null) {
|
||||
return "https://" + site.getSubdomain() + "." + siteDomain;
|
||||
}
|
||||
return null;
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
85
backend/src/main/java/com/krrishg/service/EmailService.java
Normal file
85
backend/src/main/java/com/krrishg/service/EmailService.java
Normal file
@@ -0,0 +1,85 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class EmailService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(EmailService.class);
|
||||
|
||||
private final JavaMailSender mailSender;
|
||||
private final String baseUrl;
|
||||
private final String from;
|
||||
|
||||
public EmailService(JavaMailSender mailSender,
|
||||
@Value("${indie.app.base-url}") String baseUrl,
|
||||
@Value("${spring.mail.from}") String from) {
|
||||
this.mailSender = mailSender;
|
||||
this.baseUrl = baseUrl;
|
||||
this.from = from;
|
||||
}
|
||||
|
||||
public void sendVerificationEmail(String to, String name, String token) {
|
||||
String link = baseUrl + "/verify-email?token=" + token;
|
||||
String displayName = name != null ? name : to.substring(0, to.indexOf('@'));
|
||||
String subject = "Verify your Indie account";
|
||||
String html = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 0; background-color: #f9fafb;">
|
||||
<table width="100%%" cellpadding="0" cellspacing="0" style="padding: 40px 20px;">
|
||||
<tr><td align="center">
|
||||
<table width="480" cellpadding="0" cellspacing="0" style="background: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<tr><td style="padding: 40px 32px 24px; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 32px; color: #4f46e5;">Indie</h1>
|
||||
</td></tr>
|
||||
<tr><td style="padding: 0 32px 16px;">
|
||||
<p style="margin: 0 0 8px; font-size: 16px; color: #374151;">Hi %s,</p>
|
||||
<p style="margin: 0 0 24px; font-size: 16px; color: #374151; line-height: 1.5;">
|
||||
Someone created an account with this email address. If this was you, click the button below to verify your email and set your password.
|
||||
</p>
|
||||
<table cellpadding="0" cellspacing="0" style="margin: 0 auto 24px;">
|
||||
<tr><td align="center" style="background: #4f46e5; border-radius: 8px; padding: 12px 32px;">
|
||||
<a href="%s" style="color: #ffffff; font-size: 16px; font-weight: 600; text-decoration: none; display: inline-block;">Verify Email Address</a>
|
||||
</td></tr>
|
||||
</table>
|
||||
<p style="margin: 0 0 8px; font-size: 14px; color: #9ca3af;">This link expires in 24 hours.</p>
|
||||
<p style="margin: 0; font-size: 14px; color: #9ca3af;">If you didn't create this account, you can safely ignore this email.</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding: 24px 32px; background: #f9fafb; border-top: 1px solid #e5e7eb;">
|
||||
<p style="margin: 0; font-size: 12px; color: #9ca3af; text-align: center;">Indie Platform</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
""".formatted(displayName, link);
|
||||
|
||||
sendHtml(to, subject, html);
|
||||
}
|
||||
|
||||
private void sendHtml(String to, String subject, String html) {
|
||||
try {
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
|
||||
helper.setFrom(from);
|
||||
helper.setTo(to);
|
||||
helper.setSubject(subject);
|
||||
helper.setText(html, true);
|
||||
mailSender.send(message);
|
||||
log.info("Verification email sent to {}", to);
|
||||
} catch (MessagingException e) {
|
||||
log.error("Failed to send verification email to {}", to, e);
|
||||
throw new RuntimeException("Failed to send verification email", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -323,6 +323,10 @@ public class GitService {
|
||||
int exitCode = process.waitFor();
|
||||
if (exitCode != 0) {
|
||||
String processOutput = new String(process.getInputStream().readAllBytes());
|
||||
if (processOutput.contains("nothing to commit")) {
|
||||
log.info("Nothing to commit, skipping: {}", cmd);
|
||||
continue;
|
||||
}
|
||||
throw new RuntimeException("git command failed (exit " + exitCode + "): " + cmd + "\n" + processOutput);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,17 @@ public class LLMResponseParser {
|
||||
String json = extractJson(llmOutput);
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(json);
|
||||
return parseSiteStructure(root);
|
||||
return parseSiteStructure(root, true);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalArgumentException("Failed to parse LLM output as JSON: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public SiteStructure parseLenient(String llmOutput) {
|
||||
String json = extractJson(llmOutput);
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(json);
|
||||
return parseSiteStructure(root, false);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalArgumentException("Failed to parse LLM output as JSON: " + e.getMessage());
|
||||
}
|
||||
@@ -69,7 +79,7 @@ public class LLMResponseParser {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private SiteStructure parseSiteStructure(JsonNode root) {
|
||||
private SiteStructure parseSiteStructure(JsonNode root, boolean requirePages) {
|
||||
SiteStructure.SiteStructureBuilder builder = SiteStructure.builder();
|
||||
|
||||
if (root.has("theme")) {
|
||||
@@ -79,13 +89,13 @@ public class LLMResponseParser {
|
||||
List<SiteStructure.PageStructure> pages = new ArrayList<>();
|
||||
if (root.has("pages") && root.get("pages").isArray()) {
|
||||
for (JsonNode pageNode : root.get("pages")) {
|
||||
pages.add(parsePage(pageNode));
|
||||
pages.add(parsePage(pageNode, !requirePages));
|
||||
}
|
||||
}
|
||||
builder.pages(pages);
|
||||
|
||||
SiteStructure structure = builder.build();
|
||||
if (structure.getPages() == null || structure.getPages().isEmpty()) {
|
||||
if (requirePages && (structure.getPages() == null || structure.getPages().isEmpty())) {
|
||||
throw new IllegalArgumentException("LLM output must contain at least one page");
|
||||
}
|
||||
|
||||
@@ -118,7 +128,7 @@ public class LLMResponseParser {
|
||||
return map;
|
||||
}
|
||||
|
||||
private SiteStructure.PageStructure parsePage(JsonNode pageNode) {
|
||||
private SiteStructure.PageStructure parsePage(JsonNode pageNode, boolean lenient) {
|
||||
SiteStructure.PageStructure.PageStructureBuilder builder = SiteStructure.PageStructure.builder();
|
||||
|
||||
if (pageNode.has("id")) {
|
||||
@@ -145,9 +155,12 @@ public class LLMResponseParser {
|
||||
if (pageNode.has("rawHtml")) {
|
||||
builder.rawHtml(pageNode.get("rawHtml").asText());
|
||||
}
|
||||
if (pageNode.has("deleted")) {
|
||||
builder.deleted(pageNode.get("deleted").asBoolean());
|
||||
}
|
||||
|
||||
SiteStructure.PageStructure page = builder.build();
|
||||
if (page.getTitle() == null || page.getSlug() == null) {
|
||||
if (!lenient && !page.isDeleted() && (page.getTitle() == null || page.getSlug() == null)) {
|
||||
throw new IllegalArgumentException("Page must have title and slug");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.config.EncryptionService;
|
||||
import com.krrishg.dto.LLMOptions;
|
||||
import com.krrishg.dto.LLMResult;
|
||||
import com.krrishg.dto.Reference;
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import com.krrishg.dto.SuggestSectionsResponse;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import com.krrishg.model.GenerationVersion;
|
||||
import com.krrishg.service.llm.LLMFactory;
|
||||
import com.krrishg.model.User;
|
||||
import com.krrishg.repository.UserRepository;
|
||||
import com.krrishg.service.llm.LLMProvider;
|
||||
import com.krrishg.service.llm.ProviderFactory;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -24,7 +32,12 @@ import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -32,36 +45,66 @@ import java.util.stream.Collectors;
|
||||
public class LLMService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LLMService.class);
|
||||
private static final int MAX_VERSIONS_PER_SITE = 100;
|
||||
|
||||
private final LLMFactory llmFactory;
|
||||
private final ProviderFactory providerFactory;
|
||||
private final LLMResponseParser responseParser;
|
||||
private final ReferenceService referenceService;
|
||||
private final MongoTemplate mongoTemplate;
|
||||
private final UserRepository userRepository;
|
||||
private final EncryptionService encryptionService;
|
||||
private final String systemPrompt;
|
||||
private final String refinerPrompt;
|
||||
private final String suggestSectionsPrompt;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final int maxVersionsPerSite;
|
||||
private final double refineTemperature;
|
||||
|
||||
public LLMService(LLMFactory llmFactory,
|
||||
public LLMService(ProviderFactory providerFactory,
|
||||
LLMResponseParser responseParser,
|
||||
ReferenceService referenceService,
|
||||
MongoTemplate mongoTemplate,
|
||||
UserRepository userRepository,
|
||||
EncryptionService encryptionService,
|
||||
ResourceLoader resourceLoader,
|
||||
@Value("${indie.llm.system-prompt-path:classpath:prompts/site-generator.txt}") String promptPath,
|
||||
@Value("${indie.llm.refiner-prompt-path:classpath:prompts/site-refiner.txt}") String refinerPath) {
|
||||
this.llmFactory = llmFactory;
|
||||
@Value("${indie.llm.refiner-prompt-path:classpath:prompts/site-refiner.txt}") String refinerPath,
|
||||
@Value("${indie.llm.suggest-sections-prompt-path:classpath:prompts/suggest-sections.txt}") String suggestSectionsPath,
|
||||
@Value("${indie.llm.max-versions-per-site:10}") int maxVersionsPerSite,
|
||||
@Value("${indie.llm.refine.temperature:0.3}") double refineTemperature) {
|
||||
this.providerFactory = providerFactory;
|
||||
this.responseParser = responseParser;
|
||||
this.referenceService = referenceService;
|
||||
this.mongoTemplate = mongoTemplate;
|
||||
this.userRepository = userRepository;
|
||||
this.encryptionService = encryptionService;
|
||||
this.systemPrompt = loadSystemPrompt(resourceLoader, promptPath);
|
||||
this.refinerPrompt = loadSystemPrompt(resourceLoader, refinerPath);
|
||||
this.suggestSectionsPrompt = loadSystemPrompt(resourceLoader, suggestSectionsPath);
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.maxVersionsPerSite = maxVersionsPerSite;
|
||||
this.refineTemperature = refineTemperature;
|
||||
}
|
||||
|
||||
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt, List<Reference> references) {
|
||||
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt,
|
||||
List<Reference> references, String provider, String apiKey) {
|
||||
return generateSite(userId, siteId, prompt, references, provider, apiKey, null, null);
|
||||
}
|
||||
|
||||
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt,
|
||||
List<Reference> references, String provider, String apiKey, String baseUrl) {
|
||||
return generateSite(userId, siteId, prompt, references, provider, apiKey, baseUrl, null);
|
||||
}
|
||||
|
||||
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt,
|
||||
List<Reference> references, String provider, String apiKey, String baseUrl, String model) {
|
||||
String resolvedKey = resolveApiKey(userId, provider, apiKey);
|
||||
String resolvedBaseUrl = resolveBaseUrl(userId, provider, baseUrl);
|
||||
String resolvedModel = resolveModel(userId, provider, model);
|
||||
String referenceContext = referenceService.buildReferenceContext(references);
|
||||
String fullPrompt = prompt + referenceContext;
|
||||
|
||||
LLMOptions options = LLMOptions.builder().build();
|
||||
LLMResult result = callLLM(systemPrompt, fullPrompt, references, options);
|
||||
LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel, systemPrompt, fullPrompt, references, options);
|
||||
log.info("LLM generation completed: model={}, latency={}ms, tokens={}+{}",
|
||||
result.getModel(), result.getLatencyMs(),
|
||||
result.getPromptTokens(), result.getCompletionTokens());
|
||||
@@ -72,36 +115,106 @@ public class LLMService {
|
||||
return structure;
|
||||
}
|
||||
|
||||
public SuggestSectionsResponse suggestSections(UUID userId, String briefDescription,
|
||||
String provider, String apiKey) {
|
||||
return suggestSections(userId, briefDescription, provider, apiKey, null, null);
|
||||
}
|
||||
|
||||
public SuggestSectionsResponse suggestSections(UUID userId, String briefDescription,
|
||||
String provider, String apiKey, String baseUrl) {
|
||||
return suggestSections(userId, briefDescription, provider, apiKey, baseUrl, null);
|
||||
}
|
||||
|
||||
public SuggestSectionsResponse suggestSections(UUID userId, String briefDescription,
|
||||
String provider, String apiKey, String baseUrl, String model) {
|
||||
String resolvedKey = resolveApiKey(userId, provider, apiKey);
|
||||
String resolvedBaseUrl = resolveBaseUrl(userId, provider, baseUrl);
|
||||
String resolvedModel = resolveModel(userId, provider, model);
|
||||
|
||||
LLMOptions options = LLMOptions.builder()
|
||||
.temperature(0.2)
|
||||
.maxTokens(2000)
|
||||
.build();
|
||||
LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel,
|
||||
suggestSectionsPrompt, briefDescription, null, options);
|
||||
log.info("Section suggestions completed: model={}, latency={}ms, tokens={}+{}",
|
||||
result.getModel(), result.getLatencyMs(),
|
||||
result.getPromptTokens(), result.getCompletionTokens());
|
||||
|
||||
return parseSectionSuggestions(result.getContent());
|
||||
}
|
||||
|
||||
public SiteStructure refineSite(UUID userId, UUID siteId, SiteStructure currentStructure,
|
||||
String refinementPrompt, List<Reference> references) {
|
||||
String refinementPrompt, List<Reference> references,
|
||||
String provider, String apiKey) {
|
||||
return refineSite(userId, siteId, currentStructure, refinementPrompt, references, provider, apiKey, null, null);
|
||||
}
|
||||
|
||||
public SiteStructure refineSite(UUID userId, UUID siteId, SiteStructure currentStructure,
|
||||
String refinementPrompt, List<Reference> references,
|
||||
String provider, String apiKey, String baseUrl) {
|
||||
return refineSite(userId, siteId, currentStructure, refinementPrompt, references, provider, apiKey, baseUrl, null);
|
||||
}
|
||||
|
||||
public SiteStructure refineSite(UUID userId, UUID siteId, SiteStructure currentStructure,
|
||||
String refinementPrompt, List<Reference> references,
|
||||
String provider, String apiKey, String baseUrl, String model) {
|
||||
String resolvedKey = resolveApiKey(userId, provider, apiKey);
|
||||
String resolvedBaseUrl = resolveBaseUrl(userId, provider, baseUrl);
|
||||
String resolvedModel = resolveModel(userId, provider, model);
|
||||
String currentJson = serializeStructure(currentStructure);
|
||||
String referenceContext = referenceService.buildReferenceContext(references);
|
||||
String combinedPrompt = "Current site structure:\n" + currentJson
|
||||
+ "\n\nRefinement request: " + refinementPrompt
|
||||
+ referenceContext
|
||||
+ "\n\nOutput the complete updated JSON structure with the requested changes applied.";
|
||||
+ "\n\nOutput only the fields that changed. Fields you omit will remain unchanged."
|
||||
+ " Include the \"id\" field for any page you modify or add."
|
||||
+ " To delete a page, include it with \"id\" and \"deleted\": true."
|
||||
+ " Pages not included in your output will stay as-is.";
|
||||
|
||||
LLMOptions options = LLMOptions.builder()
|
||||
.temperature(0.5)
|
||||
.temperature(refineTemperature)
|
||||
.build();
|
||||
LLMResult result = callLLM(refinerPrompt, combinedPrompt, references, options);
|
||||
LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel, refinerPrompt, combinedPrompt, references, options);
|
||||
log.info("LLM refinement completed: model={}, latency={}ms, tokens={}+{}",
|
||||
result.getModel(), result.getLatencyMs(),
|
||||
result.getPromptTokens(), result.getCompletionTokens());
|
||||
|
||||
SiteStructure structure = responseParser.parse(result.getContent());
|
||||
saveVersion(userId, siteId, refinementPrompt, references, result.getContent());
|
||||
SiteStructure patch = responseParser.parseLenient(result.getContent());
|
||||
SiteStructure merged = mergePatches(currentStructure, patch);
|
||||
saveVersion(userId, siteId, refinementPrompt, references, serializeStructure(merged));
|
||||
|
||||
return structure;
|
||||
return merged;
|
||||
}
|
||||
|
||||
public List<GenerationVersion> getVersions(UUID siteId) {
|
||||
Query query = new Query(Criteria.where("siteId").is(siteId))
|
||||
.with(Sort.by(Sort.Direction.DESC, "versionNumber"))
|
||||
.limit(MAX_VERSIONS_PER_SITE);
|
||||
.limit(maxVersionsPerSite);
|
||||
return mongoTemplate.find(query, GenerationVersion.class);
|
||||
}
|
||||
|
||||
public int pruneVersions(UUID siteId) {
|
||||
return pruneVersions(siteId, maxVersionsPerSite);
|
||||
}
|
||||
|
||||
public int pruneVersions(UUID siteId, int keep) {
|
||||
Query countQuery = new Query(Criteria.where("siteId").is(siteId));
|
||||
long total = mongoTemplate.count(countQuery, GenerationVersion.class);
|
||||
if (total <= keep) {
|
||||
return 0;
|
||||
}
|
||||
long toDelete = total - keep;
|
||||
Query findQuery = new Query(Criteria.where("siteId").is(siteId))
|
||||
.with(Sort.by(Sort.Direction.ASC, "versionNumber"))
|
||||
.limit((int) toDelete);
|
||||
List<GenerationVersion> oldVersions = mongoTemplate.find(findQuery, GenerationVersion.class);
|
||||
for (GenerationVersion v : oldVersions) {
|
||||
mongoTemplate.remove(v);
|
||||
}
|
||||
return oldVersions.size();
|
||||
}
|
||||
|
||||
public SiteStructure restoreVersion(String versionId) {
|
||||
GenerationVersion version = mongoTemplate.findById(versionId, GenerationVersion.class);
|
||||
if (version == null) {
|
||||
@@ -118,16 +231,169 @@ public class LLMService {
|
||||
mongoTemplate.remove(version);
|
||||
}
|
||||
|
||||
private LLMResult callLLM(String systemPrompt, String userPrompt, List<Reference> references, LLMOptions options) {
|
||||
LLMProvider provider = llmFactory.getActiveProvider();
|
||||
private SiteStructure mergePatches(SiteStructure current, SiteStructure patch) {
|
||||
SiteStructure.SiteStructureBuilder builder = SiteStructure.builder();
|
||||
|
||||
if (patch.getTheme() != null) {
|
||||
builder.theme(mergeTheme(current.getTheme(), patch.getTheme()));
|
||||
} else {
|
||||
builder.theme(current.getTheme());
|
||||
}
|
||||
|
||||
if (patch.getPages() != null && !patch.getPages().isEmpty()) {
|
||||
Map<String, SiteStructure.PageStructure> currentPageMap = new HashMap<>();
|
||||
for (SiteStructure.PageStructure page : current.getPages()) {
|
||||
if (page.getId() != null) {
|
||||
currentPageMap.put(page.getId(), page);
|
||||
}
|
||||
}
|
||||
|
||||
Set<String> deletedIds = new HashSet<>();
|
||||
for (SiteStructure.PageStructure patchPage : patch.getPages()) {
|
||||
if (patchPage.isDeleted() && patchPage.getId() != null) {
|
||||
deletedIds.add(patchPage.getId());
|
||||
}
|
||||
}
|
||||
|
||||
List<SiteStructure.PageStructure> mergedPages = new ArrayList<>();
|
||||
for (SiteStructure.PageStructure currentPage : current.getPages()) {
|
||||
if (currentPage.getId() != null && deletedIds.contains(currentPage.getId())) {
|
||||
continue;
|
||||
}
|
||||
SiteStructure.PageStructure matchingPatch = null;
|
||||
if (currentPage.getId() != null) {
|
||||
for (SiteStructure.PageStructure pp : patch.getPages()) {
|
||||
if (currentPage.getId().equals(pp.getId())) {
|
||||
matchingPatch = pp;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matchingPatch != null && !matchingPatch.isDeleted()) {
|
||||
mergedPages.add(mergePage(currentPage, matchingPatch));
|
||||
} else {
|
||||
mergedPages.add(currentPage);
|
||||
}
|
||||
}
|
||||
for (SiteStructure.PageStructure patchPage : patch.getPages()) {
|
||||
if (patchPage.isDeleted()) continue;
|
||||
String pid = patchPage.getId();
|
||||
if (pid == null || !currentPageMap.containsKey(pid)) {
|
||||
mergedPages.add(patchPage);
|
||||
}
|
||||
}
|
||||
builder.pages(mergedPages);
|
||||
} else {
|
||||
builder.pages(current.getPages());
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private SiteStructure.ThemeConfig mergeTheme(SiteStructure.ThemeConfig current, SiteStructure.ThemeConfig patch) {
|
||||
SiteStructure.ThemeConfig.ThemeConfigBuilder builder = SiteStructure.ThemeConfig.builder();
|
||||
|
||||
if (patch.getColors() != null) {
|
||||
Map<String, String> mergedColors = new HashMap<>(current.getColors() != null ? current.getColors() : Map.of());
|
||||
mergedColors.putAll(patch.getColors());
|
||||
builder.colors(mergedColors);
|
||||
} else {
|
||||
builder.colors(current.getColors());
|
||||
}
|
||||
|
||||
if (patch.getFonts() != null) {
|
||||
Map<String, String> mergedFonts = new HashMap<>(current.getFonts() != null ? current.getFonts() : Map.of());
|
||||
mergedFonts.putAll(patch.getFonts());
|
||||
builder.fonts(mergedFonts);
|
||||
} else {
|
||||
builder.fonts(current.getFonts());
|
||||
}
|
||||
|
||||
if (patch.getSpacing() != null) {
|
||||
Map<String, String> mergedSpacing = new HashMap<>(current.getSpacing() != null ? current.getSpacing() : Map.of());
|
||||
mergedSpacing.putAll(patch.getSpacing());
|
||||
builder.spacing(mergedSpacing);
|
||||
} else {
|
||||
builder.spacing(current.getSpacing());
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private SiteStructure.PageStructure mergePage(SiteStructure.PageStructure current, SiteStructure.PageStructure patch) {
|
||||
SiteStructure.PageStructure.PageStructureBuilder builder = SiteStructure.PageStructure.builder();
|
||||
|
||||
builder.id(patch.getId() != null ? patch.getId() : current.getId());
|
||||
builder.title(patch.getTitle() != null ? patch.getTitle() : current.getTitle());
|
||||
builder.slug(patch.getSlug() != null ? patch.getSlug() : current.getSlug());
|
||||
builder.seoTitle(patch.getSeoTitle() != null ? patch.getSeoTitle() : current.getSeoTitle());
|
||||
builder.seoDescription(patch.getSeoDescription() != null ? patch.getSeoDescription() : current.getSeoDescription());
|
||||
builder.orderIndex(patch.getOrderIndex() != 0 ? patch.getOrderIndex() : current.getOrderIndex());
|
||||
builder.rawHtml(patch.getRawHtml() != null ? patch.getRawHtml() : current.getRawHtml());
|
||||
builder.deleted(patch.isDeleted());
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private String resolveApiKey(UUID userId, String provider, String apiKey) {
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
return apiKey;
|
||||
}
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
Map<String, String> keys = user.getLlmApiKeys();
|
||||
if (keys != null && keys.containsKey(provider)) {
|
||||
return encryptionService.decrypt(keys.get(provider));
|
||||
}
|
||||
throw new IllegalStateException("No API key configured for " + provider
|
||||
+ ". Add one in Settings.");
|
||||
}
|
||||
|
||||
private String resolveModel(UUID userId, String provider, String model) {
|
||||
if (model != null && !model.isBlank()) {
|
||||
return model;
|
||||
}
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
Map<String, String> models = user.getLlmModels();
|
||||
if (models != null && models.containsKey(provider)) {
|
||||
return models.get(provider);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolveBaseUrl(UUID userId, String provider, String baseUrl) {
|
||||
if (baseUrl != null && !baseUrl.isBlank()) {
|
||||
return baseUrl;
|
||||
}
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
Map<String, String> urls = user.getLlmBaseUrls();
|
||||
if (urls != null && urls.containsKey(provider)) {
|
||||
return urls.get(provider);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private LLMResult callLLM(String provider, String apiKey, String baseUrl, String model, String systemPrompt,
|
||||
String userPrompt, List<Reference> references, LLMOptions options) {
|
||||
LLMProvider p = providerFactory.createProvider(provider, apiKey, baseUrl, model);
|
||||
try {
|
||||
return provider.generate(systemPrompt, userPrompt, references, options);
|
||||
} catch (Exception e) {
|
||||
log.warn("Active provider {} failed: {}. Trying fallback...",
|
||||
provider.getName(), e.getMessage());
|
||||
return llmFactory.getFallbackProvider()
|
||||
.orElseThrow(() -> new RuntimeException("All LLM providers failed", e))
|
||||
.generate(systemPrompt, userPrompt, references, options);
|
||||
return p.generate(systemPrompt, userPrompt, references, options);
|
||||
} catch (ResourceAccessException e) {
|
||||
throw new IllegalStateException(
|
||||
provider + " timed out. Please try again or use a simpler prompt.");
|
||||
} catch (HttpClientErrorException e) {
|
||||
String detail = e.getResponseBodyAsString();
|
||||
String msg = switch (e.getStatusCode().value()) {
|
||||
case 401 -> provider + " rejected the API key (401 Unauthorized). Verify the key is valid in Settings.";
|
||||
case 429 -> provider + " rate limit exceeded (429). Try again later.";
|
||||
default -> {
|
||||
String body = (detail != null && !detail.isBlank()) ? ": " + detail : "";
|
||||
yield provider + " API error: " + e.getStatusCode() + " " + e.getStatusText() + body;
|
||||
}
|
||||
};
|
||||
throw new IllegalStateException(msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +422,7 @@ public class LLMService {
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
mongoTemplate.save(version);
|
||||
pruneVersions(siteId);
|
||||
}
|
||||
|
||||
private int getNextVersionNumber(UUID siteId) {
|
||||
@@ -191,4 +458,65 @@ public class LLMService {
|
||||
throw new RuntimeException("Failed to serialize site structure", e);
|
||||
}
|
||||
}
|
||||
|
||||
private SuggestSectionsResponse parseSectionSuggestions(String llmOutput) {
|
||||
String json = extractJsonArray(llmOutput);
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(json);
|
||||
List<SuggestSectionsResponse.SectionSuggestion> sections = new ArrayList<>();
|
||||
if (root.isArray()) {
|
||||
for (JsonNode node : root) {
|
||||
String title = node.has("title") ? node.get("title").asText() : null;
|
||||
String description = node.has("description") ? node.get("description").asText() : null;
|
||||
String slug = node.has("slug") ? node.get("slug").asText() : null;
|
||||
if (title != null && slug != null) {
|
||||
List<SuggestSectionsResponse.PatternSuggestion> patterns = new ArrayList<>();
|
||||
if (node.has("patterns") && node.get("patterns").isArray()) {
|
||||
for (JsonNode pn : node.get("patterns")) {
|
||||
String pt = pn.has("title") ? pn.get("title").asText() : null;
|
||||
String pd = pn.has("description") ? pn.get("description").asText() : null;
|
||||
if (pt != null) {
|
||||
patterns.add(new SuggestSectionsResponse.PatternSuggestion(pt, pd));
|
||||
}
|
||||
}
|
||||
}
|
||||
sections.add(new SuggestSectionsResponse.SectionSuggestion(title, description, slug, patterns));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sections.isEmpty()) {
|
||||
throw new IllegalArgumentException("LLM output must contain at least one section suggestion");
|
||||
}
|
||||
return new SuggestSectionsResponse(sections);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalArgumentException("Failed to parse section suggestions as JSON: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String extractJsonArray(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
throw new IllegalArgumentException("LLM output is empty");
|
||||
}
|
||||
String trimmed = raw.trim();
|
||||
if (trimmed.startsWith("```")) {
|
||||
int start = trimmed.indexOf('\n');
|
||||
int end = trimmed.lastIndexOf("```");
|
||||
if (start > 0 && end > start) {
|
||||
trimmed = trimmed.substring(start, end).trim();
|
||||
}
|
||||
}
|
||||
if (!trimmed.startsWith("[")) {
|
||||
int bracket = trimmed.indexOf('[');
|
||||
if (bracket >= 0) {
|
||||
trimmed = trimmed.substring(bracket);
|
||||
}
|
||||
}
|
||||
if (!trimmed.endsWith("]")) {
|
||||
int bracket = trimmed.lastIndexOf(']');
|
||||
if (bracket >= 0) {
|
||||
trimmed = trimmed.substring(0, bracket + 1);
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.config.JwtConfig;
|
||||
import com.krrishg.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.model.AuthProvider;
|
||||
import com.krrishg.model.User;
|
||||
import com.krrishg.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);
|
||||
}
|
||||
}
|
||||
244
backend/src/main/java/com/krrishg/service/SshRsyncDeployer.java
Normal file
244
backend/src/main/java/com/krrishg/service/SshRsyncDeployer.java
Normal file
@@ -0,0 +1,244 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import com.krrishg.service.RenderService.SiteOutput;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Comparator;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class SshRsyncDeployer implements Deployer {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SshRsyncDeployer.class);
|
||||
|
||||
private final KeyManager keyManager;
|
||||
|
||||
public SshRsyncDeployer(KeyManager keyManager) {
|
||||
this.keyManager = keyManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String deploy(SiteOutput output, SelfHostedConfig config) throws Exception {
|
||||
Path tempDir = Files.createTempDirectory("indie-deploy-" + config.getSiteId());
|
||||
try {
|
||||
String resolvedDeployPath = config.getDeployPath().replace("{domain}", config.getDomain());
|
||||
String resolvedNginxPath = config.getNginxSitesPath().replace("{domain}", config.getDomain());
|
||||
|
||||
writeSiteFiles(tempDir, output);
|
||||
|
||||
String keyFile = keyManager.getPrivateKeyPath(config.getSiteId().toString()).toString();
|
||||
|
||||
ssh(keyFile, config, "mkdir -p " + resolvedDeployPath);
|
||||
|
||||
rsync(tempDir.resolve("site"), keyFile, config, resolvedDeployPath);
|
||||
|
||||
if (config.isSslEnabled()) {
|
||||
provisionSsl(keyFile, config, resolvedDeployPath);
|
||||
}
|
||||
|
||||
String nginxConfig = buildNginxConfig(config.getDomain(), resolvedDeployPath, config.isSslEnabled());
|
||||
Path nginxConfPath = tempDir.resolve(config.getDomain());
|
||||
Files.writeString(nginxConfPath, nginxConfig);
|
||||
|
||||
String remoteTemp = "/tmp/" + config.getDomain();
|
||||
rsyncSingleFile(nginxConfPath, keyFile, config, "/tmp", config.getDomain());
|
||||
ssh(keyFile, config,
|
||||
"sudo cp " + remoteTemp + " " + resolvedNginxPath + "/" + config.getDomain()
|
||||
+ " && rm " + remoteTemp
|
||||
+ " && sudo ln -sf " + resolvedNginxPath + "/" + config.getDomain()
|
||||
+ " /etc/nginx/sites-enabled/ && sudo nginx -s reload");
|
||||
|
||||
log.info("Deployed site {} to {} ({}", config.getSiteId(), config.getDomain(), resolvedDeployPath);
|
||||
return buildUrl(config);
|
||||
} finally {
|
||||
deleteDirectory(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(SelfHostedConfig config) throws Exception {
|
||||
Path tempDir = Files.createTempDirectory("indie-deploy-remove-" + config.getSiteId());
|
||||
try {
|
||||
String resolvedDeployPath = config.getDeployPath().replace("{domain}", config.getDomain());
|
||||
|
||||
String keyFile = keyManager.getPrivateKeyPath(config.getSiteId().toString()).toString();
|
||||
|
||||
ssh(keyFile, config, "rm -rf " + resolvedDeployPath);
|
||||
|
||||
String resolvedNginxPath = config.getNginxSitesPath().replace("{domain}", config.getDomain());
|
||||
ssh(keyFile, config,
|
||||
"sudo rm -f " + resolvedNginxPath + "/" + config.getDomain()
|
||||
+ " /etc/nginx/sites-enabled/" + config.getDomain()
|
||||
+ " && sudo nginx -s reload");
|
||||
|
||||
if (config.isSslEnabled()) {
|
||||
removeSsl(keyFile, config);
|
||||
}
|
||||
|
||||
log.info("Removed deployment for site {} ({})", config.getSiteId(), config.getDomain());
|
||||
} finally {
|
||||
deleteDirectory(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String buildUrl(SelfHostedConfig config) {
|
||||
String scheme = config.isSslEnabled() ? "https" : "http";
|
||||
return scheme + "://" + config.getDomain() + "/";
|
||||
}
|
||||
|
||||
private void writeSiteFiles(Path tempDir, SiteOutput output) throws IOException {
|
||||
Path siteDir = tempDir.resolve("site");
|
||||
Files.createDirectories(siteDir);
|
||||
for (Map.Entry<String, byte[]> entry : output.asFileMap().entrySet()) {
|
||||
Path filePath = siteDir.resolve(entry.getKey());
|
||||
Files.createDirectories(filePath.getParent());
|
||||
Files.write(filePath, entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
private void ssh(String keyFile, SelfHostedConfig config, String command) throws Exception {
|
||||
String sshCmd = String.format(
|
||||
"ssh -i %s -p %d -o StrictHostKeyChecking=accept-new %s@%s \"%s\"",
|
||||
keyFile, config.getSshPort(), config.getSshUser(), config.getSshHost(), command
|
||||
);
|
||||
execute(sshCmd);
|
||||
}
|
||||
|
||||
private void rsync(Path sourceDir, String keyFile, SelfHostedConfig config, String targetDir) throws Exception {
|
||||
String rsyncCmd = String.format(
|
||||
"rsync -avz --delete -e 'ssh -p %d -i %s -o StrictHostKeyChecking=accept-new' %s/ %s@%s:%s/",
|
||||
config.getSshPort(), keyFile, sourceDir.toString(),
|
||||
config.getSshUser(), config.getSshHost(), targetDir
|
||||
);
|
||||
execute(rsyncCmd);
|
||||
}
|
||||
|
||||
private void rsyncSingleFile(Path sourceFile, String keyFile, SelfHostedConfig config,
|
||||
String targetDir, String filename) throws Exception {
|
||||
ssh(keyFile, config, "mkdir -p " + targetDir);
|
||||
String rsyncCmd = String.format(
|
||||
"rsync -avz -e 'ssh -p %d -i %s -o StrictHostKeyChecking=accept-new' %s %s@%s:%s/%s",
|
||||
config.getSshPort(), keyFile, sourceFile.toString(),
|
||||
config.getSshUser(), config.getSshHost(), targetDir, filename
|
||||
);
|
||||
execute(rsyncCmd);
|
||||
}
|
||||
|
||||
private String buildNginxConfig(String domain, String rootPath, boolean sslEnabled) {
|
||||
if (sslEnabled) {
|
||||
return "server {\n"
|
||||
+ " listen 443 ssl http2;\n"
|
||||
+ " server_name " + domain + ";\n"
|
||||
+ " root " + rootPath + ";\n"
|
||||
+ " index index.html;\n"
|
||||
+ " ssl_certificate /etc/letsencrypt/live/" + domain + "/fullchain.pem;\n"
|
||||
+ " ssl_certificate_key /etc/letsencrypt/live/" + domain + "/privkey.pem;\n"
|
||||
+ " location / {\n"
|
||||
+ " try_files $uri $uri/ /index.html;\n"
|
||||
+ " }\n"
|
||||
+ "}\n"
|
||||
+ "server {\n"
|
||||
+ " listen 80;\n"
|
||||
+ " server_name " + domain + ";\n"
|
||||
+ " return 301 https://$host$request_uri;\n"
|
||||
+ "}\n";
|
||||
}
|
||||
return "server {\n"
|
||||
+ " listen 80;\n"
|
||||
+ " server_name " + domain + ";\n"
|
||||
+ " root " + rootPath + ";\n"
|
||||
+ " index index.html;\n"
|
||||
+ " location / {\n"
|
||||
+ " try_files $uri $uri/ /index.html;\n"
|
||||
+ " }\n"
|
||||
+ "}\n";
|
||||
}
|
||||
|
||||
private void provisionSsl(String keyFile, SelfHostedConfig config, String deployPath) throws Exception {
|
||||
String domain = config.getDomain();
|
||||
String email = config.getSslEmail();
|
||||
if (email == null || email.isBlank()) {
|
||||
throw new RuntimeException("SSL email is required when SSL is enabled");
|
||||
}
|
||||
|
||||
String checkCerts = "sudo test -d /etc/letsencrypt/live/" + domain + " && echo EXISTS || echo MISSING";
|
||||
String certStatus = sshWithOutput(keyFile, config, checkCerts);
|
||||
|
||||
if (certStatus.contains("EXISTS")) {
|
||||
log.info("SSL certificates already exist for {}", domain);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Provisioning SSL certificate for {} via Let's Encrypt", domain);
|
||||
|
||||
String certbotCmd = String.format(
|
||||
"sudo certbot certonly --webroot -w %s -d %s --non-interactive --agree-tos -m %s",
|
||||
deployPath, domain, email
|
||||
);
|
||||
ssh(keyFile, config, certbotCmd);
|
||||
log.info("SSL certificate obtained for {}", domain);
|
||||
}
|
||||
|
||||
private void removeSsl(String keyFile, SelfHostedConfig config) throws Exception {
|
||||
String domain = config.getDomain();
|
||||
|
||||
String checkCerts = "sudo test -d /etc/letsencrypt/live/" + domain + " && echo EXISTS || echo MISSING";
|
||||
String certStatus = sshWithOutput(keyFile, config, checkCerts);
|
||||
|
||||
if (!certStatus.contains("EXISTS")) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Removing SSL certificates for {}", domain);
|
||||
ssh(keyFile, config, "sudo certbot delete --non-interactive -d " + domain);
|
||||
}
|
||||
|
||||
private String sshWithOutput(String keyFile, SelfHostedConfig config, String command) throws Exception {
|
||||
String sshCmd = String.format(
|
||||
"ssh -i %s -p %d -o StrictHostKeyChecking=accept-new %s@%s \"%s\"",
|
||||
keyFile, config.getSshPort(), config.getSshUser(), config.getSshHost(), command
|
||||
);
|
||||
ProcessBuilder pb = new ProcessBuilder("sh", "-c", sshCmd);
|
||||
pb.redirectErrorStream(true);
|
||||
Process process = pb.start();
|
||||
String output = new String(process.getInputStream().readAllBytes());
|
||||
int exitCode = process.waitFor();
|
||||
if (exitCode != 0) {
|
||||
throw new RuntimeException("Command failed (exit " + exitCode + "): " + sshCmd + "\n" + output);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
private void execute(String command) throws Exception {
|
||||
ProcessBuilder pb = new ProcessBuilder("sh", "-c", command);
|
||||
pb.redirectErrorStream(true);
|
||||
Process process = pb.start();
|
||||
int exitCode = process.waitFor();
|
||||
if (exitCode != 0) {
|
||||
String output = new String(process.getInputStream().readAllBytes());
|
||||
throw new RuntimeException("Command failed (exit " + exitCode + "): " + command + "\n" + output);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteDirectory(Path path) throws IOException {
|
||||
if (Files.exists(path)) {
|
||||
try (var walk = Files.walk(path)) {
|
||||
walk.sorted(Comparator.reverseOrder())
|
||||
.forEach(p -> {
|
||||
try {
|
||||
Files.deleteIfExists(p);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete {}", p);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,13 @@ package com.krrishg.service.llm;
|
||||
import com.krrishg.dto.LLMResult;
|
||||
import com.krrishg.dto.LLMOptions;
|
||||
import com.krrishg.dto.Reference;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class GeminiProvider implements LLMProvider {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
@@ -22,18 +17,11 @@ public class GeminiProvider implements LLMProvider {
|
||||
private final String model;
|
||||
private final String baseUrl;
|
||||
|
||||
public GeminiProvider(
|
||||
@Value("${indie.llm.providers.gemini.api-key:}") String apiKey,
|
||||
@Value("${indie.llm.providers.gemini.model:gemini-2.0-flash}") String model,
|
||||
@Value("${indie.llm.providers.gemini.url:https://generativelanguage.googleapis.com/v1beta/models}") String baseUrl,
|
||||
RestTemplateBuilder builder) {
|
||||
public GeminiProvider(String apiKey, String model, String baseUrl, RestTemplate restTemplate) {
|
||||
this.apiKey = apiKey;
|
||||
this.model = model;
|
||||
this.baseUrl = baseUrl;
|
||||
this.restTemplate = builder
|
||||
.setConnectTimeout(Duration.ofSeconds(30))
|
||||
.setReadTimeout(Duration.ofSeconds(60))
|
||||
.build();
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package com.krrishg.service.llm;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
public class LLMFactory {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LLMFactory.class);
|
||||
|
||||
private final List<LLMProvider> providers;
|
||||
private final String activeProvider;
|
||||
private final String fallbackProvider;
|
||||
|
||||
private Map<String, LLMProvider> providerMap;
|
||||
|
||||
public LLMFactory(List<LLMProvider> providers,
|
||||
@Value("${indie.llm.active-provider:gemini}") String activeProvider,
|
||||
@Value("${indie.llm.fallback-provider:openai}") String fallbackProvider) {
|
||||
this.providers = providers;
|
||||
this.activeProvider = activeProvider;
|
||||
this.fallbackProvider = fallbackProvider;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
providerMap = providers.stream()
|
||||
.collect(Collectors.toMap(LLMProvider::getName, Function.identity()));
|
||||
log.info("Available LLM providers: {}", providerMap.keySet());
|
||||
}
|
||||
|
||||
public LLMProvider getActiveProvider() {
|
||||
return getProvider(activeProvider)
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"Active LLM provider '" + activeProvider + "' not found. Available: " + providerMap.keySet()));
|
||||
}
|
||||
|
||||
public Optional<LLMProvider> getFallbackProvider() {
|
||||
return getProvider(fallbackProvider);
|
||||
}
|
||||
|
||||
private Optional<LLMProvider> getProvider(String name) {
|
||||
return Optional.ofNullable(providerMap.get(name));
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,12 @@ package com.krrishg.service.llm;
|
||||
import com.krrishg.dto.LLMResult;
|
||||
import com.krrishg.dto.LLMOptions;
|
||||
import com.krrishg.dto.Reference;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class OpenAIProvider implements LLMProvider {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
@@ -21,18 +16,11 @@ public class OpenAIProvider implements LLMProvider {
|
||||
private final String model;
|
||||
private final String url;
|
||||
|
||||
public OpenAIProvider(
|
||||
@Value("${indie.llm.providers.openai.api-key:}") String apiKey,
|
||||
@Value("${indie.llm.providers.openai.model:gpt-4o-mini}") String model,
|
||||
@Value("${indie.llm.providers.openai.url:https://api.openai.com/v1/chat/completions}") String url,
|
||||
RestTemplateBuilder builder) {
|
||||
public OpenAIProvider(String apiKey, String model, String url, RestTemplate restTemplate) {
|
||||
this.apiKey = apiKey;
|
||||
this.model = model;
|
||||
this.url = url;
|
||||
this.restTemplate = builder
|
||||
.setConnectTimeout(Duration.ofSeconds(30))
|
||||
.setReadTimeout(Duration.ofSeconds(60))
|
||||
.build();
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.krrishg.service.llm;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class ProviderFactory {
|
||||
|
||||
private final RestTemplateBuilder restTemplateBuilder;
|
||||
private final Map<String, ProviderConfig> providerConfigs;
|
||||
private final Duration readTimeout;
|
||||
|
||||
public ProviderFactory(
|
||||
@Value("${indie.llm.providers.gemini.model:gemini-3.1-flash-lite}") String geminiModel,
|
||||
@Value("${indie.llm.providers.gemini.url:https://generativelanguage.googleapis.com/v1beta/models}") String geminiUrl,
|
||||
@Value("${indie.llm.providers.openai.model:gpt-4o-mini}") String openaiModel,
|
||||
@Value("${indie.llm.providers.openai.url:https://api.openai.com/v1/chat/completions}") String openaiUrl,
|
||||
@Value("${indie.llm.read-timeout:180s}") Duration readTimeout,
|
||||
RestTemplateBuilder restTemplateBuilder) {
|
||||
this.restTemplateBuilder = restTemplateBuilder;
|
||||
this.readTimeout = readTimeout;
|
||||
this.providerConfigs = Map.of(
|
||||
"gemini", new ProviderConfig(geminiModel, geminiUrl),
|
||||
"openai", new ProviderConfig(openaiModel, openaiUrl)
|
||||
);
|
||||
}
|
||||
|
||||
public LLMProvider createProvider(String name, String apiKey) {
|
||||
return createProvider(name, apiKey, null, null);
|
||||
}
|
||||
|
||||
public LLMProvider createProvider(String name, String apiKey, String baseUrl) {
|
||||
return createProvider(name, apiKey, baseUrl, null);
|
||||
}
|
||||
|
||||
public LLMProvider createProvider(String name, String apiKey, String baseUrl, String model) {
|
||||
ProviderConfig config = providerConfigs.get(name);
|
||||
if (config == null) {
|
||||
throw new IllegalArgumentException("Unknown LLM provider: " + name
|
||||
+ ". Available: " + providerConfigs.keySet());
|
||||
}
|
||||
String resolvedModel = (model != null && !model.isBlank()) ? model : config.model;
|
||||
String url = config.url;
|
||||
if (baseUrl != null && !baseUrl.isBlank()) {
|
||||
url = baseUrl;
|
||||
if ("openai".equals(name) && !url.endsWith("/chat/completions")) {
|
||||
url = url.endsWith("/") ? url + "chat/completions" : url + "/chat/completions";
|
||||
}
|
||||
}
|
||||
RestTemplate rt = restTemplateBuilder
|
||||
.setConnectTimeout(Duration.ofSeconds(30))
|
||||
.setReadTimeout(readTimeout)
|
||||
.build();
|
||||
return switch (name) {
|
||||
case "gemini" -> new GeminiProvider(apiKey, resolvedModel, url, rt);
|
||||
case "openai" -> new OpenAIProvider(apiKey, resolvedModel, url, rt);
|
||||
default -> throw new IllegalArgumentException("Unknown LLM provider: " + name);
|
||||
};
|
||||
}
|
||||
|
||||
public List<String> getAvailableProviders() {
|
||||
return List.copyOf(providerConfigs.keySet());
|
||||
}
|
||||
|
||||
private record ProviderConfig(String model, String url) {}
|
||||
}
|
||||
@@ -26,6 +26,19 @@ spring:
|
||||
show-sql: true
|
||||
open-in-view: false
|
||||
|
||||
mail:
|
||||
host: ${EMAIL_HOST:localhost}
|
||||
port: ${EMAIL_PORT:1025}
|
||||
username: ${EMAIL_USERNAME:}
|
||||
password: ${EMAIL_PASSWORD:}
|
||||
from: ${EMAIL_FROM:mailme@krrishg.com}
|
||||
properties:
|
||||
mail:
|
||||
smtp:
|
||||
auth: ${EMAIL_SMTP_AUTH:true}
|
||||
starttls:
|
||||
enable: ${EMAIL_SMTP_STARTTLS:true}
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /api-docs
|
||||
@@ -43,10 +56,6 @@ indie:
|
||||
repos-path: ${INDIE_GIT_REPOS_PATH:/home/krrish/git/sites/}
|
||||
author-name: ${INDIE_GIT_AUTHOR_NAME:Indie Platform}
|
||||
author-email: ${INDIE_GIT_AUTHOR_EMAIL:git@indie.local}
|
||||
domain: ${INDIE_SITE_DOMAIN:indie.com}
|
||||
cors:
|
||||
allowed-origins: ${INDIE_CORS_ORIGINS:http://localhost:4200,http://localhost:3000}
|
||||
|
||||
storage:
|
||||
endpoint: ${INDIE_STORAGE_ENDPOINT:http://localhost:9000}
|
||||
region: ${INDIE_STORAGE_REGION:auto}
|
||||
@@ -55,17 +64,25 @@ indie:
|
||||
bucket: ${INDIE_STORAGE_BUCKET:indie-sites}
|
||||
public-url-base: ${INDIE_STORAGE_PUBLIC_URL:http://localhost:9000/indie-sites}
|
||||
|
||||
encryption:
|
||||
master-key-path: ${INDIE_ENCRYPTION_KEY_PATH:config/encryption.key}
|
||||
|
||||
app:
|
||||
base-url: ${INDIE_APP_BASE_URL:https://indie.krrishg.com}
|
||||
verification-token-expiration-minutes: ${INDIE_VERIFICATION_TOKEN_EXPIRATION:1440}
|
||||
|
||||
llm:
|
||||
active-provider: ${INDIE_LLM_ACTIVE_PROVIDER:gemini}
|
||||
fallback-provider: ${INDIE_LLM_FALLBACK_PROVIDER:openai}
|
||||
read-timeout: ${INDIE_LLM_READ_TIMEOUT:180s}
|
||||
max-versions-per-site: ${INDIE_LLM_MAX_VERSIONS:10}
|
||||
refine:
|
||||
temperature: ${INDIE_LLM_REFINE_TEMPERATURE:0.3}
|
||||
read-timeout: ${INDIE_LLM_REFINE_READ_TIMEOUT:300s}
|
||||
rate-limit:
|
||||
max-requests-per-hour: ${INDIE_LLM_RATE_LIMIT:10}
|
||||
providers:
|
||||
gemini:
|
||||
api-key: ${GEMINI_API_KEY:}
|
||||
model: ${GEMINI_MODEL:gemini-3.1-flash-lite}
|
||||
url: https://generativelanguage.googleapis.com/v1beta/models
|
||||
openai:
|
||||
api-key: ${OPENAI_API_KEY:}
|
||||
model: ${OPENAI_MODEL:gpt-4o-mini}
|
||||
url: https://api.openai.com/v1/chat/completions
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class EncryptionServiceTest {
|
||||
|
||||
private Path tempKeyPath;
|
||||
private EncryptionService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
try {
|
||||
tempKeyPath = Files.createTempFile("test-encryption", ".key");
|
||||
service = new EncryptionService(tempKeyPath.toString());
|
||||
service.init();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws IOException {
|
||||
Files.deleteIfExists(tempKeyPath);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptAndDecryptRoundtrip() {
|
||||
String plaintext = "Hello, World!";
|
||||
|
||||
String encrypted = service.encrypt(plaintext);
|
||||
assertNotNull(encrypted);
|
||||
assertNotEquals(plaintext, encrypted);
|
||||
|
||||
String decrypted = service.decrypt(encrypted);
|
||||
assertEquals(plaintext, decrypted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptAndDecryptSpecialCharacters() {
|
||||
String plaintext = "Special chars: !@#$%^&*()_+-=[]{}|;':\",./<>?`~\n\t";
|
||||
|
||||
String encrypted = service.encrypt(plaintext);
|
||||
String decrypted = service.decrypt(encrypted);
|
||||
|
||||
assertEquals(plaintext, decrypted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptAndDecryptEmptyString() {
|
||||
String encrypted = service.encrypt("");
|
||||
String decrypted = service.decrypt(encrypted);
|
||||
|
||||
assertEquals("", decrypted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentPlaintextsProduceDifferentCiphertexts() {
|
||||
String encrypted1 = service.encrypt("text1");
|
||||
String encrypted2 = service.encrypt("text2");
|
||||
|
||||
assertNotEquals(encrypted1, encrypted2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void samePlaintextProducedDifferentCiphertextsDueToRandomIv() {
|
||||
String encrypted1 = service.encrypt("same text");
|
||||
String encrypted2 = service.encrypt("same text");
|
||||
|
||||
assertNotEquals(encrypted1, encrypted2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decryptThrowsOnInvalidData() {
|
||||
assertThrows(RuntimeException.class, () -> service.decrypt("invalid-base64!"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void initCreatesKeyFile() throws IOException {
|
||||
assertTrue(Files.exists(tempKeyPath));
|
||||
assertTrue(Files.size(tempKeyPath) > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void initLoadsExistingKeyFile() {
|
||||
EncryptionService secondService = new EncryptionService(tempKeyPath.toString());
|
||||
secondService.init();
|
||||
|
||||
String plaintext = "Persistent key test";
|
||||
String encrypted = secondService.encrypt(plaintext);
|
||||
String decrypted = secondService.decrypt(encrypted);
|
||||
|
||||
assertEquals(plaintext, decrypted);
|
||||
}
|
||||
}
|
||||
66
backend/src/test/java/com/krrishg/config/GitConfigTest.java
Normal file
66
backend/src/test/java/com/krrishg/config/GitConfigTest.java
Normal file
@@ -0,0 +1,66 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class GitConfigTest {
|
||||
|
||||
@Test
|
||||
void getSiteRepoPathResolvesCorrectly() throws IOException {
|
||||
Path tempDir = Files.createTempDirectory("git-config-test");
|
||||
GitConfig config = new GitConfig();
|
||||
setField(config, "reposPath", tempDir.toString());
|
||||
setField(config, "authorName", "Indie Platform");
|
||||
setField(config, "authorEmail", "git@indie.local");
|
||||
config.init();
|
||||
|
||||
Path repoPath = config.getSiteRepoPath("test-site-id");
|
||||
|
||||
assertEquals(tempDir.resolve("test-site-id"), repoPath);
|
||||
assertEquals("Indie Platform", config.getAuthorName());
|
||||
assertEquals("git@indie.local", config.getAuthorEmail());
|
||||
|
||||
Files.deleteIfExists(tempDir);
|
||||
}
|
||||
|
||||
@Test
|
||||
void initCreatesDirectory() throws IOException {
|
||||
Path tempDir = Files.createTempDirectory("git-config-init-test");
|
||||
Path reposDir = tempDir.resolve("repos");
|
||||
GitConfig config = new GitConfig();
|
||||
setField(config, "reposPath", reposDir.toString());
|
||||
|
||||
assertFalse(Files.exists(reposDir));
|
||||
config.init();
|
||||
assertTrue(Files.exists(reposDir));
|
||||
|
||||
Files.deleteIfExists(reposDir);
|
||||
Files.deleteIfExists(tempDir);
|
||||
}
|
||||
|
||||
@Test
|
||||
void settersUpdateAuthorInfo() {
|
||||
GitConfig config = new GitConfig();
|
||||
|
||||
config.setAuthorName("Custom Author");
|
||||
config.setAuthorEmail("custom@example.com");
|
||||
|
||||
assertEquals("Custom Author", config.getAuthorName());
|
||||
assertEquals("custom@example.com", config.getAuthorEmail());
|
||||
}
|
||||
|
||||
private void setField(Object target, String fieldName, Object value) {
|
||||
try {
|
||||
var field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import org.apache.catalina.connector.ClientAbortException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
@@ -13,6 +15,19 @@ class GlobalExceptionHandlerTest {
|
||||
|
||||
private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
|
||||
|
||||
@Test
|
||||
void jwtExceptionReturns401() {
|
||||
ProblemDetail detail = handler.handleJwtException(new JwtException("token expired"));
|
||||
|
||||
assertEquals(HttpStatus.UNAUTHORIZED.value(), detail.getStatus());
|
||||
assertEquals("token expired", detail.getDetail());
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientAbortDoesNotThrow() {
|
||||
handler.handleClientAbort(new ClientAbortException("client disconnected"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void illegalArgumentReturns400() {
|
||||
ProblemDetail detail = handler.handleBadRequest(new IllegalArgumentException("bad input"));
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class JwtAuthenticationTokenTest {
|
||||
|
||||
private final UserPrincipal principal = new UserPrincipal(
|
||||
UUID.randomUUID(), "test@example.com", "Test User");
|
||||
private final String token = "test-jwt-token";
|
||||
private final JwtAuthenticationToken auth = new JwtAuthenticationToken(principal, token);
|
||||
|
||||
@Test
|
||||
void constructorSetsAuthenticated() {
|
||||
assertTrue(auth.isAuthenticated());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCredentialsReturnsToken() {
|
||||
assertEquals(token, auth.getCredentials());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPrincipalReturnsUserPrincipal() {
|
||||
assertSame(principal, auth.getPrincipal());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuthoritiesContainsRoleUser() {
|
||||
var authorities = auth.getAuthorities();
|
||||
assertEquals(1, authorities.size());
|
||||
assertEquals("ROLE_USER", authorities.iterator().next().getAuthority());
|
||||
}
|
||||
|
||||
@Test
|
||||
void principalFieldsAreAccessible() {
|
||||
assertEquals("test@example.com", principal.email());
|
||||
assertEquals("Test User", principal.name());
|
||||
assertNotNull(principal.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void principalIsInstanceOfUserPrincipal() {
|
||||
assertInstanceOf(UserPrincipal.class, auth.getPrincipal());
|
||||
}
|
||||
}
|
||||
193
backend/src/test/java/com/krrishg/config/JwtConfigTest.java
Normal file
193
backend/src/test/java/com/krrishg/config/JwtConfigTest.java
Normal file
@@ -0,0 +1,193 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import com.krrishg.model.User;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyPair;
|
||||
import java.util.UUID;
|
||||
|
||||
import static io.jsonwebtoken.Jwts.SIG.RS256;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class JwtConfigTest {
|
||||
|
||||
@Test
|
||||
void generateAndValidateAccessToken() {
|
||||
JwtConfig jwtConfig = createWithExpiration(900_000L, 604_800_000L);
|
||||
jwtConfig.init();
|
||||
|
||||
User user = User.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.email("test@example.com")
|
||||
.name("Test User")
|
||||
.build();
|
||||
|
||||
String token = jwtConfig.generateAccessToken(user);
|
||||
Claims claims = jwtConfig.validateToken(token);
|
||||
|
||||
assertEquals(user.getId().toString(), claims.getSubject());
|
||||
assertEquals("test@example.com", claims.get("email", String.class));
|
||||
assertEquals("Test User", claims.get("name", String.class));
|
||||
assertNotNull(claims.getIssuedAt());
|
||||
assertNotNull(claims.getExpiration());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateAndValidateRefreshToken() {
|
||||
JwtConfig jwtConfig = createWithExpiration(900_000L, 604_800_000L);
|
||||
jwtConfig.init();
|
||||
|
||||
User user = User.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.email("refresh@example.com")
|
||||
.name("Refresh User")
|
||||
.build();
|
||||
|
||||
String token = jwtConfig.generateRefreshToken(user);
|
||||
Claims claims = jwtConfig.validateToken(token);
|
||||
|
||||
assertEquals(user.getId().toString(), claims.getSubject());
|
||||
assertEquals("refresh@example.com", claims.get("email", String.class));
|
||||
assertEquals("Refresh User", claims.get("name", String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipleTokensForSameUserAreAllValid() {
|
||||
JwtConfig jwtConfig = createWithExpiration(900_000L, 604_800_000L);
|
||||
jwtConfig.init();
|
||||
|
||||
User user = User.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.email("same@example.com")
|
||||
.build();
|
||||
|
||||
String token1 = jwtConfig.generateAccessToken(user);
|
||||
String token2 = jwtConfig.generateAccessToken(user);
|
||||
|
||||
assertDoesNotThrow(() -> jwtConfig.validateToken(token1));
|
||||
assertDoesNotThrow(() -> jwtConfig.validateToken(token2));
|
||||
Claims claims1 = jwtConfig.validateToken(token1);
|
||||
Claims claims2 = jwtConfig.validateToken(token2);
|
||||
assertEquals(user.getId().toString(), claims1.getSubject());
|
||||
assertEquals(user.getId().toString(), claims2.getSubject());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectTamperedToken() {
|
||||
JwtConfig jwtConfig = createWithExpiration(900_000L, 604_800_000L);
|
||||
jwtConfig.init();
|
||||
|
||||
User user = User.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.email("tamper@example.com")
|
||||
.build();
|
||||
|
||||
String token = jwtConfig.generateAccessToken(user);
|
||||
String tampered = token.substring(0, token.lastIndexOf('.') + 1) + "tampered";
|
||||
|
||||
assertThrows(Exception.class, () -> jwtConfig.validateToken(tampered));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectExpiredToken() throws Exception {
|
||||
JwtConfig jwtConfig = new JwtConfig();
|
||||
setField(jwtConfig, "accessTokenExpiration", -1000L);
|
||||
jwtConfig.init();
|
||||
|
||||
User user = User.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.email("expired@example.com")
|
||||
.build();
|
||||
|
||||
String token = jwtConfig.generateAccessToken(user);
|
||||
|
||||
assertThrows(Exception.class, () -> jwtConfig.validateToken(token));
|
||||
}
|
||||
|
||||
@Test
|
||||
void initGeneratesEphemeralKeys() {
|
||||
JwtConfig jwtConfig = createWithExpiration(900_000L, 604_800_000L);
|
||||
jwtConfig.init();
|
||||
|
||||
assertNotNull(jwtConfig.getPublicKey());
|
||||
User user = User.builder().id(UUID.randomUUID()).email("ephemeral@test.com").build();
|
||||
String token = jwtConfig.generateAccessToken(user);
|
||||
assertDoesNotThrow(() -> jwtConfig.validateToken(token));
|
||||
}
|
||||
|
||||
@Test
|
||||
void initLoadsKeysFromFiles(@TempDir Path tempDir) throws Exception {
|
||||
KeyPair keyPair = RS256.keyPair().build();
|
||||
Path privPath = tempDir.resolve("private.key");
|
||||
Path pubPath = tempDir.resolve("public.key");
|
||||
Files.write(privPath, keyPair.getPrivate().getEncoded());
|
||||
Files.write(pubPath, keyPair.getPublic().getEncoded());
|
||||
|
||||
JwtConfig jwtConfig = new JwtConfig();
|
||||
setField(jwtConfig, "privateKeyPath", privPath.toString());
|
||||
setField(jwtConfig, "publicKeyPath", pubPath.toString());
|
||||
setField(jwtConfig, "accessTokenExpiration", 900_000L);
|
||||
setField(jwtConfig, "refreshTokenExpiration", 604_800_000L);
|
||||
jwtConfig.init();
|
||||
|
||||
User user = User.builder().id(UUID.randomUUID()).email("file@test.com").name("File Test").build();
|
||||
String token = jwtConfig.generateAccessToken(user);
|
||||
Claims claims = jwtConfig.validateToken(token);
|
||||
assertEquals(user.getId().toString(), claims.getSubject());
|
||||
assertEquals("file@test.com", claims.get("email", String.class));
|
||||
assertEquals("File Test", claims.get("name", String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void initGeneratesAndSavesKeysWhenFilesDoNotExist(@TempDir Path tempDir) throws Exception {
|
||||
Path privPath = tempDir.resolve("private.key");
|
||||
Path pubPath = tempDir.resolve("public.key");
|
||||
|
||||
JwtConfig jwtConfig = new JwtConfig();
|
||||
setField(jwtConfig, "privateKeyPath", privPath.toString());
|
||||
setField(jwtConfig, "publicKeyPath", pubPath.toString());
|
||||
setField(jwtConfig, "accessTokenExpiration", 900_000L);
|
||||
setField(jwtConfig, "refreshTokenExpiration", 604_800_000L);
|
||||
jwtConfig.init();
|
||||
|
||||
assertTrue(Files.exists(privPath));
|
||||
assertTrue(Files.exists(pubPath));
|
||||
|
||||
User user = User.builder().id(UUID.randomUUID()).email("saved@test.com").build();
|
||||
String token = jwtConfig.generateAccessToken(user);
|
||||
assertDoesNotThrow(() -> jwtConfig.validateToken(token));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getExpirationReturnsConfiguredValues() throws Exception {
|
||||
JwtConfig jwtConfig = new JwtConfig();
|
||||
setField(jwtConfig, "accessTokenExpiration", 300_000L);
|
||||
setField(jwtConfig, "refreshTokenExpiration", 1_800_000L);
|
||||
|
||||
assertEquals(300_000L, jwtConfig.getAccessTokenExpiration());
|
||||
assertEquals(1_800_000L, jwtConfig.getRefreshTokenExpiration());
|
||||
}
|
||||
|
||||
private JwtConfig createWithExpiration(long access, long refresh) {
|
||||
JwtConfig jwtConfig = new JwtConfig();
|
||||
try {
|
||||
setField(jwtConfig, "accessTokenExpiration", access);
|
||||
setField(jwtConfig, "refreshTokenExpiration", refresh);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return jwtConfig;
|
||||
}
|
||||
|
||||
private void setField(Object target, String name, Object value) throws Exception {
|
||||
Field field = target.getClass().getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class MapToJsonConverterTest {
|
||||
|
||||
private final MapToJsonConverter converter = new MapToJsonConverter();
|
||||
|
||||
@Test
|
||||
void convertToDatabaseColumnSerializesMap() {
|
||||
Map<String, String> map = Map.of("key1", "value1", "key2", "value2");
|
||||
|
||||
String json = converter.convertToDatabaseColumn(map);
|
||||
|
||||
assertNotNull(json);
|
||||
assertTrue(json.contains("key1"));
|
||||
assertTrue(json.contains("value1"));
|
||||
assertTrue(json.contains("key2"));
|
||||
assertTrue(json.contains("value2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertToEntityAttributeDeserializesJson() {
|
||||
String json = "{\"a\":\"1\",\"b\":\"2\"}";
|
||||
|
||||
Map<String, String> result = converter.convertToEntityAttribute(json);
|
||||
|
||||
assertEquals(2, result.size());
|
||||
assertEquals("1", result.get("a"));
|
||||
assertEquals("2", result.get("b"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundtripPreservesMap() {
|
||||
Map<String, String> original = Map.of("alpha", "beta", "gamma", "delta");
|
||||
|
||||
String json = converter.convertToDatabaseColumn(original);
|
||||
Map<String, String> result = converter.convertToEntityAttribute(json);
|
||||
|
||||
assertEquals(original, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertToDatabaseColumnReturnsNullForNullInput() {
|
||||
assertNull(converter.convertToDatabaseColumn(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertToDatabaseColumnReturnsNullForEmptyMap() {
|
||||
assertNull(converter.convertToDatabaseColumn(Map.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertToEntityAttributeReturnsEmptyMapForNullInput() {
|
||||
assertEquals(Map.of(), converter.convertToEntityAttribute(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertToEntityAttributeReturnsEmptyMapForBlankInput() {
|
||||
assertEquals(Map.of(), converter.convertToEntityAttribute(""));
|
||||
assertEquals(Map.of(), converter.convertToEntityAttribute(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertToEntityAttributeThrowsOnMalformedJson() {
|
||||
assertThrows(RuntimeException.class, () ->
|
||||
converter.convertToEntityAttribute("{invalid json}"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.SQLException;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
class SchemaMigrationTest {
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
|
||||
@Test
|
||||
void migrateExecutesWithoutError() {
|
||||
SchemaMigration migration = new SchemaMigration(jdbcTemplate);
|
||||
assertDoesNotThrow(() -> migration.migrate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void migrateIsIdempotent() {
|
||||
SchemaMigration migration = new SchemaMigration(jdbcTemplate);
|
||||
migration.migrate();
|
||||
assertDoesNotThrow(() -> migration.migrate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sslColumnsExistOnSelfHostedConfigs() throws SQLException {
|
||||
SchemaMigration migration = new SchemaMigration(jdbcTemplate);
|
||||
migration.migrate();
|
||||
|
||||
assertTrue(columnExists("self_hosted_configs", "ssl_enabled"));
|
||||
assertTrue(columnExists("self_hosted_configs", "ssl_email"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptedSshKeyColumnIsDropped() throws SQLException {
|
||||
SchemaMigration migration = new SchemaMigration(jdbcTemplate);
|
||||
migration.migrate();
|
||||
|
||||
assertFalse(columnExists("self_hosted_configs", "encrypted_ssh_key"));
|
||||
}
|
||||
|
||||
private boolean columnExists(String table, String column) throws SQLException {
|
||||
try (var rs = dataSource.getConnection().getMetaData()
|
||||
.getColumns(null, null, table, column)) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
144
backend/src/test/java/com/krrishg/config/SecurityConfigTest.java
Normal file
144
backend/src/test/java/com/krrishg/config/SecurityConfigTest.java
Normal file
@@ -0,0 +1,144 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SecurityConfigTest {
|
||||
|
||||
@Mock
|
||||
private JwtConfig jwtConfig;
|
||||
|
||||
private SecurityConfig securityConfig;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
securityConfig = new SecurityConfig(jwtConfig);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void setsAuthenticationForValidToken() throws Exception {
|
||||
UUID userId = UUID.randomUUID();
|
||||
Claims claims = mock(Claims.class);
|
||||
when(claims.getSubject()).thenReturn(userId.toString());
|
||||
when(claims.get("email", String.class)).thenReturn("test@example.com");
|
||||
when(claims.get("name", String.class)).thenReturn("Test User");
|
||||
when(jwtConfig.validateToken("valid-token")).thenReturn(claims);
|
||||
|
||||
OncePerRequestFilter filter = getJwtFilter();
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getHeader("Authorization")).thenReturn("Bearer valid-token");
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
FilterChain chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
assertInstanceOf(JwtAuthenticationToken.class, auth);
|
||||
UserPrincipal principal = (UserPrincipal) auth.getPrincipal();
|
||||
assertEquals(userId, principal.id());
|
||||
assertEquals("test@example.com", principal.email());
|
||||
assertEquals("Test User", principal.name());
|
||||
verify(chain).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearsContextOnInvalidToken() throws Exception {
|
||||
when(jwtConfig.validateToken(anyString())).thenThrow(new RuntimeException("bad token"));
|
||||
|
||||
OncePerRequestFilter filter = getJwtFilter();
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getHeader("Authorization")).thenReturn("Bearer bad-token");
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
FilterChain chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertNull(SecurityContextHolder.getContext().getAuthentication());
|
||||
verify(chain).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotAuthenticateWhenNoAuthHeader() throws Exception {
|
||||
OncePerRequestFilter filter = getJwtFilter();
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getHeader("Authorization")).thenReturn(null);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
FilterChain chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertNull(SecurityContextHolder.getContext().getAuthentication());
|
||||
verify(chain).doFilter(request, response);
|
||||
verify(jwtConfig, never()).validateToken(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotAuthenticateForNonBearerScheme() throws Exception {
|
||||
OncePerRequestFilter filter = getJwtFilter();
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getHeader("Authorization")).thenReturn("Basic credentials");
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
FilterChain chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertNull(SecurityContextHolder.getContext().getAuthentication());
|
||||
verify(chain).doFilter(request, response);
|
||||
verify(jwtConfig, never()).validateToken(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void filterCallsDoFilterOnChain() throws Exception {
|
||||
OncePerRequestFilter filter = getJwtFilter();
|
||||
var request = mock(HttpServletRequest.class);
|
||||
var response = mock(HttpServletResponse.class);
|
||||
var chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
verify(chain).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void alwaysCallsDoFilterEvenOnException() throws Exception {
|
||||
OncePerRequestFilter filter = getJwtFilter();
|
||||
var request = mock(HttpServletRequest.class);
|
||||
when(request.getHeader("Authorization")).thenReturn("Bearer token");
|
||||
when(jwtConfig.validateToken(anyString())).thenThrow(new RuntimeException("fail"));
|
||||
var response = mock(HttpServletResponse.class);
|
||||
var chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
verify(chain).doFilter(request, response);
|
||||
}
|
||||
|
||||
private OncePerRequestFilter getJwtFilter() throws Exception {
|
||||
Method method = SecurityConfig.class.getDeclaredMethod("jwtAuthenticationFilter");
|
||||
method.setAccessible(true);
|
||||
return (OncePerRequestFilter) method.invoke(securityConfig);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class StorageConfigTest {
|
||||
|
||||
@Test
|
||||
void s3ClientIsCreatedWithConfiguration() throws Exception {
|
||||
StorageConfig config = new StorageConfig();
|
||||
setField(config, "endpoint", "http://localhost:9000");
|
||||
setField(config, "region", "us-east-1");
|
||||
setField(config, "accessKey", "test-access-key");
|
||||
setField(config, "secretKey", "test-secret-key");
|
||||
|
||||
S3Client client = config.s3Client();
|
||||
assertNotNull(client);
|
||||
client.close();
|
||||
}
|
||||
|
||||
private void setField(Object target, String name, Object value) throws Exception {
|
||||
Field field = target.getClass().getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SuggestRateLimitConfigTest {
|
||||
|
||||
private SuggestRateLimitConfig config;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
config = new SuggestRateLimitConfig(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tryConsumeReturnsTrueWithinLimit() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
assertTrue(config.tryConsume(userId));
|
||||
assertTrue(config.tryConsume(userId));
|
||||
assertTrue(config.tryConsume(userId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tryConsumeReturnsFalseWhenExceeded() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
assertTrue(config.tryConsume(userId));
|
||||
assertTrue(config.tryConsume(userId));
|
||||
assertTrue(config.tryConsume(userId));
|
||||
assertFalse(config.tryConsume(userId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentUsersHaveIndependentLimits() {
|
||||
UUID userA = UUID.randomUUID();
|
||||
UUID userB = UUID.randomUUID();
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
assertTrue(config.tryConsume(userA));
|
||||
}
|
||||
assertFalse(config.tryConsume(userA));
|
||||
|
||||
assertTrue(config.tryConsume(userB));
|
||||
assertTrue(config.tryConsume(userB));
|
||||
assertTrue(config.tryConsume(userB));
|
||||
assertFalse(config.tryConsume(userB));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAvailableTokensReturnsRemaining() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
assertEquals(3, config.getAvailableTokens(userId));
|
||||
|
||||
config.tryConsume(userId);
|
||||
assertEquals(2, config.getAvailableTokens(userId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAvailableTokensReturnsZeroWhenExhausted() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
for (int i = 0; i < 3; i++) {
|
||||
config.tryConsume(userId);
|
||||
}
|
||||
assertEquals(0, config.getAvailableTokens(userId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructorWithDefaultRate() {
|
||||
SuggestRateLimitConfig defaultConfig = new SuggestRateLimitConfig(30);
|
||||
UUID userId = UUID.randomUUID();
|
||||
assertEquals(30, defaultConfig.getAvailableTokens(userId));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,11 @@ import com.krrishg.dto.AuthResponse.UserInfo;
|
||||
import com.krrishg.dto.LoginRequest;
|
||||
import com.krrishg.dto.RefreshTokenRequest;
|
||||
import com.krrishg.dto.RegisterRequest;
|
||||
import com.krrishg.model.AuthProvider;
|
||||
import com.krrishg.dto.RegistrationResponse;
|
||||
import com.krrishg.dto.ResendVerificationRequest;
|
||||
import com.krrishg.dto.SetPasswordRequest;
|
||||
import com.krrishg.dto.VerifyEmailRequest;
|
||||
import com.krrishg.dto.VerifyEmailResponse;
|
||||
import com.krrishg.service.AuthService;
|
||||
import com.krrishg.support.TestSecurityConfig;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -21,6 +25,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
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.*;
|
||||
@@ -41,28 +46,18 @@ class AuthControllerTest {
|
||||
}
|
||||
|
||||
@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);
|
||||
void registerReturns201WithMessage() throws Exception {
|
||||
when(authService.register(any(RegisterRequest.class)))
|
||||
.thenReturn(new RegistrationResponse("If this email is available, a verification link has been sent."));
|
||||
|
||||
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"));
|
||||
.andExpect(jsonPath("$.message").value("If this email is available, a verification link has been sent."));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -81,7 +76,7 @@ class AuthControllerTest {
|
||||
.accessToken("at-123")
|
||||
.refreshToken("rt-456")
|
||||
.tokenType("Bearer")
|
||||
.user(new UserInfo(UUID.randomUUID(), "login@example.com", "Login User", null, AuthProvider.LOCAL))
|
||||
.user(new UserInfo(UUID.randomUUID(), "login@example.com", "Login User", null))
|
||||
.build();
|
||||
when(authService.login(any(LoginRequest.class))).thenReturn(response);
|
||||
|
||||
@@ -113,13 +108,29 @@ class AuthControllerTest {
|
||||
.andExpect(jsonPath("$.detail").value("Invalid email or password"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginReturns400OnUnverifiedEmail() throws Exception {
|
||||
when(authService.login(any(LoginRequest.class)))
|
||||
.thenThrow(new IllegalArgumentException("Please verify your email address before logging in"));
|
||||
|
||||
LoginRequest request = new LoginRequest();
|
||||
request.setEmail("unverified@example.com");
|
||||
request.setPassword("password123");
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.detail").value("Please verify your email address before logging in"));
|
||||
}
|
||||
|
||||
@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))
|
||||
.user(new UserInfo(UUID.randomUUID(), "user@example.com", "User", null))
|
||||
.build();
|
||||
when(authService.refreshToken(any(RefreshTokenRequest.class))).thenReturn(response);
|
||||
|
||||
@@ -138,4 +149,95 @@ class AuthControllerTest {
|
||||
mockMvc.perform(post("/api/auth/logout"))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifyEmailReturns200() throws Exception {
|
||||
when(authService.verifyEmail(any(VerifyEmailRequest.class)))
|
||||
.thenReturn(new VerifyEmailResponse("Email verified successfully", "setup-token-123"));
|
||||
|
||||
VerifyEmailRequest request = new VerifyEmailRequest();
|
||||
request.setToken("valid-token");
|
||||
|
||||
mockMvc.perform(post("/api/auth/verify-email")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message").value("Email verified successfully"))
|
||||
.andExpect(jsonPath("$.setupToken").value("setup-token-123"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifyEmailReturns400OnInvalidToken() throws Exception {
|
||||
when(authService.verifyEmail(any(VerifyEmailRequest.class)))
|
||||
.thenThrow(new IllegalArgumentException("Invalid verification token"));
|
||||
|
||||
VerifyEmailRequest request = new VerifyEmailRequest();
|
||||
request.setToken("bad-token");
|
||||
|
||||
mockMvc.perform(post("/api/auth/verify-email")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.detail").value("Invalid verification token"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifyEmailReturns400OnExpiredToken() throws Exception {
|
||||
when(authService.verifyEmail(any(VerifyEmailRequest.class)))
|
||||
.thenThrow(new IllegalArgumentException("Verification token has expired"));
|
||||
|
||||
VerifyEmailRequest request = new VerifyEmailRequest();
|
||||
request.setToken("expired-token");
|
||||
|
||||
mockMvc.perform(post("/api/auth/verify-email")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.detail").value("Verification token has expired"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resendVerificationReturns200() throws Exception {
|
||||
when(authService.resendVerification(any(ResendVerificationRequest.class)))
|
||||
.thenReturn(new RegistrationResponse("If this email is available, a verification link has been sent."));
|
||||
|
||||
ResendVerificationRequest request = new ResendVerificationRequest();
|
||||
request.setEmail("user@example.com");
|
||||
|
||||
mockMvc.perform(post("/api/auth/resend-verification")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message").value("If this email is available, a verification link has been sent."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setPasswordReturns200() throws Exception {
|
||||
SetPasswordRequest request = new SetPasswordRequest();
|
||||
request.setSetupToken("setup-token-123");
|
||||
request.setPassword("new-password");
|
||||
request.setName("Test");
|
||||
|
||||
mockMvc.perform(post("/api/auth/set-password")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setPasswordReturns400OnInvalidToken() throws Exception {
|
||||
doThrow(new IllegalArgumentException("Invalid setup token"))
|
||||
.when(authService).setPassword(any(SetPasswordRequest.class));
|
||||
|
||||
SetPasswordRequest request = new SetPasswordRequest();
|
||||
request.setSetupToken("bad-token");
|
||||
request.setPassword("new-password");
|
||||
request.setName("Test");
|
||||
|
||||
mockMvc.perform(post("/api/auth/set-password")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.detail").value("Invalid setup token"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.GlobalExceptionHandler;
|
||||
import com.krrishg.config.JwtAuthenticationToken;
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.model.Site;
|
||||
import com.krrishg.repository.SiteRepository;
|
||||
import com.krrishg.service.DeploymentService;
|
||||
import com.krrishg.support.TestSecurityConfig;
|
||||
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.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
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.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@WebMvcTest(DeploymentController.class)
|
||||
@Import({GlobalExceptionHandler.class, TestSecurityConfig.class})
|
||||
class DeploymentControllerTest {
|
||||
|
||||
private final MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private DeploymentService deploymentService;
|
||||
|
||||
@MockBean
|
||||
private SiteRepository siteRepository;
|
||||
|
||||
private UUID userId;
|
||||
private UUID siteId;
|
||||
private UserPrincipal principal;
|
||||
private JwtAuthenticationToken auth;
|
||||
|
||||
DeploymentControllerTest(@Autowired MockMvc mockMvc) {
|
||||
this.mockMvc = mockMvc;
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userId = UUID.randomUUID();
|
||||
siteId = UUID.randomUUID();
|
||||
principal = new UserPrincipal(userId, "user@test.com", "Test User");
|
||||
auth = new JwtAuthenticationToken(principal, "test-token");
|
||||
|
||||
Site site = Site.builder()
|
||||
.id(siteId)
|
||||
.userId(userId)
|
||||
.name("Test Site")
|
||||
.build();
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishReturns200() throws Exception {
|
||||
when(deploymentService.publish(siteId)).thenReturn("https://mysite.example.com");
|
||||
|
||||
mockMvc.perform(post("/api/sites/{id}/publish", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message").value("Site published successfully"))
|
||||
.andExpect(jsonPath("$.url").value("https://mysite.example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishReturns400WhenNoVersion() throws Exception {
|
||||
when(deploymentService.publish(siteId))
|
||||
.thenThrow(new IllegalStateException("No generation versions found for site " + siteId));
|
||||
|
||||
mockMvc.perform(post("/api/sites/{id}/publish", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("No generation versions found for site " + siteId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishReturns400WhenNoRemote() throws Exception {
|
||||
when(deploymentService.publish(siteId))
|
||||
.thenThrow(new IllegalStateException("No git remote configured"));
|
||||
|
||||
mockMvc.perform(post("/api/sites/{id}/publish", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("No git remote configured"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unpublishReturns200() throws Exception {
|
||||
mockMvc.perform(post("/api/sites/{id}/unpublish", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message").value("Site unpublished successfully"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.GlobalExceptionHandler;
|
||||
import com.krrishg.config.JwtAuthenticationToken;
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import com.krrishg.model.GenerationVersion;
|
||||
import com.krrishg.model.Site;
|
||||
import com.krrishg.model.SiteStatus;
|
||||
import com.krrishg.repository.SiteRepository;
|
||||
import com.krrishg.service.ExportService;
|
||||
import com.krrishg.service.LLMService;
|
||||
import com.krrishg.support.TestSecurityConfig;
|
||||
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.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
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.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@WebMvcTest(ExportController.class)
|
||||
@Import({GlobalExceptionHandler.class, TestSecurityConfig.class})
|
||||
class ExportControllerTest {
|
||||
|
||||
private final MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private ExportService exportService;
|
||||
|
||||
@MockBean
|
||||
private LLMService llmService;
|
||||
|
||||
@MockBean
|
||||
private SiteRepository siteRepository;
|
||||
|
||||
private UUID userId;
|
||||
private UUID siteId;
|
||||
private UserPrincipal principal;
|
||||
private JwtAuthenticationToken auth;
|
||||
private Site site;
|
||||
|
||||
ExportControllerTest(@Autowired MockMvc mockMvc) {
|
||||
this.mockMvc = mockMvc;
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userId = UUID.randomUUID();
|
||||
siteId = UUID.randomUUID();
|
||||
principal = new UserPrincipal(userId, "user@test.com", "Test User");
|
||||
auth = new JwtAuthenticationToken(principal, "test-token");
|
||||
|
||||
site = Site.builder()
|
||||
.id(siteId)
|
||||
.userId(userId)
|
||||
.name("My Site")
|
||||
.status(SiteStatus.DRAFT)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportReturnsZipFile() throws Exception {
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
||||
|
||||
GenerationVersion version = GenerationVersion.builder()
|
||||
.id("v1")
|
||||
.siteId(siteId)
|
||||
.versionNumber(1)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
when(llmService.getVersions(siteId)).thenReturn(List.of(version));
|
||||
when(llmService.restoreVersion("v1")).thenReturn(SiteStructure.builder().build());
|
||||
when(exportService.exportSite(any(SiteStructure.class))).thenReturn("fake-zip-content".getBytes());
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}/export", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("Content-Disposition", "attachment; filename=\"my-site-export.zip\""))
|
||||
.andExpect(header().string("Content-Type", "application/octet-stream"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportReturns400WhenNoVersions() throws Exception {
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
||||
when(llmService.getVersions(siteId)).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}/export", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportReturns500OnExportError() throws Exception {
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
||||
|
||||
GenerationVersion version = GenerationVersion.builder()
|
||||
.id("v1")
|
||||
.siteId(siteId)
|
||||
.versionNumber(1)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
when(llmService.getVersions(siteId)).thenReturn(List.of(version));
|
||||
when(llmService.restoreVersion("v1")).thenReturn(SiteStructure.builder().build());
|
||||
when(exportService.exportSite(any(SiteStructure.class)))
|
||||
.thenThrow(new RuntimeException("Export failed"));
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}/export", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isInternalServerError());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportSanitizesFilenameWithSpecialCharacters() throws Exception {
|
||||
Site siteWithSpecialName = Site.builder()
|
||||
.id(siteId)
|
||||
.userId(userId)
|
||||
.name("My Cool Site!")
|
||||
.status(SiteStatus.DRAFT)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(siteWithSpecialName));
|
||||
|
||||
GenerationVersion version = GenerationVersion.builder()
|
||||
.id("v1")
|
||||
.siteId(siteId)
|
||||
.versionNumber(1)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
when(llmService.getVersions(siteId)).thenReturn(List.of(version));
|
||||
when(llmService.restoreVersion("v1")).thenReturn(SiteStructure.builder().build());
|
||||
when(exportService.exportSite(any(SiteStructure.class))).thenReturn("zip".getBytes());
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}/export", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("Content-Disposition",
|
||||
"attachment; filename=\"my-cool-site-export.zip\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.GlobalExceptionHandler;
|
||||
import com.krrishg.config.JwtAuthenticationToken;
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.model.GitRemote;
|
||||
import com.krrishg.repository.GitRemoteRepository;
|
||||
import com.krrishg.service.GitService;
|
||||
import com.krrishg.service.KeyManager;
|
||||
import com.krrishg.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.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
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(GitController.class)
|
||||
@Import({GlobalExceptionHandler.class, TestSecurityConfig.class})
|
||||
class GitControllerTest {
|
||||
|
||||
private final MockMvc mockMvc;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@MockBean
|
||||
private GitService gitService;
|
||||
|
||||
@MockBean
|
||||
private GitRemoteRepository gitRemoteRepository;
|
||||
|
||||
@MockBean
|
||||
private KeyManager keyManager;
|
||||
|
||||
private UUID userId;
|
||||
private UUID siteId;
|
||||
private UserPrincipal principal;
|
||||
private JwtAuthenticationToken auth;
|
||||
|
||||
GitControllerTest(@Autowired MockMvc mockMvc, @Autowired ObjectMapper objectMapper) {
|
||||
this.mockMvc = mockMvc;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@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 connectRemoteReturns200() throws Exception {
|
||||
when(gitRemoteRepository.findBySiteId(siteId)).thenReturn(Optional.empty());
|
||||
when(keyManager.getPublicKey(siteId.toString())).thenReturn("ssh-ed25519 AAA...");
|
||||
|
||||
Map<String, String> request = Map.of(
|
||||
"provider", "GITHUB",
|
||||
"remoteUrl", "https://github.com/user/repo.git",
|
||||
"token", "ghp_token"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/sites/{id}/git/connect", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message").value("Remote connected successfully"))
|
||||
.andExpect(jsonPath("$.provider").value("GITHUB"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectRemoteReturns400WhenFieldsMissing() throws Exception {
|
||||
Map<String, String> request = Map.of("provider", "GITHUB");
|
||||
|
||||
mockMvc.perform(post("/api/sites/{id}/git/connect", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectRemoteReturns400WhenInvalidProvider() throws Exception {
|
||||
Map<String, String> request = Map.of(
|
||||
"provider", "INVALID",
|
||||
"remoteUrl", "https://github.com/user/repo.git",
|
||||
"token", "token"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/sites/{id}/git/connect", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("Invalid provider. Must be GITHUB or GITLAB"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectRemoteReplacesExistingRemote() throws Exception {
|
||||
GitRemote existing = GitRemote.builder()
|
||||
.siteId(siteId)
|
||||
.provider(GitRemote.GitProvider.GITLAB)
|
||||
.remoteUrl("https://gitlab.com/old/repo.git")
|
||||
.encryptedToken("old-token")
|
||||
.build();
|
||||
when(gitRemoteRepository.findBySiteId(siteId)).thenReturn(Optional.of(existing));
|
||||
when(keyManager.getPublicKey(siteId.toString())).thenReturn("ssh-ed25519 AAA...");
|
||||
|
||||
Map<String, String> request = Map.of(
|
||||
"provider", "GITHUB",
|
||||
"remoteUrl", "https://github.com/user/repo.git",
|
||||
"token", "ghp_token"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/sites/{id}/git/connect", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(gitRemoteRepository).delete(existing);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disconnectRemoteReturns200() throws Exception {
|
||||
mockMvc.perform(post("/api/sites/{id}/git/disconnect", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message").value("Remote disconnected"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getStatusReturnsConnectedInfo() throws Exception {
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.siteId(siteId)
|
||||
.provider(GitRemote.GitProvider.GITHUB)
|
||||
.remoteUrl("https://github.com/user/repo.git")
|
||||
.encryptedToken("token")
|
||||
.build();
|
||||
when(gitRemoteRepository.findBySiteId(siteId)).thenReturn(Optional.of(remote));
|
||||
when(keyManager.hasKeyPair(siteId.toString())).thenReturn(true);
|
||||
when(gitService.getLog(eq(siteId.toString()), eq(10))).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}/git/status", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.connected").value(true))
|
||||
.andExpect(jsonPath("$.provider").value("GITHUB"))
|
||||
.andExpect(jsonPath("$.remoteUrl").value("https://github.com/user/repo.git"))
|
||||
.andExpect(jsonPath("$.hasKeys").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getStatusReturnsDisconnectedState() throws Exception {
|
||||
when(gitRemoteRepository.findBySiteId(siteId)).thenReturn(Optional.empty());
|
||||
when(keyManager.hasKeyPair(siteId.toString())).thenReturn(false);
|
||||
when(gitService.getLog(eq(siteId.toString()), eq(10))).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}/git/status", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.connected").value(false))
|
||||
.andExpect(jsonPath("$.hasKeys").value(false));
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@ package com.krrishg.controller;
|
||||
import com.krrishg.config.GlobalExceptionHandler;
|
||||
import com.krrishg.config.JwtAuthenticationToken;
|
||||
import com.krrishg.config.RateLimitConfig;
|
||||
import com.krrishg.config.SuggestRateLimitConfig;
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.dto.Reference;
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import com.krrishg.dto.SuggestSectionsResponse;
|
||||
import com.krrishg.model.GenerationVersion;
|
||||
import com.krrishg.service.LLMService;
|
||||
import com.krrishg.support.TestSecurityConfig;
|
||||
@@ -45,6 +47,9 @@ class LLMControllerTest {
|
||||
@MockBean
|
||||
private RateLimitConfig rateLimitConfig;
|
||||
|
||||
@MockBean
|
||||
private SuggestRateLimitConfig suggestRateLimitConfig;
|
||||
|
||||
private UUID userId;
|
||||
private UserPrincipal principal;
|
||||
private JwtAuthenticationToken auth;
|
||||
@@ -76,11 +81,12 @@ class LLMControllerTest {
|
||||
.build()
|
||||
))
|
||||
.build();
|
||||
when(llmService.generateSite(any(), any(), anyString(), any())).thenReturn(structure);
|
||||
when(llmService.generateSite(any(), any(), anyString(), any(), anyString(), any(), any(), any())).thenReturn(structure);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site",
|
||||
"siteId", UUID.randomUUID().toString()
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
@@ -103,11 +109,12 @@ class LLMControllerTest {
|
||||
.build()
|
||||
))
|
||||
.build();
|
||||
when(llmService.generateSite(any(), any(), anyString(), any())).thenReturn(structure);
|
||||
when(llmService.generateSite(any(), any(), anyString(), any(), anyString(), any(), any(), any())).thenReturn(structure);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini",
|
||||
"references", List.of(
|
||||
Map.of("type", "IMAGE", "url", "data:img", "altText", "Photo")
|
||||
)
|
||||
@@ -120,6 +127,34 @@ class LLMControllerTest {
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns200WithPerRequestApiKey() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
SiteStructure structure = SiteStructure.builder()
|
||||
.pages(List.of(
|
||||
SiteStructure.PageStructure.builder()
|
||||
.title("Home").slug("home")
|
||||
.rawHtml("<h1>Home</h1>")
|
||||
.build()
|
||||
))
|
||||
.build();
|
||||
when(llmService.generateSite(any(), any(), anyString(), any(), eq("gemini"), eq("custom-key"), any(), any())).thenReturn(structure);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini",
|
||||
"apiKey", "custom-key"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns429WhenRateLimited() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(false);
|
||||
@@ -127,7 +162,8 @@ class LLMControllerTest {
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site",
|
||||
"siteId", UUID.randomUUID().toString()
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
@@ -142,6 +178,23 @@ class LLMControllerTest {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenProviderMissing() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site",
|
||||
"siteId", UUID.randomUUID().toString()
|
||||
);
|
||||
|
||||
@@ -153,43 +206,32 @@ class LLMControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenPromptBlank() throws Exception {
|
||||
void returns400WhenNoKeyConfigured() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
when(llmService.generateSite(any(), any(), anyString(), any(), anyString(), any(), any(), any()))
|
||||
.thenThrow(new IllegalStateException("No API key configured for gemini"));
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", " ",
|
||||
"siteId", UUID.randomUUID().toString()
|
||||
"prompt", "Build a site",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("No API key configured for gemini"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenSiteIdMissing() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenSiteIdBlank() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site",
|
||||
"siteId", ""
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
@@ -223,11 +265,12 @@ class LLMControllerTest {
|
||||
.build()
|
||||
))
|
||||
.build();
|
||||
when(llmService.refineSite(any(), any(), any(), anyString(), any())).thenReturn(refined);
|
||||
when(llmService.refineSite(any(), any(), any(), anyString(), any(), anyString(), any(), any(), any())).thenReturn(refined);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Make it better",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini",
|
||||
"currentStructure", current
|
||||
);
|
||||
|
||||
@@ -247,6 +290,7 @@ class LLMControllerTest {
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Refine",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini",
|
||||
"currentStructure", Map.of()
|
||||
);
|
||||
|
||||
@@ -257,12 +301,30 @@ class LLMControllerTest {
|
||||
.andExpect(status().isTooManyRequests());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenProviderMissing() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Refine",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"currentStructure", SiteStructure.builder().build()
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/refine")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenPromptMissing() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini",
|
||||
"currentStructure", SiteStructure.builder().build()
|
||||
);
|
||||
|
||||
@@ -279,6 +341,7 @@ class LLMControllerTest {
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Refine",
|
||||
"provider", "gemini",
|
||||
"currentStructure", SiteStructure.builder().build()
|
||||
);
|
||||
|
||||
@@ -290,12 +353,14 @@ class LLMControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenCurrentStructureMissing() throws Exception {
|
||||
void returns400WhenCurrentStructureInvalid() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Refine",
|
||||
"siteId", UUID.randomUUID().toString()
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini",
|
||||
"currentStructure", "not-a-valid-structure"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/refine")
|
||||
@@ -304,6 +369,119 @@ class LLMControllerTest {
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenCurrentStructureNull() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Refine",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/refine")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class SuggestSections {
|
||||
|
||||
@Test
|
||||
void returns200WithSuggestions() throws Exception {
|
||||
when(suggestRateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
SuggestSectionsResponse response = new SuggestSectionsResponse(
|
||||
List.of(
|
||||
new SuggestSectionsResponse.SectionSuggestion("Home", "Hero section", "home", List.of()),
|
||||
new SuggestSectionsResponse.SectionSuggestion("About", "Bio", "about", List.of())
|
||||
)
|
||||
);
|
||||
when(llmService.suggestSections(any(), anyString(), anyString(), any(), any(), any())).thenReturn(response);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"briefDescription", "photography portfolio",
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/suggest-sections")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.sections[0].title").value("Home"))
|
||||
.andExpect(jsonPath("$.sections[1].slug").value("about"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns429WhenRateLimited() throws Exception {
|
||||
when(suggestRateLimitConfig.tryConsume(userId)).thenReturn(false);
|
||||
when(suggestRateLimitConfig.getAvailableTokens(userId)).thenReturn(0);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"briefDescription", "portfolio",
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/suggest-sections")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isTooManyRequests());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenBriefDescriptionMissing() throws Exception {
|
||||
when(suggestRateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/suggest-sections")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenProviderMissing() throws Exception {
|
||||
when(suggestRateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"briefDescription", "portfolio"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/suggest-sections")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenNoKeyConfigured() throws Exception {
|
||||
when(suggestRateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
when(llmService.suggestSections(any(), anyString(), anyString(), any(), any(), any()))
|
||||
.thenThrow(new IllegalStateException("No API key configured for gemini"));
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"briefDescription", "portfolio",
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/suggest-sections")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("No API key configured for gemini"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -391,4 +569,45 @@ class LLMControllerTest {
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class PruneVersions {
|
||||
|
||||
@Test
|
||||
void returns200WithDefaultKeep() throws Exception {
|
||||
when(llmService.pruneVersions(any(UUID.class), eq(10))).thenReturn(3);
|
||||
|
||||
UUID siteId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/llm/versions/{siteId}/prune", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.deleted").value(3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns200WithCustomKeep() throws Exception {
|
||||
when(llmService.pruneVersions(any(UUID.class), eq(5))).thenReturn(2);
|
||||
|
||||
UUID siteId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/llm/versions/{siteId}/prune", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(Map.of("keep", 5))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.deleted").value(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns200WithNoRequestBody() throws Exception {
|
||||
when(llmService.pruneVersions(any(UUID.class), eq(10))).thenReturn(0);
|
||||
|
||||
UUID siteId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/llm/versions/{siteId}/prune", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.deleted").value(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.GlobalExceptionHandler;
|
||||
import com.krrishg.config.JwtAuthenticationToken;
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.service.MediaService;
|
||||
import com.krrishg.support.TestSecurityConfig;
|
||||
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.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
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.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@WebMvcTest(MediaController.class)
|
||||
@Import({GlobalExceptionHandler.class, TestSecurityConfig.class})
|
||||
class MediaControllerTest {
|
||||
|
||||
private final MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private MediaService mediaService;
|
||||
|
||||
private UUID userId;
|
||||
private UUID siteId;
|
||||
private UserPrincipal principal;
|
||||
private JwtAuthenticationToken auth;
|
||||
|
||||
MediaControllerTest(@Autowired MockMvc mockMvc) {
|
||||
this.mockMvc = mockMvc;
|
||||
}
|
||||
|
||||
@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 uploadReturns200() throws Exception {
|
||||
MediaService.MediaUploadResult result = new MediaService.MediaUploadResult(
|
||||
"media/" + siteId + "/abc123.jpg",
|
||||
"https://cdn.example.com/media/" + siteId + "/abc123.jpg",
|
||||
"photo.jpg",
|
||||
"image/jpeg",
|
||||
1024L
|
||||
);
|
||||
when(mediaService.upload(any(), any())).thenReturn(result);
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "photo.jpg", "image/jpeg", "test-image-content".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/media/upload")
|
||||
.file(file)
|
||||
.param("siteId", siteId.toString())
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.key").value("media/" + siteId + "/abc123.jpg"))
|
||||
.andExpect(jsonPath("$.url").value("https://cdn.example.com/media/" + siteId + "/abc123.jpg"))
|
||||
.andExpect(jsonPath("$.originalFilename").value("photo.jpg"))
|
||||
.andExpect(jsonPath("$.contentType").value("image/jpeg"))
|
||||
.andExpect(jsonPath("$.size").value(1024));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadReturns400WhenInvalidFile() throws Exception {
|
||||
when(mediaService.upload(any(), any()))
|
||||
.thenThrow(new IllegalArgumentException("File type not allowed"));
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "bad.exe", "application/x-msdownload", "bad".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/media/upload")
|
||||
.file(file)
|
||||
.param("siteId", siteId.toString())
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("File type not allowed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getMediaUrlReturns200() throws Exception {
|
||||
String key = "abc.jpg";
|
||||
when(mediaService.getPublicUrl(key)).thenReturn("https://cdn.example.com/media/" + key);
|
||||
|
||||
mockMvc.perform(get("/api/media/{key}", key)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.key").value(key))
|
||||
.andExpect(jsonPath("$.url").value("https://cdn.example.com/media/abc.jpg"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.GlobalExceptionHandler;
|
||||
import com.krrishg.config.JwtAuthenticationToken;
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.dto.SelfHostedConfigRequest;
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import com.krrishg.model.Site;
|
||||
import com.krrishg.repository.SelfHostedConfigRepository;
|
||||
import com.krrishg.repository.SiteRepository;
|
||||
import com.krrishg.service.Deployer;
|
||||
import com.krrishg.service.KeyManager;
|
||||
import com.krrishg.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.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
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(SelfHostedController.class)
|
||||
@Import({GlobalExceptionHandler.class, TestSecurityConfig.class})
|
||||
class SelfHostedControllerTest {
|
||||
|
||||
private final MockMvc mockMvc;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@MockBean
|
||||
private SiteRepository siteRepository;
|
||||
|
||||
@MockBean
|
||||
private SelfHostedConfigRepository selfHostedConfigRepository;
|
||||
|
||||
@MockBean
|
||||
private Deployer deployer;
|
||||
|
||||
@MockBean
|
||||
private KeyManager keyManager;
|
||||
|
||||
private UUID userId;
|
||||
private UUID siteId;
|
||||
private UserPrincipal principal;
|
||||
private JwtAuthenticationToken auth;
|
||||
|
||||
SelfHostedControllerTest(@Autowired MockMvc mockMvc, @Autowired ObjectMapper objectMapper) {
|
||||
this.mockMvc = mockMvc;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
siteId = UUID.randomUUID();
|
||||
userId = UUID.randomUUID();
|
||||
principal = new UserPrincipal(userId, "user@test.com", "Test User");
|
||||
auth = new JwtAuthenticationToken(principal, "test-token");
|
||||
}
|
||||
|
||||
void givenSaveReturnsInput() {
|
||||
when(selfHostedConfigRepository.save(any())).thenAnswer(invocation -> {
|
||||
SelfHostedConfig input = invocation.getArgument(0);
|
||||
if (input.getId() == null) {
|
||||
input.setId(UUID.randomUUID());
|
||||
}
|
||||
if (input.getCreatedAt() == null) {
|
||||
input.setCreatedAt(LocalDateTime.now());
|
||||
input.setUpdatedAt(LocalDateTime.now());
|
||||
}
|
||||
return input;
|
||||
});
|
||||
}
|
||||
|
||||
private Site givenSiteOwnedByUser() {
|
||||
Site site = Site.builder()
|
||||
.id(siteId)
|
||||
.userId(userId)
|
||||
.name("Test Site")
|
||||
.build();
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
||||
return site;
|
||||
}
|
||||
|
||||
@Test
|
||||
void getConfigReturnsConfigWithPublicKey() throws Exception {
|
||||
givenSiteOwnedByUser();
|
||||
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.siteId(siteId)
|
||||
.domain("example.com")
|
||||
.sshHost("192.168.1.1")
|
||||
.sshPort(22)
|
||||
.sshUser("deploy")
|
||||
.deployPath("/var/www/example/public")
|
||||
.nginxSitesPath("/etc/nginx/sites-available")
|
||||
.sslEnabled(true)
|
||||
.sslEmail("admin@example.com")
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
when(selfHostedConfigRepository.findBySiteId(siteId)).thenReturn(Optional.of(config));
|
||||
when(keyManager.getPublicKey(siteId.toString())).thenReturn("ssh-ed25519 AAA...");
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}/self-hosted", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.domain").value("example.com"))
|
||||
.andExpect(jsonPath("$.sshHost").value("192.168.1.1"))
|
||||
.andExpect(jsonPath("$.publicKey").value("ssh-ed25519 AAA..."))
|
||||
.andExpect(jsonPath("$.sslEnabled").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getConfigReturnsNotConfiguredWhenNoConfig() throws Exception {
|
||||
givenSiteOwnedByUser();
|
||||
when(selfHostedConfigRepository.findBySiteId(siteId)).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}/self-hosted", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.configured").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void putSaveConfigCreatesWithDefaults() throws Exception {
|
||||
givenSiteOwnedByUser();
|
||||
givenSaveReturnsInput();
|
||||
when(selfHostedConfigRepository.findBySiteId(siteId)).thenReturn(Optional.empty());
|
||||
when(keyManager.getPublicKey(siteId.toString())).thenReturn("ssh-ed25519 BBB...");
|
||||
|
||||
SelfHostedConfigRequest request = new SelfHostedConfigRequest();
|
||||
request.setDomain("mysite.com");
|
||||
request.setSshHost("10.0.0.1");
|
||||
request.setSshPort(2222);
|
||||
request.setSshUser("admin");
|
||||
request.setSslEnabled(true);
|
||||
request.setSslEmail("ssl@mysite.com");
|
||||
|
||||
mockMvc.perform(put("/api/sites/{id}/self-hosted", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.domain").value("mysite.com"))
|
||||
.andExpect(jsonPath("$.publicKey").value("ssh-ed25519 BBB..."));
|
||||
|
||||
verify(selfHostedConfigRepository).save(any(SelfHostedConfig.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void putSaveConfigUsesDefaultPathsWhenNotProvided() throws Exception {
|
||||
givenSiteOwnedByUser();
|
||||
givenSaveReturnsInput();
|
||||
when(selfHostedConfigRepository.findBySiteId(siteId)).thenReturn(Optional.empty());
|
||||
when(keyManager.getPublicKey(siteId.toString())).thenReturn("key");
|
||||
|
||||
SelfHostedConfigRequest request = new SelfHostedConfigRequest();
|
||||
request.setDomain("site.com");
|
||||
request.setSshHost("host");
|
||||
request.setSshPort(22);
|
||||
request.setSshUser("user");
|
||||
|
||||
mockMvc.perform(put("/api/sites/{id}/self-hosted", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(selfHostedConfigRepository).save(argThat(config ->
|
||||
"/var/www/{domain}/public".equals(config.getDeployPath()) &&
|
||||
"/etc/nginx/sites-available".equals(config.getNginxSitesPath())
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void putReturns400OnValidationError() throws Exception {
|
||||
SelfHostedConfigRequest request = new SelfHostedConfigRequest();
|
||||
|
||||
mockMvc.perform(put("/api/sites/{id}/self-hosted", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteRemovesConfigAndCallsDeployer() throws Exception {
|
||||
givenSiteOwnedByUser();
|
||||
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.siteId(siteId)
|
||||
.domain("example.com")
|
||||
.sshHost("host")
|
||||
.sshPort(22)
|
||||
.sshUser("user")
|
||||
.build();
|
||||
when(selfHostedConfigRepository.findBySiteId(siteId)).thenReturn(Optional.of(config));
|
||||
|
||||
mockMvc.perform(delete("/api/sites/{id}/self-hosted", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message").value("Self-hosted configuration removed"));
|
||||
|
||||
verify(deployer).remove(config);
|
||||
verify(selfHostedConfigRepository).delete(config);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteSwallowsDeployerException() throws Exception {
|
||||
givenSiteOwnedByUser();
|
||||
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.siteId(siteId)
|
||||
.domain("example.com")
|
||||
.sshHost("host")
|
||||
.sshPort(22)
|
||||
.sshUser("user")
|
||||
.build();
|
||||
when(selfHostedConfigRepository.findBySiteId(siteId)).thenReturn(Optional.of(config));
|
||||
doThrow(new RuntimeException("SSH error")).when(deployer).remove(config);
|
||||
|
||||
mockMvc.perform(delete("/api/sites/{id}/self-hosted", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(selfHostedConfigRepository).delete(config);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteHandlesNoConfig() throws Exception {
|
||||
givenSiteOwnedByUser();
|
||||
when(selfHostedConfigRepository.findBySiteId(siteId)).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(delete("/api/sites/{id}/self-hosted", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(deployer, never()).remove(any());
|
||||
verify(selfHostedConfigRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getConfigReturns400WhenSiteNotFound() throws Exception {
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}/self-hosted", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getConfigReturns400ForWrongOwner() throws Exception {
|
||||
Site site = Site.builder()
|
||||
.id(siteId)
|
||||
.userId(UUID.randomUUID())
|
||||
.name("Test Site")
|
||||
.build();
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}/self-hosted", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
@@ -80,41 +80,22 @@ class SiteControllerTest {
|
||||
.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());
|
||||
.andExpect(jsonPath("$.name").value("New Site"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -173,23 +154,19 @@ class SiteControllerTest {
|
||||
.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"));
|
||||
.andExpect(jsonPath("$.name").value("New Name"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.EncryptionService;
|
||||
import com.krrishg.config.GlobalExceptionHandler;
|
||||
import com.krrishg.config.JwtAuthenticationToken;
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.model.User;
|
||||
import com.krrishg.repository.UserRepository;
|
||||
import com.krrishg.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.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
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(UserSettingsController.class)
|
||||
@Import({GlobalExceptionHandler.class, TestSecurityConfig.class})
|
||||
class UserSettingsControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@MockBean
|
||||
private UserRepository userRepository;
|
||||
|
||||
@MockBean
|
||||
private EncryptionService encryptionService;
|
||||
|
||||
private UUID userId;
|
||||
private UserPrincipal principal;
|
||||
private JwtAuthenticationToken auth;
|
||||
private User testUser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userId = UUID.randomUUID();
|
||||
principal = new UserPrincipal(userId, "user@test.com", "Test User");
|
||||
auth = new JwtAuthenticationToken(principal, "test-token");
|
||||
testUser = User.builder()
|
||||
.id(userId)
|
||||
.email("user@test.com")
|
||||
.name("Test User")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLlmConfigReturnsConfigWithAllFields() throws Exception {
|
||||
Map<String, String> keys = new HashMap<>();
|
||||
keys.put("gemini", "encrypted-gemini-key");
|
||||
Map<String, String> urls = new HashMap<>();
|
||||
urls.put("gemini", "https://custom.gemini.com");
|
||||
Map<String, String> models = new HashMap<>();
|
||||
models.put("gemini", "gemini-2.0-flash");
|
||||
testUser.setLlmApiKeys(keys);
|
||||
testUser.setLlmBaseUrls(urls);
|
||||
testUser.setLlmModels(models);
|
||||
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
|
||||
mockMvc.perform(get("/api/user/llm-config")
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.gemini.configured").value(true))
|
||||
.andExpect(jsonPath("$.gemini.baseUrl").value("https://custom.gemini.com"))
|
||||
.andExpect(jsonPath("$.gemini.model").value("gemini-2.0-flash"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLlmConfigReturnsMinimalConfigWhenNoUrlsOrModels() throws Exception {
|
||||
Map<String, String> keys = new HashMap<>();
|
||||
keys.put("openai", "encrypted-openai-key");
|
||||
testUser.setLlmApiKeys(keys);
|
||||
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
|
||||
mockMvc.perform(get("/api/user/llm-config")
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.openai.configured").value(true))
|
||||
.andExpect(jsonPath("$.openai.baseUrl").doesNotExist())
|
||||
.andExpect(jsonPath("$.openai.model").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLlmConfigReturnsEmptyMapWhenNoKeys() throws Exception {
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
|
||||
mockMvc.perform(get("/api/user/llm-config")
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().json("{}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLlmConfigReturns400WhenUserNotFound() throws Exception {
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(get("/api/user/llm-config")
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveLlmConfigSavesApiKey() throws Exception {
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
when(encryptionService.encrypt("sk-test-key")).thenReturn("encrypted-key");
|
||||
|
||||
Map<String, String> request = new HashMap<>();
|
||||
request.put("provider", "openai");
|
||||
request.put("apiKey", "sk-test-key");
|
||||
|
||||
mockMvc.perform(put("/api/user/llm-config")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(userRepository).save(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveLlmConfigSavesBaseUrlAndModel() throws Exception {
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
when(encryptionService.encrypt("key")).thenReturn("encrypted");
|
||||
|
||||
Map<String, String> request = new HashMap<>();
|
||||
request.put("provider", "gemini");
|
||||
request.put("apiKey", "key");
|
||||
request.put("baseUrl", "https://gemini.api.com");
|
||||
request.put("model", "gemini-2.0-flash");
|
||||
|
||||
mockMvc.perform(put("/api/user/llm-config")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(userRepository).save(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveLlmConfigMergesWithExistingConfig() throws Exception {
|
||||
Map<String, String> existingKeys = new HashMap<>();
|
||||
existingKeys.put("gemini", "existing-gemini-key");
|
||||
testUser.setLlmApiKeys(existingKeys);
|
||||
testUser.setLlmBaseUrls(new HashMap<>(Map.of("gemini", "https://gemini.api.com")));
|
||||
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
when(encryptionService.encrypt("new-openai-key")).thenReturn("encrypted-new-key");
|
||||
|
||||
Map<String, String> request = new HashMap<>();
|
||||
request.put("provider", "openai");
|
||||
request.put("apiKey", "new-openai-key");
|
||||
|
||||
mockMvc.perform(put("/api/user/llm-config")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(userRepository).save(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveLlmConfigReturns400WhenProviderIsBlank() throws Exception {
|
||||
Map<String, String> request = new HashMap<>();
|
||||
request.put("provider", "");
|
||||
request.put("apiKey", "key");
|
||||
|
||||
mockMvc.perform(put("/api/user/llm-config")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveLlmConfigReturns400WhenProviderIsNull() throws Exception {
|
||||
Map<String, String> request = new HashMap<>();
|
||||
request.put("apiKey", "key");
|
||||
|
||||
mockMvc.perform(put("/api/user/llm-config")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveLlmConfigSkipsEmptyApiKey() throws Exception {
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
|
||||
Map<String, String> request = new HashMap<>();
|
||||
request.put("provider", "openai");
|
||||
request.put("apiKey", "");
|
||||
|
||||
mockMvc.perform(put("/api/user/llm-config")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
assertNull(testUser.getLlmApiKeys());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteLlmConfigRemovesProviderConfig() throws Exception {
|
||||
Map<String, String> keys = new HashMap<>();
|
||||
keys.put("gemini", "encrypted-key");
|
||||
keys.put("openai", "encrypted-key");
|
||||
testUser.setLlmApiKeys(keys);
|
||||
testUser.setLlmBaseUrls(new HashMap<>(Map.of("gemini", "url")));
|
||||
testUser.setLlmModels(new HashMap<>(Map.of("gemini", "model")));
|
||||
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
|
||||
mockMvc.perform(delete("/api/user/llm-config/gemini")
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(userRepository).save(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteLlmConfigReturns400WhenUserNotFound() throws Exception {
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(delete("/api/user/llm-config/openai")
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
58
backend/src/test/java/com/krrishg/dto/AuthResponseTest.java
Normal file
58
backend/src/test/java/com/krrishg/dto/AuthResponseTest.java
Normal file
@@ -0,0 +1,58 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AuthResponseTest {
|
||||
|
||||
@Test
|
||||
void constructorSetsBearerTokenType() {
|
||||
AuthResponse.UserInfo userInfo = new AuthResponse.UserInfo(
|
||||
UUID.randomUUID(), "test@example.com", "Test", null);
|
||||
|
||||
AuthResponse response = new AuthResponse("access-token", "refresh-token", userInfo);
|
||||
|
||||
assertEquals("access-token", response.getAccessToken());
|
||||
assertEquals("refresh-token", response.getRefreshToken());
|
||||
assertEquals("Bearer", response.getTokenType());
|
||||
assertSame(userInfo, response.getUser());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builderWorksWithTokenType() {
|
||||
AuthResponse.UserInfo userInfo = AuthResponse.UserInfo.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.email("user@example.com")
|
||||
.name("User")
|
||||
.build();
|
||||
|
||||
AuthResponse response = AuthResponse.builder()
|
||||
.accessToken("at")
|
||||
.refreshToken("rt")
|
||||
.tokenType("Custom")
|
||||
.user(userInfo)
|
||||
.build();
|
||||
|
||||
assertEquals("Custom", response.getTokenType());
|
||||
assertEquals("user@example.com", response.getUser().getEmail());
|
||||
}
|
||||
|
||||
@Test
|
||||
void userInfoBuilderSetsAllFields() {
|
||||
UUID id = UUID.randomUUID();
|
||||
AuthResponse.UserInfo userInfo = AuthResponse.UserInfo.builder()
|
||||
.id(id)
|
||||
.email("avatar@example.com")
|
||||
.name("Avatar User")
|
||||
.avatarUrl("https://example.com/avatar.png")
|
||||
.build();
|
||||
|
||||
assertEquals(id, userInfo.getId());
|
||||
assertEquals("avatar@example.com", userInfo.getEmail());
|
||||
assertEquals("Avatar User", userInfo.getName());
|
||||
assertEquals("https://example.com/avatar.png", userInfo.getAvatarUrl());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class LLMConfigRequestTest {
|
||||
|
||||
@Test
|
||||
void compactConstructorSetsProviderAndApiKey() {
|
||||
LLMConfigRequest request = new LLMConfigRequest("gemini", "test-key");
|
||||
|
||||
assertEquals("gemini", request.provider());
|
||||
assertEquals("test-key", request.apiKey());
|
||||
assertNull(request.baseUrl());
|
||||
assertNull(request.model());
|
||||
}
|
||||
|
||||
@Test
|
||||
void threeArgConstructorSetsProviderApiKeyAndBaseUrl() {
|
||||
LLMConfigRequest request = new LLMConfigRequest("openai", "sk-test", "https://api.openai.com");
|
||||
|
||||
assertEquals("openai", request.provider());
|
||||
assertEquals("sk-test", request.apiKey());
|
||||
assertEquals("https://api.openai.com", request.baseUrl());
|
||||
assertNull(request.model());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullConstructorSetsAllFields() {
|
||||
LLMConfigRequest request = new LLMConfigRequest("gemini", "key", "https://custom.url", "gemini-2.0-flash");
|
||||
|
||||
assertEquals("gemini", request.provider());
|
||||
assertEquals("key", request.apiKey());
|
||||
assertEquals("https://custom.url", request.baseUrl());
|
||||
assertEquals("gemini-2.0-flash", request.model());
|
||||
}
|
||||
}
|
||||
30
backend/src/test/java/com/krrishg/dto/LLMOptionsTest.java
Normal file
30
backend/src/test/java/com/krrishg/dto/LLMOptionsTest.java
Normal file
@@ -0,0 +1,30 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class LLMOptionsTest {
|
||||
|
||||
@Test
|
||||
void builderDefaults() {
|
||||
LLMOptions options = LLMOptions.builder().build();
|
||||
|
||||
assertEquals(0.9, options.getTemperature());
|
||||
assertEquals(16384, options.getMaxTokens());
|
||||
assertEquals(60, options.getTimeoutSeconds());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builderOverridesDefaults() {
|
||||
LLMOptions options = LLMOptions.builder()
|
||||
.temperature(0.5)
|
||||
.maxTokens(2048)
|
||||
.timeoutSeconds(30)
|
||||
.build();
|
||||
|
||||
assertEquals(0.5, options.getTemperature());
|
||||
assertEquals(2048, options.getMaxTokens());
|
||||
assertEquals(30, options.getTimeoutSeconds());
|
||||
}
|
||||
}
|
||||
38
backend/src/test/java/com/krrishg/dto/ReferenceTest.java
Normal file
38
backend/src/test/java/com/krrishg/dto/ReferenceTest.java
Normal file
@@ -0,0 +1,38 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ReferenceTest {
|
||||
|
||||
@Test
|
||||
void builderSetsAllFields() {
|
||||
Reference ref = Reference.builder()
|
||||
.type(Reference.ReferenceType.URL)
|
||||
.url("https://example.com")
|
||||
.altText("Example Site")
|
||||
.build();
|
||||
|
||||
assertEquals(Reference.ReferenceType.URL, ref.getType());
|
||||
assertEquals("https://example.com", ref.getUrl());
|
||||
assertEquals("Example Site", ref.getAltText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builderDefaultsToNull() {
|
||||
Reference ref = Reference.builder().build();
|
||||
|
||||
assertNull(ref.getType());
|
||||
assertNull(ref.getUrl());
|
||||
assertNull(ref.getAltText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void referenceTypeEnumValues() {
|
||||
assertArrayEquals(
|
||||
new Reference.ReferenceType[]{Reference.ReferenceType.URL, Reference.ReferenceType.IMAGE},
|
||||
Reference.ReferenceType.values()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SelfHostedConfigResponseTest {
|
||||
|
||||
@Test
|
||||
void fromMapsAllFields() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID siteId = UUID.randomUUID();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.id(id)
|
||||
.siteId(siteId)
|
||||
.domain("example.com")
|
||||
.sshHost("192.168.1.1")
|
||||
.sshPort(2222)
|
||||
.sshUser("deploy")
|
||||
.deployPath("/var/www/html")
|
||||
.nginxSitesPath("/etc/nginx/conf.d")
|
||||
.sslEnabled(true)
|
||||
.sslEmail("admin@example.com")
|
||||
.deployedAt(now)
|
||||
.createdAt(now)
|
||||
.updatedAt(now)
|
||||
.build();
|
||||
|
||||
SelfHostedConfigResponse response = SelfHostedConfigResponse.from(config, "ssh-ed25519 AAA...");
|
||||
|
||||
assertEquals(id, response.getId());
|
||||
assertEquals(siteId, response.getSiteId());
|
||||
assertEquals("example.com", response.getDomain());
|
||||
assertEquals("192.168.1.1", response.getSshHost());
|
||||
assertEquals(2222, response.getSshPort());
|
||||
assertEquals("deploy", response.getSshUser());
|
||||
assertEquals("/var/www/html", response.getDeployPath());
|
||||
assertEquals("/etc/nginx/conf.d", response.getNginxSitesPath());
|
||||
assertTrue(response.isSslEnabled());
|
||||
assertEquals("admin@example.com", response.getSslEmail());
|
||||
assertEquals("ssh-ed25519 AAA...", response.getPublicKey());
|
||||
assertEquals(now, response.getDeployedAt());
|
||||
assertEquals(now, response.getCreatedAt());
|
||||
assertEquals(now, response.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromHandlesNullOptionalFields() {
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.siteId(UUID.randomUUID())
|
||||
.domain("example.com")
|
||||
.sshHost("host")
|
||||
.sshUser("user")
|
||||
.build();
|
||||
|
||||
SelfHostedConfigResponse response = SelfHostedConfigResponse.from(config, null);
|
||||
|
||||
assertNull(response.getSslEmail());
|
||||
assertNull(response.getPublicKey());
|
||||
assertNull(response.getDeployedAt());
|
||||
assertFalse(response.isSslEnabled());
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ class SiteResponseTest {
|
||||
.userId(userId)
|
||||
.name("My Site")
|
||||
.description("A test site")
|
||||
.subdomain("mysite")
|
||||
.status(SiteStatus.PUBLISHED)
|
||||
.publishedAt(now)
|
||||
.createdAt(now)
|
||||
@@ -35,7 +34,6 @@ class SiteResponseTest {
|
||||
assertEquals(userId, response.getUserId());
|
||||
assertEquals("My Site", response.getName());
|
||||
assertEquals("A test site", response.getDescription());
|
||||
assertEquals("mysite", response.getSubdomain());
|
||||
assertEquals(SiteStatus.PUBLISHED, response.getStatus());
|
||||
assertEquals(now, response.getPublishedAt());
|
||||
assertEquals(now, response.getCreatedAt());
|
||||
@@ -54,7 +52,6 @@ class SiteResponseTest {
|
||||
SiteResponse response = SiteResponse.from(site);
|
||||
|
||||
assertNull(response.getDescription());
|
||||
assertNull(response.getSubdomain());
|
||||
assertNull(response.getPublishedAt());
|
||||
}
|
||||
}
|
||||
|
||||
57
backend/src/test/java/com/krrishg/dto/SiteStructureTest.java
Normal file
57
backend/src/test/java/com/krrishg/dto/SiteStructureTest.java
Normal file
@@ -0,0 +1,57 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SiteStructureTest {
|
||||
|
||||
@Test
|
||||
void builderSetsAllFields() {
|
||||
Map<String, String> colors = Map.of("primary", "#000", "secondary", "#fff");
|
||||
Map<String, String> fonts = Map.of("heading", "Arial");
|
||||
Map<String, String> spacing = Map.of("margin", "16px");
|
||||
|
||||
SiteStructure.ThemeConfig theme = new SiteStructure.ThemeConfig(colors, fonts, spacing);
|
||||
|
||||
SiteStructure.PageStructure page = SiteStructure.PageStructure.builder()
|
||||
.id("home")
|
||||
.title("Home")
|
||||
.slug("/")
|
||||
.seoTitle("Home Page")
|
||||
.seoDescription("Welcome")
|
||||
.orderIndex(0)
|
||||
.rawHtml("<h1>Hello</h1>")
|
||||
.build();
|
||||
|
||||
SiteStructure structure = SiteStructure.builder()
|
||||
.theme(theme)
|
||||
.pages(List.of(page))
|
||||
.build();
|
||||
|
||||
assertNotNull(structure.getTheme());
|
||||
assertEquals("#000", structure.getTheme().getColors().get("primary"));
|
||||
assertEquals("Arial", structure.getTheme().getFonts().get("heading"));
|
||||
assertEquals("16px", structure.getTheme().getSpacing().get("margin"));
|
||||
|
||||
assertEquals(1, structure.getPages().size());
|
||||
assertEquals("home", structure.getPages().get(0).getId());
|
||||
assertEquals("Home", structure.getPages().get(0).getTitle());
|
||||
assertEquals("/", structure.getPages().get(0).getSlug());
|
||||
assertEquals("Home Page", structure.getPages().get(0).getSeoTitle());
|
||||
assertEquals("Welcome", structure.getPages().get(0).getSeoDescription());
|
||||
assertEquals(0, structure.getPages().get(0).getOrderIndex());
|
||||
assertEquals("<h1>Hello</h1>", structure.getPages().get(0).getRawHtml());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builderHandlesNullOptionalFields() {
|
||||
SiteStructure structure = SiteStructure.builder().build();
|
||||
|
||||
assertNull(structure.getTheme());
|
||||
assertNull(structure.getPages());
|
||||
}
|
||||
}
|
||||
48
backend/src/test/java/com/krrishg/engine/JsBundleTest.java
Normal file
48
backend/src/test/java/com/krrishg/engine/JsBundleTest.java
Normal file
@@ -0,0 +1,48 @@
|
||||
package com.krrishg.engine;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class JsBundleTest {
|
||||
|
||||
private final JsBundle jsBundle = new JsBundle();
|
||||
|
||||
@Test
|
||||
void compileReturnsNonEmptyJs() {
|
||||
String js = jsBundle.compile();
|
||||
assertNotNull(js);
|
||||
assertFalse(js.isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileContainsDomContentLoaded() {
|
||||
String js = jsBundle.compile();
|
||||
assertTrue(js.contains("DOMContentLoaded"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileContainsNavToggle() {
|
||||
String js = jsBundle.compile();
|
||||
assertTrue(js.contains("nav-toggle"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileContainsSmoothScroll() {
|
||||
String js = jsBundle.compile();
|
||||
assertTrue(js.contains("scrollIntoView"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileContainsLightboxHandler() {
|
||||
String js = jsBundle.compile();
|
||||
assertTrue(js.contains("lightbox"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileContainsContactFormHandler() {
|
||||
String js = jsBundle.compile();
|
||||
assertTrue(js.contains("data-static"));
|
||||
assertTrue(js.contains("Message sent"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.krrishg.engine;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class StyleCompilerTest {
|
||||
|
||||
private final StyleCompiler compiler = new StyleCompiler();
|
||||
|
||||
@Test
|
||||
void compileReturnsNonEmptyCss() {
|
||||
String css = compiler.compile();
|
||||
assertNotNull(css);
|
||||
assertFalse(css.isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileContainsCssVariables() {
|
||||
String css = compiler.compile();
|
||||
assertTrue(css.contains("--color-text"));
|
||||
assertTrue(css.contains("--color-background"));
|
||||
assertTrue(css.contains("--font-body"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileContainsContainerMaxWidth() {
|
||||
String css = compiler.compile();
|
||||
assertTrue(css.contains("--spacing-containerWidth"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileContainsResponsiveQueries() {
|
||||
String css = compiler.compile();
|
||||
assertTrue(css.contains("@media (max-width: 768px)"));
|
||||
assertTrue(css.contains("@media (max-width: 480px)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileStartsWithUniversalReset() {
|
||||
String css = compiler.compile();
|
||||
assertTrue(css.startsWith("* { margin: 0; padding: 0; box-sizing: border-box; }"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AuthProviderTest {
|
||||
|
||||
@Test
|
||||
void enumValues() {
|
||||
assertArrayEquals(
|
||||
new AuthProvider[]{AuthProvider.LOCAL},
|
||||
AuthProvider.values()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class GenerationVersionTest {
|
||||
|
||||
@Test
|
||||
void builderSetsAllFields() {
|
||||
UUID siteId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
GenerationVersion version = GenerationVersion.builder()
|
||||
.id("mongo-id")
|
||||
.siteId(siteId)
|
||||
.userId(userId)
|
||||
.versionNumber(1)
|
||||
.prompt("Build a landing page")
|
||||
.fullStructure("{\"theme\":{}}")
|
||||
.createdAt(now)
|
||||
.build();
|
||||
|
||||
assertEquals("mongo-id", version.getId());
|
||||
assertEquals(siteId, version.getSiteId());
|
||||
assertEquals(userId, version.getUserId());
|
||||
assertEquals(1, version.getVersionNumber());
|
||||
assertEquals("Build a landing page", version.getPrompt());
|
||||
assertEquals("{\"theme\":{}}", version.getFullStructure());
|
||||
assertEquals(now, version.getCreatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builderSupportsReferenceEntries() {
|
||||
GenerationVersion.ReferenceEntry entry = GenerationVersion.ReferenceEntry.builder()
|
||||
.type("IMAGE")
|
||||
.url("https://example.com/img.png")
|
||||
.altText("Screenshot")
|
||||
.build();
|
||||
|
||||
GenerationVersion version = GenerationVersion.builder()
|
||||
.siteId(UUID.randomUUID())
|
||||
.userId(UUID.randomUUID())
|
||||
.versionNumber(2)
|
||||
.prompt("With references")
|
||||
.references(List.of(entry))
|
||||
.build();
|
||||
|
||||
assertEquals(1, version.getReferences().size());
|
||||
assertEquals("IMAGE", version.getReferences().get(0).getType());
|
||||
assertEquals("https://example.com/img.png", version.getReferences().get(0).getUrl());
|
||||
assertEquals("Screenshot", version.getReferences().get(0).getAltText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noArgsConstructorCreatesEmptyObject() {
|
||||
GenerationVersion version = new GenerationVersion();
|
||||
assertNull(version.getId());
|
||||
assertNull(version.getSiteId());
|
||||
assertEquals(0, version.getVersionNumber());
|
||||
}
|
||||
}
|
||||
70
backend/src/test/java/com/krrishg/model/GitRemoteTest.java
Normal file
70
backend/src/test/java/com/krrishg/model/GitRemoteTest.java
Normal file
@@ -0,0 +1,70 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class GitRemoteTest {
|
||||
|
||||
@Test
|
||||
void onCreateSetsIdAndTimestamps() {
|
||||
GitRemote remote = new GitRemote();
|
||||
remote.onCreate();
|
||||
|
||||
assertNotNull(remote.getId());
|
||||
assertNotNull(remote.getCreatedAt());
|
||||
assertNotNull(remote.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void onCreateDoesNotOverrideExistingId() {
|
||||
java.util.UUID existingId = java.util.UUID.randomUUID();
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.id(existingId)
|
||||
.siteId(java.util.UUID.randomUUID())
|
||||
.provider(GitRemote.GitProvider.GITHUB)
|
||||
.remoteUrl("https://github.com/user/repo.git")
|
||||
.encryptedToken("token")
|
||||
.build();
|
||||
|
||||
remote.onCreate();
|
||||
|
||||
assertEquals(existingId, remote.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void onUpdateSetsUpdatedAt() {
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.siteId(java.util.UUID.randomUUID())
|
||||
.provider(GitRemote.GitProvider.GITLAB)
|
||||
.remoteUrl("https://gitlab.com/user/repo.git")
|
||||
.encryptedToken("token")
|
||||
.build();
|
||||
remote.onCreate();
|
||||
|
||||
java.time.LocalDateTime before = remote.getUpdatedAt();
|
||||
remote.onUpdate();
|
||||
|
||||
assertTrue(remote.getUpdatedAt().isAfter(before) || remote.getUpdatedAt().equals(before));
|
||||
}
|
||||
|
||||
@Test
|
||||
void builderSetsDefaults() {
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.siteId(java.util.UUID.randomUUID())
|
||||
.provider(GitRemote.GitProvider.GITHUB)
|
||||
.remoteUrl("https://github.com/user/repo.git")
|
||||
.encryptedToken("token")
|
||||
.build();
|
||||
|
||||
assertEquals("main", remote.getDefaultBranch());
|
||||
}
|
||||
|
||||
@Test
|
||||
void gitProviderEnumValues() {
|
||||
assertArrayEquals(
|
||||
new GitRemote.GitProvider[]{GitRemote.GitProvider.GITHUB, GitRemote.GitProvider.GITLAB},
|
||||
GitRemote.GitProvider.values()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SelfHostedConfigTest {
|
||||
|
||||
@Test
|
||||
void prePersistSetsIdAndTimestamps() {
|
||||
SelfHostedConfig config = new SelfHostedConfig();
|
||||
config.setSiteId(UUID.randomUUID());
|
||||
config.setDomain("example.com");
|
||||
config.setSshHost("192.168.1.1");
|
||||
config.setSshUser("deploy");
|
||||
|
||||
assertNull(config.getId());
|
||||
assertNull(config.getCreatedAt());
|
||||
assertNull(config.getUpdatedAt());
|
||||
|
||||
config.onCreate();
|
||||
|
||||
assertNotNull(config.getId());
|
||||
assertNotNull(config.getCreatedAt());
|
||||
assertNotNull(config.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prePersistDoesNotOverrideExistingId() {
|
||||
UUID existingId = UUID.randomUUID();
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.id(existingId)
|
||||
.siteId(UUID.randomUUID())
|
||||
.domain("example.com")
|
||||
.sshHost("host")
|
||||
.sshUser("user")
|
||||
.build();
|
||||
|
||||
config.onCreate();
|
||||
|
||||
assertEquals(existingId, config.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preUpdateSetsUpdatedAt() {
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.siteId(UUID.randomUUID())
|
||||
.domain("example.com")
|
||||
.sshHost("host")
|
||||
.sshUser("user")
|
||||
.build();
|
||||
config.onCreate();
|
||||
|
||||
LocalDateTime original = config.getUpdatedAt();
|
||||
|
||||
config.onUpdate();
|
||||
|
||||
assertTrue(config.getUpdatedAt().isAfter(original));
|
||||
}
|
||||
|
||||
@Test
|
||||
void builderSetsDefaults() {
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.siteId(UUID.randomUUID())
|
||||
.domain("example.com")
|
||||
.sshHost("host")
|
||||
.sshUser("user")
|
||||
.build();
|
||||
|
||||
assertEquals(22, config.getSshPort());
|
||||
assertEquals("/var/www/{domain}/public", config.getDeployPath());
|
||||
assertEquals("/etc/nginx/sites-available", config.getNginxSitesPath());
|
||||
assertFalse(config.isSslEnabled());
|
||||
}
|
||||
}
|
||||
16
backend/src/test/java/com/krrishg/model/SiteStatusTest.java
Normal file
16
backend/src/test/java/com/krrishg/model/SiteStatusTest.java
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SiteStatusTest {
|
||||
|
||||
@Test
|
||||
void enumValues() {
|
||||
assertArrayEquals(
|
||||
new SiteStatus[]{SiteStatus.DRAFT, SiteStatus.PUBLISHED},
|
||||
SiteStatus.values()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ class UserTest {
|
||||
User user = new User();
|
||||
user.setEmail("test@example.com");
|
||||
user.setName("Test User");
|
||||
user.setProvider(AuthProvider.LOCAL);
|
||||
|
||||
assertNull(user.getId());
|
||||
assertNull(user.getCreatedAt());
|
||||
@@ -33,7 +32,6 @@ class UserTest {
|
||||
.id(UUID.randomUUID())
|
||||
.email("test@example.com")
|
||||
.name("Test")
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.build();
|
||||
|
||||
UUID existingId = user.getId();
|
||||
@@ -47,7 +45,6 @@ class UserTest {
|
||||
User user = User.builder()
|
||||
.email("test@example.com")
|
||||
.name("Test")
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.build();
|
||||
user.onCreate();
|
||||
|
||||
@@ -63,9 +60,9 @@ class UserTest {
|
||||
User user = User.builder()
|
||||
.email("test@example.com")
|
||||
.name("Test")
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.build();
|
||||
|
||||
assertFalse(user.isEmailVerified());
|
||||
assertEquals(AuthProvider.LOCAL, user.getProvider());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class VerificationTokenTest {
|
||||
|
||||
@Test
|
||||
void prePersistSetsIdAndCreatedAt() {
|
||||
VerificationToken token = new VerificationToken();
|
||||
token.setUserId(UUID.randomUUID());
|
||||
token.setToken("verify-token");
|
||||
token.setExpiryDate(LocalDateTime.now().plusHours(1));
|
||||
|
||||
assertNull(token.getId());
|
||||
assertNull(token.getCreatedAt());
|
||||
|
||||
token.onCreate();
|
||||
|
||||
assertNotNull(token.getId());
|
||||
assertNotNull(token.getCreatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prePersistDoesNotOverrideExistingId() {
|
||||
UUID existingId = UUID.randomUUID();
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.id(existingId)
|
||||
.userId(UUID.randomUUID())
|
||||
.token("test-token")
|
||||
.expiryDate(LocalDateTime.now().plusHours(1))
|
||||
.build();
|
||||
|
||||
token.onCreate();
|
||||
|
||||
assertEquals(existingId, token.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builderDefaults() {
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(UUID.randomUUID())
|
||||
.token("test-token")
|
||||
.expiryDate(LocalDateTime.now().plusHours(1))
|
||||
.build();
|
||||
|
||||
assertFalse(token.isUsed());
|
||||
assertFalse(token.isSetupTokenUsed());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.krrishg.repository;
|
||||
|
||||
import com.krrishg.model.GitRemote;
|
||||
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 GitRemoteRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private GitRemoteRepository repository;
|
||||
|
||||
@Test
|
||||
void findBySiteIdReturnsRemoteWhenExists() {
|
||||
UUID siteId = UUID.randomUUID();
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.siteId(siteId)
|
||||
.provider(GitRemote.GitProvider.GITHUB)
|
||||
.remoteUrl("https://github.com/user/repo.git")
|
||||
.encryptedToken("encrypted-token")
|
||||
.build();
|
||||
repository.save(remote);
|
||||
|
||||
Optional<GitRemote> found = repository.findBySiteId(siteId);
|
||||
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals(siteId, found.get().getSiteId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findBySiteIdReturnsEmptyWhenNotExists() {
|
||||
Optional<GitRemote> found = repository.findBySiteId(UUID.randomUUID());
|
||||
assertTrue(found.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsBySiteIdReturnsTrueWhenExists() {
|
||||
UUID siteId = UUID.randomUUID();
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.siteId(siteId)
|
||||
.provider(GitRemote.GitProvider.GITLAB)
|
||||
.remoteUrl("https://gitlab.com/user/repo.git")
|
||||
.encryptedToken("encrypted-token")
|
||||
.build();
|
||||
repository.save(remote);
|
||||
|
||||
assertTrue(repository.existsBySiteId(siteId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsBySiteIdReturnsFalseWhenNotExists() {
|
||||
assertFalse(repository.existsBySiteId(UUID.randomUUID()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteBySiteIdRemovesRemote() {
|
||||
UUID siteId = UUID.randomUUID();
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.siteId(siteId)
|
||||
.provider(GitRemote.GitProvider.GITHUB)
|
||||
.remoteUrl("https://github.com/user/repo.git")
|
||||
.encryptedToken("encrypted-token")
|
||||
.build();
|
||||
repository.save(remote);
|
||||
|
||||
repository.deleteBySiteId(siteId);
|
||||
|
||||
assertFalse(repository.existsBySiteId(siteId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void savedRemoteHasIdAndTimestamps() {
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.siteId(UUID.randomUUID())
|
||||
.provider(GitRemote.GitProvider.GITHUB)
|
||||
.remoteUrl("https://github.com/user/repo.git")
|
||||
.encryptedToken("encrypted-token")
|
||||
.build();
|
||||
GitRemote saved = repository.save(remote);
|
||||
|
||||
assertNotNull(saved.getId());
|
||||
assertNotNull(saved.getCreatedAt());
|
||||
assertNotNull(saved.getUpdatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.krrishg.repository;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
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 SelfHostedConfigRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private SelfHostedConfigRepository repository;
|
||||
|
||||
@Test
|
||||
void saveAndFindBySiteId() {
|
||||
UUID siteId = UUID.randomUUID();
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.siteId(siteId)
|
||||
.domain("example.com")
|
||||
.sshHost("192.168.1.1")
|
||||
.sshPort(22)
|
||||
.sshUser("deploy")
|
||||
.build();
|
||||
repository.save(config);
|
||||
|
||||
Optional<SelfHostedConfig> found = repository.findBySiteId(siteId);
|
||||
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals("example.com", found.get().getDomain());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findBySiteIdReturnsEmptyWhenNotExists() {
|
||||
Optional<SelfHostedConfig> found = repository.findBySiteId(UUID.randomUUID());
|
||||
assertTrue(found.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByDomainReturnsConfig() {
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.siteId(UUID.randomUUID())
|
||||
.domain("mysite.com")
|
||||
.sshHost("10.0.0.1")
|
||||
.sshPort(2222)
|
||||
.sshUser("admin")
|
||||
.build();
|
||||
repository.save(config);
|
||||
|
||||
Optional<SelfHostedConfig> found = repository.findByDomain("mysite.com");
|
||||
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals("mysite.com", found.get().getDomain());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByDomainReturnsEmptyWhenNotExists() {
|
||||
Optional<SelfHostedConfig> found = repository.findByDomain("nonexistent.com");
|
||||
assertTrue(found.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsBySiteIdReturnsTrueWhenExists() {
|
||||
UUID siteId = UUID.randomUUID();
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.siteId(siteId)
|
||||
.domain("exists.com")
|
||||
.sshHost("host")
|
||||
.sshPort(22)
|
||||
.sshUser("user")
|
||||
.build();
|
||||
repository.save(config);
|
||||
|
||||
assertTrue(repository.existsBySiteId(siteId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsBySiteIdReturnsFalseWhenNotExists() {
|
||||
assertFalse(repository.existsBySiteId(UUID.randomUUID()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsByDomainReturnsTrueWhenExists() {
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.siteId(UUID.randomUUID())
|
||||
.domain("domain-check.com")
|
||||
.sshHost("host")
|
||||
.sshPort(22)
|
||||
.sshUser("user")
|
||||
.build();
|
||||
repository.save(config);
|
||||
|
||||
assertTrue(repository.existsByDomain("domain-check.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsByDomainReturnsFalseWhenNotExists() {
|
||||
assertFalse(repository.existsByDomain("no-domain.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void savedEntityHasIdAndTimestamps() {
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.siteId(UUID.randomUUID())
|
||||
.domain("timestamps.com")
|
||||
.sshHost("host")
|
||||
.sshPort(22)
|
||||
.sshUser("user")
|
||||
.build();
|
||||
SelfHostedConfig saved = repository.save(config);
|
||||
|
||||
assertNotNull(saved.getId());
|
||||
assertNotNull(saved.getCreatedAt());
|
||||
assertNotNull(saved.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void siteIdIsUniqueByConstraint() {
|
||||
UUID siteId = UUID.randomUUID();
|
||||
|
||||
SelfHostedConfig config1 = SelfHostedConfig.builder()
|
||||
.siteId(siteId)
|
||||
.domain("first.com")
|
||||
.sshHost("host1")
|
||||
.sshPort(22)
|
||||
.sshUser("user1")
|
||||
.build();
|
||||
repository.saveAndFlush(config1);
|
||||
|
||||
SelfHostedConfig config2 = SelfHostedConfig.builder()
|
||||
.siteId(siteId)
|
||||
.domain("second.com")
|
||||
.sshHost("host2")
|
||||
.sshPort(22)
|
||||
.sshUser("user2")
|
||||
.build();
|
||||
|
||||
assertThrows(Exception.class, () -> repository.saveAndFlush(config2));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user