publish site to custom server

This commit is contained in:
Krrish Ghimire
2026-07-06 11:15:03 +05:45
parent 51d887a005
commit a498d55e8c
23 changed files with 966 additions and 25 deletions

View File

@@ -0,0 +1,30 @@
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());
}
}
}

View File

@@ -1,6 +1,8 @@
package com.krrishg.controller; package com.krrishg.controller;
import com.krrishg.config.UserPrincipal; import com.krrishg.config.UserPrincipal;
import com.krrishg.model.Site;
import com.krrishg.repository.SiteRepository;
import com.krrishg.service.DeploymentService; import com.krrishg.service.DeploymentService;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -14,15 +16,19 @@ import java.util.UUID;
public class DeploymentController { public class DeploymentController {
private final DeploymentService deploymentService; private final DeploymentService deploymentService;
private final SiteRepository siteRepository;
public DeploymentController(DeploymentService deploymentService) { public DeploymentController(DeploymentService deploymentService,
SiteRepository siteRepository) {
this.deploymentService = deploymentService; this.deploymentService = deploymentService;
this.siteRepository = siteRepository;
} }
@PostMapping("/publish") @PostMapping("/publish")
public ResponseEntity<?> publish( public ResponseEntity<?> publish(
@AuthenticationPrincipal UserPrincipal principal, @AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID id) { @PathVariable UUID id) {
findSiteForUser(id, principal.id());
try { try {
String url = deploymentService.publish(id); String url = deploymentService.publish(id);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
@@ -38,7 +44,17 @@ public class DeploymentController {
public ResponseEntity<?> unpublish( public ResponseEntity<?> unpublish(
@AuthenticationPrincipal UserPrincipal principal, @AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID id) { @PathVariable UUID id) {
findSiteForUser(id, principal.id());
deploymentService.unpublish(id); deploymentService.unpublish(id);
return ResponseEntity.ok(Map.of("message", "Site unpublished successfully")); 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;
}
} }

View File

@@ -0,0 +1,126 @@
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));
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)
.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;
}
}

View File

@@ -41,11 +41,14 @@ public class SiteController {
throw new IllegalArgumentException("Subdomain already taken"); throw new IllegalArgumentException("Subdomain already taken");
} }
Site.DeploymentTarget target = parseDeploymentTarget(request.getDeploymentTarget());
Site site = Site.builder() Site site = Site.builder()
.userId(principal.id()) .userId(principal.id())
.name(request.getName()) .name(request.getName())
.description(request.getDescription()) .description(request.getDescription())
.subdomain(subdomain) .subdomain(subdomain)
.deploymentTarget(target)
.build(); .build();
site = siteRepository.save(site); site = siteRepository.save(site);
@@ -76,6 +79,10 @@ public class SiteController {
site.setDescription(request.getDescription()); site.setDescription(request.getDescription());
site.setSubdomain(newSubdomain); site.setSubdomain(newSubdomain);
if (request.getDeploymentTarget() != null) {
site.setDeploymentTarget(parseDeploymentTarget(request.getDeploymentTarget()));
}
site = siteRepository.save(site); site = siteRepository.save(site);
return ResponseEntity.ok(SiteResponse.from(site)); return ResponseEntity.ok(SiteResponse.from(site));
} }
@@ -107,4 +114,15 @@ public class SiteController {
} }
return cleaned; return cleaned;
} }
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;
}
}
} }

View File

@@ -0,0 +1,23 @@
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;
}

View File

@@ -0,0 +1,45 @@
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 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())
.publicKey(publicKey)
.deployedAt(config.getDeployedAt())
.createdAt(config.getCreatedAt())
.updatedAt(config.getUpdatedAt())
.build();
}
}

View File

@@ -12,4 +12,6 @@ public class SiteRequest {
private String description; private String description;
private String subdomain; private String subdomain;
private String deploymentTarget;
} }

View File

@@ -21,6 +21,7 @@ public class SiteResponse {
private String subdomain; private String subdomain;
private String publishedUrl; private String publishedUrl;
private SiteStatus status; private SiteStatus status;
private String deploymentTarget;
private LocalDateTime publishedAt; private LocalDateTime publishedAt;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
@@ -34,6 +35,7 @@ public class SiteResponse {
.subdomain(site.getSubdomain()) .subdomain(site.getSubdomain())
.publishedUrl(site.getPublishedUrl()) .publishedUrl(site.getPublishedUrl())
.status(site.getStatus()) .status(site.getStatus())
.deploymentTarget(site.getDeploymentTarget().name())
.publishedAt(site.getPublishedAt()) .publishedAt(site.getPublishedAt())
.createdAt(site.getCreatedAt()) .createdAt(site.getCreatedAt())
.updatedAt(site.getUpdatedAt()) .updatedAt(site.getUpdatedAt())

View File

@@ -0,0 +1,65 @@
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
private UUID id;
@Column(nullable = false, unique = true)
private UUID siteId;
@Column(nullable = false)
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)
@Builder.Default
private String deployPath = "/var/www/{domain}/public";
@Column(nullable = false)
@Builder.Default
private String nginxSitesPath = "/etc/nginx/sites-available";
private LocalDateTime deployedAt;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private LocalDateTime updatedAt;
@PrePersist
void onCreate() {
if (id == null) {
id = UUID.randomUUID();
}
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -39,6 +39,14 @@ public class Site {
@Builder.Default @Builder.Default
private SiteStatus status = SiteStatus.DRAFT; private SiteStatus status = SiteStatus.DRAFT;
@Enumerated(EnumType.STRING)
@Builder.Default
private DeploymentTarget deploymentTarget = DeploymentTarget.NONE;
public DeploymentTarget getDeploymentTarget() {
return deploymentTarget != null ? deploymentTarget : DeploymentTarget.NONE;
}
private LocalDateTime publishedAt; private LocalDateTime publishedAt;
@Column(nullable = false, updatable = false) @Column(nullable = false, updatable = false)
@@ -47,6 +55,10 @@ public class Site {
@Column(nullable = false) @Column(nullable = false)
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
public enum DeploymentTarget {
GIT_PAGES, SELF_HOSTED, NONE
}
@PrePersist @PrePersist
void onCreate() { void onCreate() {
if (id == null) { if (id == null) {

View File

@@ -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);
}

View 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);
}

View File

@@ -2,10 +2,14 @@ package com.krrishg.service;
import com.krrishg.dto.SiteStructure; import com.krrishg.dto.SiteStructure;
import com.krrishg.model.GitRemote; import com.krrishg.model.GitRemote;
import com.krrishg.model.SelfHostedConfig;
import com.krrishg.model.Site; import com.krrishg.model.Site;
import com.krrishg.model.Site.DeploymentTarget;
import com.krrishg.model.SiteStatus; import com.krrishg.model.SiteStatus;
import com.krrishg.repository.GitRemoteRepository; import com.krrishg.repository.GitRemoteRepository;
import com.krrishg.repository.SelfHostedConfigRepository;
import com.krrishg.repository.SiteRepository; import com.krrishg.repository.SiteRepository;
import com.krrishg.service.RenderService.SiteOutput;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -23,17 +27,23 @@ public class DeploymentService {
private final SiteRepository siteRepository; private final SiteRepository siteRepository;
private final LLMService llmService; private final LLMService llmService;
private final GitRemoteRepository gitRemoteRepository; private final GitRemoteRepository gitRemoteRepository;
private final SelfHostedConfigRepository selfHostedConfigRepository;
private final Deployer deployer;
public DeploymentService(RenderService renderService, public DeploymentService(RenderService renderService,
GitService gitService, GitService gitService,
SiteRepository siteRepository, SiteRepository siteRepository,
LLMService llmService, LLMService llmService,
GitRemoteRepository gitRemoteRepository) { GitRemoteRepository gitRemoteRepository,
SelfHostedConfigRepository selfHostedConfigRepository,
Deployer deployer) {
this.renderService = renderService; this.renderService = renderService;
this.gitService = gitService; this.gitService = gitService;
this.siteRepository = siteRepository; this.siteRepository = siteRepository;
this.llmService = llmService; this.llmService = llmService;
this.gitRemoteRepository = gitRemoteRepository; this.gitRemoteRepository = gitRemoteRepository;
this.selfHostedConfigRepository = selfHostedConfigRepository;
this.deployer = deployer;
} }
public String publish(UUID siteId) { public String publish(UUID siteId) {
@@ -45,19 +55,46 @@ public class DeploymentService {
throw new IllegalStateException("No generation versions found for site " + siteId); throw new IllegalStateException("No generation versions found for site " + siteId);
} }
SiteOutput output = renderService.renderSite(structure);
GitRemote remote = gitRemoteRepository.findBySiteId(siteId).orElse(null); GitRemote remote = gitRemoteRepository.findBySiteId(siteId).orElse(null);
if (remote == null) { if (remote != null) {
throw new IllegalStateException("No git remote configured. Connect a GitHub or GitLab remote first."); 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.setStatus(SiteStatus.PUBLISHED);
site.setPublishedAt(LocalDateTime.now()); site.setPublishedAt(LocalDateTime.now());
site.setPublishedUrl(url); site.setPublishedUrl(url);
siteRepository.save(site); 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; return url;
} }
@@ -65,7 +102,19 @@ public class DeploymentService {
Site site = siteRepository.findById(siteId) Site site = siteRepository.findById(siteId)
.orElseThrow(() -> new IllegalArgumentException("Site not found")); .orElseThrow(() -> new IllegalArgumentException("Site not found"));
GitRemote remote = gitRemoteRepository.findBySiteId(siteId).orElse(null);
if (remote != null) {
gitService.removePages(siteId.toString()); 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.setStatus(SiteStatus.DRAFT);
site.setPublishedAt(null); site.setPublishedAt(null);

View File

@@ -1,6 +1,8 @@
package com.krrishg.service; package com.krrishg.service;
import com.krrishg.model.SelfHostedConfig;
import com.krrishg.model.Site; import com.krrishg.model.Site;
import com.krrishg.repository.SelfHostedConfigRepository;
import com.krrishg.repository.SiteRepository; import com.krrishg.repository.SiteRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -15,31 +17,41 @@ public class DomainService {
private static final Logger log = LoggerFactory.getLogger(DomainService.class); private static final Logger log = LoggerFactory.getLogger(DomainService.class);
private final SiteRepository siteRepository; private final SiteRepository siteRepository;
private final SelfHostedConfigRepository selfHostedConfigRepository;
private final String siteDomain; private final String siteDomain;
public DomainService(SiteRepository siteRepository, public DomainService(SiteRepository siteRepository,
SelfHostedConfigRepository selfHostedConfigRepository,
@Value("${indie.domain}") String siteDomain) { @Value("${indie.domain}") String siteDomain) {
this.siteRepository = siteRepository; this.siteRepository = siteRepository;
this.selfHostedConfigRepository = selfHostedConfigRepository;
this.siteDomain = siteDomain; this.siteDomain = siteDomain;
} }
public Optional<Site> resolveSite(String hostname) { public Optional<Site> resolveSite(String hostname) {
String subdomain = extractSubdomain(hostname); if (hostname == null || hostname.isBlank()) {
return Optional.empty();
}
String lower = hostname.toLowerCase().strip();
Optional<SelfHostedConfig> config = selfHostedConfigRepository.findByDomain(lower);
if (config.isPresent()) {
return siteRepository.findById(config.get().getSiteId());
}
String subdomain = extractSubdomain(lower);
if (subdomain != null) { if (subdomain != null) {
return siteRepository.findBySubdomain(subdomain); return siteRepository.findBySubdomain(subdomain);
} }
return Optional.empty(); return Optional.empty();
} }
private String extractSubdomain(String hostname) { private String extractSubdomain(String hostname) {
if (hostname == null || hostname.isBlank()) {
return null;
}
String lower = hostname.toLowerCase();
String suffix = "." + siteDomain; String suffix = "." + siteDomain;
if (lower.endsWith(suffix)) { if (hostname.endsWith(suffix)) {
String subdomain = lower.substring(0, lower.indexOf(suffix)); String subdomain = hostname.substring(0, hostname.indexOf(suffix));
if (!subdomain.isEmpty() && !subdomain.contains(".")) { if (!subdomain.isEmpty() && !subdomain.contains(".")) {
return subdomain; return subdomain;
} }

View File

@@ -323,6 +323,10 @@ public class GitService {
int exitCode = process.waitFor(); int exitCode = process.waitFor();
if (exitCode != 0) { if (exitCode != 0) {
String processOutput = new String(process.getInputStream().readAllBytes()); 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); throw new RuntimeException("git command failed (exit " + exitCode + "): " + cmd + "\n" + processOutput);
} }
} }

View File

@@ -0,0 +1,162 @@
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);
String nginxConfig = buildNginxConfig(config.getDomain(), resolvedDeployPath);
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");
log.info("Removed deployment for site {} ({})", config.getSiteId(), config.getDomain());
} finally {
deleteDirectory(tempDir);
}
}
@Override
public String buildUrl(SelfHostedConfig config) {
return "https://" + 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) {
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 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);
}
});
}
}
}
}

View File

@@ -3,6 +3,8 @@ package com.krrishg.controller;
import com.krrishg.config.GlobalExceptionHandler; import com.krrishg.config.GlobalExceptionHandler;
import com.krrishg.config.JwtAuthenticationToken; import com.krrishg.config.JwtAuthenticationToken;
import com.krrishg.config.UserPrincipal; import com.krrishg.config.UserPrincipal;
import com.krrishg.model.Site;
import com.krrishg.repository.SiteRepository;
import com.krrishg.service.DeploymentService; import com.krrishg.service.DeploymentService;
import com.krrishg.support.TestSecurityConfig; import com.krrishg.support.TestSecurityConfig;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -13,6 +15,7 @@ import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
@@ -31,6 +34,9 @@ class DeploymentControllerTest {
@MockBean @MockBean
private DeploymentService deploymentService; private DeploymentService deploymentService;
@MockBean
private SiteRepository siteRepository;
private UUID userId; private UUID userId;
private UUID siteId; private UUID siteId;
private UserPrincipal principal; private UserPrincipal principal;
@@ -46,6 +52,13 @@ class DeploymentControllerTest {
siteId = UUID.randomUUID(); siteId = UUID.randomUUID();
principal = new UserPrincipal(userId, "user@test.com", "Test User"); principal = new UserPrincipal(userId, "user@test.com", "Test User");
auth = new JwtAuthenticationToken(principal, "test-token"); 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 @Test

View File

@@ -2,6 +2,7 @@ package com.krrishg.service;
import com.krrishg.model.Site; import com.krrishg.model.Site;
import com.krrishg.model.SiteStatus; import com.krrishg.model.SiteStatus;
import com.krrishg.support.FakeSelfHostedConfigRepository;
import com.krrishg.support.FakeSiteRepository; import com.krrishg.support.FakeSiteRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -16,12 +17,14 @@ class DomainServiceTest {
private static final String SITE_DOMAIN = "indie.example.com"; private static final String SITE_DOMAIN = "indie.example.com";
private FakeSiteRepository siteRepository; private FakeSiteRepository siteRepository;
private FakeSelfHostedConfigRepository selfHostedConfigRepository;
private DomainService domainService; private DomainService domainService;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
siteRepository = new FakeSiteRepository(); siteRepository = new FakeSiteRepository();
domainService = new DomainService(siteRepository, SITE_DOMAIN); selfHostedConfigRepository = new FakeSelfHostedConfigRepository();
domainService = new DomainService(siteRepository, selfHostedConfigRepository, SITE_DOMAIN);
} }
@Test @Test

View File

@@ -0,0 +1,54 @@
package com.krrishg.support;
import com.krrishg.model.SelfHostedConfig;
import com.krrishg.repository.SelfHostedConfigRepository;
import java.util.Optional;
import java.util.UUID;
public class FakeSelfHostedConfigRepository
extends InMemoryRepository<SelfHostedConfig, UUID>
implements SelfHostedConfigRepository {
@Override
protected UUID getId(SelfHostedConfig entity) {
return entity.getId();
}
@Override
protected SelfHostedConfig setId(SelfHostedConfig entity, UUID id) {
entity.setId(id);
return entity;
}
@Override
protected UUID generateId() {
return UUID.randomUUID();
}
@Override
public Optional<SelfHostedConfig> findBySiteId(UUID siteId) {
return store.values().stream()
.filter(c -> siteId.equals(c.getSiteId()))
.findFirst();
}
@Override
public Optional<SelfHostedConfig> findByDomain(String domain) {
return store.values().stream()
.filter(c -> domain.equals(c.getDomain()))
.findFirst();
}
@Override
public boolean existsBySiteId(UUID siteId) {
return store.values().stream()
.anyMatch(c -> siteId.equals(c.getSiteId()));
}
@Override
public boolean existsByDomain(String domain) {
return store.values().stream()
.anyMatch(c -> domain.equals(c.getDomain()));
}
}

View File

@@ -23,3 +23,27 @@ export interface PublishResponse {
message: string; message: string;
url: string; url: string;
} }
export interface SelfHostedConfigRequest {
domain: string;
sshHost: string;
sshPort: number;
sshUser: string;
deployPath: string;
nginxSitesPath: string;
}
export interface SelfHostedConfigResponse {
id: string;
siteId: string;
domain: string;
sshHost: string;
sshPort: number;
sshUser: string;
deployPath: string;
nginxSitesPath: string;
publicKey: string;
deployedAt: string | null;
createdAt: string;
updatedAt: string;
}

View File

@@ -7,6 +7,7 @@ export interface SiteResponse {
publishedUrl: string | null; publishedUrl: string | null;
themeConfig: Record<string, unknown>; themeConfig: Record<string, unknown>;
status: 'DRAFT' | 'PUBLISHED'; status: 'DRAFT' | 'PUBLISHED';
deploymentTarget: 'GIT_PAGES' | 'SELF_HOSTED' | 'NONE';
publishedAt: string | null; publishedAt: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -16,5 +17,6 @@ export interface SiteRequest {
name: string; name: string;
description: string; description: string;
subdomain: string; subdomain: string;
deploymentTarget?: string;
themeConfig?: Record<string, unknown>; themeConfig?: Record<string, unknown>;
} }

View File

@@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { SiteResponse, SiteRequest } from '../models/site'; import { SiteResponse, SiteRequest } from '../models/site';
import { SelfHostedConfigRequest, SelfHostedConfigResponse } from '../models/deployment';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SiteService { export class SiteService {
@@ -26,4 +27,18 @@ export class SiteService {
delete(id: string): Observable<void> { delete(id: string): Observable<void> {
return this.http.delete<void>(`/api/sites/${id}`); return this.http.delete<void>(`/api/sites/${id}`);
} }
getSelfHostedConfig(id: string): Observable<SelfHostedConfigResponse | { configured: false }> {
return this.http.get<SelfHostedConfigResponse | { configured: false }>(
`/api/sites/${id}/self-hosted`
);
}
saveSelfHostedConfig(id: string, config: SelfHostedConfigRequest): Observable<SelfHostedConfigResponse> {
return this.http.put<SelfHostedConfigResponse>(`/api/sites/${id}/self-hosted`, config);
}
deleteSelfHostedConfig(id: string): Observable<void> {
return this.http.delete<void>(`/api/sites/${id}/self-hosted`);
}
} }

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy, inject } from '@angular/core'; import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { NgIf, NgFor, DatePipe } from '@angular/common'; import { NgIf, NgFor, NgSwitch, NgSwitchCase, NgSwitchDefault, DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatToolbarModule } from '@angular/material/toolbar'; import { MatToolbarModule } from '@angular/material/toolbar';
@@ -7,6 +7,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatDividerModule } from '@angular/material/divider'; import { MatDividerModule } from '@angular/material/divider';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
@@ -15,16 +16,16 @@ import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { SiteService } from '../../core/services/site.service'; import { SiteService } from '../../core/services/site.service';
import { DeploymentService } from '../../core/services/deployment.service'; import { DeploymentService } from '../../core/services/deployment.service';
import { SiteResponse } from '../../core/models/site'; import { SelfHostedConfigResponse } from '../../core/models/deployment';
@Component({ @Component({
selector: 'app-settings', selector: 'app-settings',
standalone: true, standalone: true,
imports: [ imports: [
NgIf, NgFor, DatePipe, FormsModule, RouterModule, NgIf, NgFor, NgSwitch, NgSwitchCase, NgSwitchDefault, DatePipe, FormsModule, RouterModule,
MatToolbarModule, MatButtonModule, MatIconModule, MatToolbarModule, MatButtonModule, MatIconModule,
MatInputModule, MatFormFieldModule, MatCardModule, MatInputModule, MatFormFieldModule, MatSelectModule,
MatDividerModule, MatSnackBarModule, MatProgressSpinnerModule, MatCardModule, MatDividerModule, MatSnackBarModule, MatProgressSpinnerModule,
], ],
template: ` template: `
<div class="h-screen flex flex-col bg-gray-50"> <div class="h-screen flex flex-col bg-gray-50">
@@ -66,6 +67,38 @@ import { SiteResponse } from '../../core/models/site';
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
<mat-card class="p-4" appearance="outlined">
<mat-card-content class="!p-0">
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Deployment Target</h4>
<p class="text-sm text-gray-600 mb-3">
Choose where your site is published. Git push always runs if a remote is configured.
</p>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Target</mat-label>
<mat-select [(ngModel)]="deploymentTarget" (selectionChange)="onDeploymentTargetChange()">
<mat-option value="NONE">None</mat-option>
<mat-option value="GIT_PAGES">GitHub / GitLab Pages</mat-option>
<mat-option value="SELF_HOSTED">Self-Hosted Server</mat-option>
</mat-select>
</mat-form-field>
<div class="text-xs text-gray-500 space-y-1 mt-1">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full shrink-0"
[class.bg-green-500]="hasGitRemote"
[class.bg-gray-300]="!hasGitRemote"></span>
<span>Git Remote: {{ hasGitRemote ? 'Connected' : 'Not configured' }}</span>
<button mat-button (click)="goToGitSettings()" class="!text-xs !min-w-0 !px-1">Manage</button>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full shrink-0"
[class.bg-green-500]="hasSelfHostedConfig"
[class.bg-gray-300]="!hasSelfHostedConfig"></span>
<span>Self-Hosted: {{ hasSelfHostedConfig ? 'Configured' : 'Not configured' }}</span>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="p-4" appearance="outlined"> <mat-card class="p-4" appearance="outlined">
<mat-card-content class="!p-0"> <mat-card-content class="!p-0">
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Export</h4> <h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Export</h4>
@@ -92,7 +125,13 @@ import { SiteResponse } from '../../core/models/site';
</span> </span>
</div> </div>
<p class="text-sm text-gray-600 mb-3" *ngIf="siteStatus === 'DRAFT'"> <p class="text-sm text-gray-600 mb-3" *ngIf="siteStatus === 'DRAFT'">
Publish your site to make it live at your subdomain. The latest version will be rendered and deployed. Publish your site to make it live
<ng-container [ngSwitch]="deploymentTarget">
<span *ngSwitchCase="'GIT_PAGES'">on GitHub/GitLab Pages</span>
<span *ngSwitchCase="'SELF_HOSTED'">on your own server</span>
<span *ngSwitchDefault>.</span>
</ng-container>
. The latest version will be rendered and deployed.
</p> </p>
<p class="text-sm text-gray-600 mb-3" *ngIf="siteStatus === 'PUBLISHED' && liveUrl"> <p class="text-sm text-gray-600 mb-3" *ngIf="siteStatus === 'PUBLISHED' && liveUrl">
Your site is live at <a [href]="liveUrl" target="_blank" class="text-blue-600 underline">{{ liveUrl }}</a> Your site is live at <a [href]="liveUrl" target="_blank" class="text-blue-600 underline">{{ liveUrl }}</a>
@@ -114,6 +153,66 @@ import { SiteResponse } from '../../core/models/site';
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
<mat-card class="p-4" appearance="outlined" *ngIf="deploymentTarget === 'SELF_HOSTED'">
<mat-card-content class="!p-0">
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Self-Hosted Server</h4>
<div class="space-y-3">
<mat-form-field appearance="outline" class="w-full">
<mat-label>Domain</mat-label>
<input matInput [(ngModel)]="shDomain" [disabled]="shSaving" placeholder="www.example.com" />
</mat-form-field>
<div class="flex gap-3">
<mat-form-field appearance="outline" class="flex-1">
<mat-label>SSH Host</mat-label>
<input matInput [(ngModel)]="shSshHost" [disabled]="shSaving" placeholder="192.168.1.1" />
</mat-form-field>
<mat-form-field appearance="outline" class="w-24">
<mat-label>Port</mat-label>
<input matInput type="number" [(ngModel)]="shSshPort" [disabled]="shSaving" />
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="w-full">
<mat-label>SSH User</mat-label>
<input matInput [(ngModel)]="shSshUser" [disabled]="shSaving" placeholder="deploy" />
</mat-form-field>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Deploy Path</mat-label>
<input matInput [(ngModel)]="shDeployPath" [disabled]="shSaving" />
<mat-hint>Use {{ '{' }}domain{{ '}' }} as placeholder: /var/www/{{ '{' }}domain{{ '}' }}/public</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Nginx Sites Path</mat-label>
<input matInput [(ngModel)]="shNginxSitesPath" [disabled]="shSaving" />
<mat-hint>Path to nginx sites-available directory</mat-hint>
</mat-form-field>
<div class="bg-gray-50 rounded-lg p-3 space-y-2" *ngIf="shPublicKey">
<div class="text-xs font-semibold text-gray-500 uppercase tracking-wide">Public Key</div>
<p class="text-xs text-gray-600">
Add this key to <code class="bg-gray-200 px-1 rounded">~/.ssh/authorized_keys</code> on your server for the SSH user above.
</p>
<pre class="text-xs bg-gray-800 text-green-300 rounded p-2 overflow-x-auto whitespace-pre-wrap break-all max-h-28 overflow-y-auto">{{ shPublicKey }}</pre>
<button mat-stroked-button (click)="copyPublicKey()" class="!text-xs">
<mat-icon>content_copy</mat-icon>
{{ shPublicKeyCopied ? 'Copied!' : 'Copy' }}
</button>
</div>
<p class="text-xs text-gray-500" *ngIf="shDeployedAt">
Last deployed: {{ shDeployedAt }}
</p>
<div class="flex gap-3">
<button mat-flat-button color="primary" [disabled]="shSaving || !shDomain.trim() || !shSshHost.trim() || !shSshUser.trim()" (click)="saveSelfHostedConfig()">
<mat-icon>save</mat-icon>
{{ shSaving ? 'Saving...' : 'Save Config' }}
</button>
<button mat-stroked-button color="warn" *ngIf="hasSelfHostedConfig" [disabled]="shSaving" (click)="removeSelfHostedConfig()">
<mat-icon>delete</mat-icon>
Remove
</button>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="p-4" appearance="outlined"> <mat-card class="p-4" appearance="outlined">
<mat-card-content class="!p-0"> <mat-card-content class="!p-0">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -124,7 +223,7 @@ import { SiteResponse } from '../../core/models/site';
</button> </button>
</div> </div>
<p class="text-sm text-gray-600 mt-2"> <p class="text-sm text-gray-600 mt-2">
Connect a GitHub or GitLab repository to automatically push changes when you publish. Connect a GitHub or GitLab repository. Git push runs on every publish regardless of deployment target.
</p> </p>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
@@ -147,10 +246,25 @@ export class SettingsComponent implements OnInit, OnDestroy {
siteSubdomain = ''; siteSubdomain = '';
siteStatus: 'DRAFT' | 'PUBLISHED' = 'DRAFT'; siteStatus: 'DRAFT' | 'PUBLISHED' = 'DRAFT';
liveUrl = ''; liveUrl = '';
deploymentTarget: 'GIT_PAGES' | 'SELF_HOSTED' | 'NONE' = 'NONE';
hasGitRemote = false;
hasSelfHostedConfig = false;
saving = false; saving = false;
exporting = false; exporting = false;
publishing = false; publishing = false;
publishStatusText = ''; publishStatusText = '';
shDomain = '';
shSshHost = '';
shSshPort = 22;
shSshUser = '';
shDeployPath = '/var/www/{domain}/public';
shNginxSitesPath = '/etc/nginx/sites-available';
shPublicKey = '';
shPublicKeyCopied = false;
shDeployedAt: string | null = null;
shSaving = false;
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
ngOnInit(): void { ngOnInit(): void {
@@ -173,11 +287,64 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.siteSubdomain = site.subdomain || ''; this.siteSubdomain = site.subdomain || '';
this.siteStatus = site.status; this.siteStatus = site.status;
this.liveUrl = site.publishedUrl || ''; this.liveUrl = site.publishedUrl || '';
this.deploymentTarget = site.deploymentTarget || 'NONE';
this.loadGitStatus();
this.loadSelfHostedConfig();
}, },
error: () => this.snackBar.open('Failed to load site', 'Close', { duration: 3000 }), error: () => this.snackBar.open('Failed to load site', 'Close', { duration: 3000 }),
}); });
} }
private loadGitStatus(): void {
if (!this.siteId) return;
this.deploymentService.getGitStatus(this.siteId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (status) => { this.hasGitRemote = status.connected; },
error: () => {},
});
}
private loadSelfHostedConfig(): void {
if (!this.siteId) return;
this.siteService.getSelfHostedConfig(this.siteId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (config) => {
if ('configured' in config && !config.configured) {
this.hasSelfHostedConfig = false;
return;
}
const c = config as SelfHostedConfigResponse;
this.hasSelfHostedConfig = true;
this.shDomain = c.domain;
this.shSshHost = c.sshHost;
this.shSshPort = c.sshPort;
this.shSshUser = c.sshUser;
this.shDeployPath = c.deployPath;
this.shNginxSitesPath = c.nginxSitesPath;
this.shPublicKey = c.publicKey;
this.shDeployedAt = c.deployedAt;
},
error: () => { this.hasSelfHostedConfig = false; },
});
}
onDeploymentTargetChange(): void {
if (!this.siteId) return;
this.saving = true;
this.siteService.update(this.siteId, {
name: this.siteName,
description: this.siteDescription,
subdomain: this.siteSubdomain,
deploymentTarget: this.deploymentTarget,
}).pipe(takeUntil(this.destroy$))
.subscribe({
next: () => { this.saving = false; },
error: () => { this.saving = false; },
});
}
saveSettings(): void { saveSettings(): void {
if (!this.siteId || !this.siteName.trim()) return; if (!this.siteId || !this.siteName.trim()) return;
this.saving = true; this.saving = true;
@@ -185,6 +352,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
name: this.siteName, name: this.siteName,
description: this.siteDescription, description: this.siteDescription,
subdomain: this.siteSubdomain, subdomain: this.siteSubdomain,
deploymentTarget: this.deploymentTarget,
}).pipe(takeUntil(this.destroy$)) }).pipe(takeUntil(this.destroy$))
.subscribe({ .subscribe({
next: () => { next: () => {
@@ -198,6 +366,63 @@ export class SettingsComponent implements OnInit, OnDestroy {
}); });
} }
saveSelfHostedConfig(): void {
if (!this.siteId) return;
this.shSaving = true;
this.siteService.saveSelfHostedConfig(this.siteId, {
domain: this.shDomain,
sshHost: this.shSshHost,
sshPort: this.shSshPort,
sshUser: this.shSshUser,
deployPath: this.shDeployPath,
nginxSitesPath: this.shNginxSitesPath,
}).pipe(takeUntil(this.destroy$))
.subscribe({
next: (config) => {
this.shSaving = false;
this.hasSelfHostedConfig = true;
this.shPublicKey = config.publicKey;
this.shDeployedAt = config.deployedAt;
this.deploymentTarget = 'SELF_HOSTED';
this.snackBar.open('Self-hosted config saved', 'Close', { duration: 2000 });
},
error: () => {
this.shSaving = false;
this.snackBar.open('Failed to save self-hosted config', 'Close', { duration: 3000 });
},
});
}
removeSelfHostedConfig(): void {
if (!this.siteId) return;
this.shSaving = true;
this.siteService.deleteSelfHostedConfig(this.siteId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.shSaving = false;
this.hasSelfHostedConfig = false;
this.shDomain = '';
this.shSshHost = '';
this.shSshPort = 22;
this.shSshUser = '';
this.shDeployPath = '/var/www/{domain}/public';
this.shNginxSitesPath = '/etc/nginx/sites-available';
this.shPublicKey = '';
this.shPublicKeyCopied = false;
this.shDeployedAt = null;
if (this.deploymentTarget === 'SELF_HOSTED') {
this.deploymentTarget = 'NONE';
}
this.snackBar.open('Self-hosted config removed', 'Close', { duration: 2000 });
},
error: () => {
this.shSaving = false;
this.snackBar.open('Failed to remove self-hosted config', 'Close', { duration: 3000 });
},
});
}
exportSite(): void { exportSite(): void {
if (!this.siteId) return; if (!this.siteId) return;
this.exporting = true; this.exporting = true;
@@ -263,6 +488,14 @@ export class SettingsComponent implements OnInit, OnDestroy {
}); });
} }
copyPublicKey(): void {
if (!this.shPublicKey) return;
navigator.clipboard.writeText(this.shPublicKey).then(() => {
this.shPublicKeyCopied = true;
setTimeout(() => { this.shPublicKeyCopied = false; }, 2000);
});
}
goBack(): void { goBack(): void {
if (this.siteId) this.router.navigate(['/llm', this.siteId]); if (this.siteId) this.router.navigate(['/llm', this.siteId]);
} }