From a498d55e8c5ade9005125e2106f7d00f9b48624b Mon Sep 17 00:00:00 2001 From: Krrish Ghimire Date: Mon, 6 Jul 2026 11:15:03 +0545 Subject: [PATCH] publish site to custom server --- .../com/krrishg/config/SchemaMigration.java | 30 +++ .../controller/DeploymentController.java | 18 +- .../controller/SelfHostedController.java | 126 +++++++++ .../krrishg/controller/SiteController.java | 22 +- .../krrishg/dto/SelfHostedConfigRequest.java | 23 ++ .../krrishg/dto/SelfHostedConfigResponse.java | 45 ++++ .../java/com/krrishg/dto/SiteRequest.java | 2 + .../java/com/krrishg/dto/SiteResponse.java | 2 + .../com/krrishg/model/SelfHostedConfig.java | 65 +++++ .../src/main/java/com/krrishg/model/Site.java | 12 + .../SelfHostedConfigRepository.java | 18 ++ .../java/com/krrishg/service/Deployer.java | 13 + .../krrishg/service/DeploymentService.java | 61 ++++- .../com/krrishg/service/DomainService.java | 28 +- .../java/com/krrishg/service/GitService.java | 4 + .../com/krrishg/service/SshRsyncDeployer.java | 162 ++++++++++++ .../controller/DeploymentControllerTest.java | 13 + .../krrishg/service/DomainServiceTest.java | 5 +- .../FakeSelfHostedConfigRepository.java | 54 ++++ frontend/src/app/core/models/deployment.ts | 24 ++ frontend/src/app/core/models/site.ts | 2 + .../src/app/core/services/site.service.ts | 15 ++ .../settings/settings.component.ts | 247 +++++++++++++++++- 23 files changed, 966 insertions(+), 25 deletions(-) create mode 100644 backend/src/main/java/com/krrishg/config/SchemaMigration.java create mode 100644 backend/src/main/java/com/krrishg/controller/SelfHostedController.java create mode 100644 backend/src/main/java/com/krrishg/dto/SelfHostedConfigRequest.java create mode 100644 backend/src/main/java/com/krrishg/dto/SelfHostedConfigResponse.java create mode 100644 backend/src/main/java/com/krrishg/model/SelfHostedConfig.java create mode 100644 backend/src/main/java/com/krrishg/repository/SelfHostedConfigRepository.java create mode 100644 backend/src/main/java/com/krrishg/service/Deployer.java create mode 100644 backend/src/main/java/com/krrishg/service/SshRsyncDeployer.java create mode 100644 backend/src/test/java/com/krrishg/support/FakeSelfHostedConfigRepository.java diff --git a/backend/src/main/java/com/krrishg/config/SchemaMigration.java b/backend/src/main/java/com/krrishg/config/SchemaMigration.java new file mode 100644 index 0000000..3fbf854 --- /dev/null +++ b/backend/src/main/java/com/krrishg/config/SchemaMigration.java @@ -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()); + } + } +} diff --git a/backend/src/main/java/com/krrishg/controller/DeploymentController.java b/backend/src/main/java/com/krrishg/controller/DeploymentController.java index e5f5a7f..7f2b370 100644 --- a/backend/src/main/java/com/krrishg/controller/DeploymentController.java +++ b/backend/src/main/java/com/krrishg/controller/DeploymentController.java @@ -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; + } } diff --git a/backend/src/main/java/com/krrishg/controller/SelfHostedController.java b/backend/src/main/java/com/krrishg/controller/SelfHostedController.java new file mode 100644 index 0000000..fdb4a33 --- /dev/null +++ b/backend/src/main/java/com/krrishg/controller/SelfHostedController.java @@ -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; + } +} diff --git a/backend/src/main/java/com/krrishg/controller/SiteController.java b/backend/src/main/java/com/krrishg/controller/SiteController.java index ef975f9..2ef2f3c 100644 --- a/backend/src/main/java/com/krrishg/controller/SiteController.java +++ b/backend/src/main/java/com/krrishg/controller/SiteController.java @@ -41,11 +41,14 @@ public class SiteController { 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); @@ -61,8 +64,8 @@ public class SiteController { @PutMapping("/{id}") public ResponseEntity updateSite(@AuthenticationPrincipal UserPrincipal principal, - @PathVariable UUID id, - @Valid @RequestBody SiteRequest request) { + @PathVariable UUID id, + @Valid @RequestBody SiteRequest request) { Site site = findSiteForUser(id, principal.id()); String newSubdomain = sanitizeSubdomain(request.getSubdomain()); @@ -76,6 +79,10 @@ public class SiteController { 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)); } @@ -107,4 +114,15 @@ public class SiteController { } 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; + } + } } diff --git a/backend/src/main/java/com/krrishg/dto/SelfHostedConfigRequest.java b/backend/src/main/java/com/krrishg/dto/SelfHostedConfigRequest.java new file mode 100644 index 0000000..4829014 --- /dev/null +++ b/backend/src/main/java/com/krrishg/dto/SelfHostedConfigRequest.java @@ -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; +} diff --git a/backend/src/main/java/com/krrishg/dto/SelfHostedConfigResponse.java b/backend/src/main/java/com/krrishg/dto/SelfHostedConfigResponse.java new file mode 100644 index 0000000..63cda23 --- /dev/null +++ b/backend/src/main/java/com/krrishg/dto/SelfHostedConfigResponse.java @@ -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(); + } +} diff --git a/backend/src/main/java/com/krrishg/dto/SiteRequest.java b/backend/src/main/java/com/krrishg/dto/SiteRequest.java index e451e03..bbdf3ce 100644 --- a/backend/src/main/java/com/krrishg/dto/SiteRequest.java +++ b/backend/src/main/java/com/krrishg/dto/SiteRequest.java @@ -12,4 +12,6 @@ public class SiteRequest { private String description; private String subdomain; + + private String deploymentTarget; } diff --git a/backend/src/main/java/com/krrishg/dto/SiteResponse.java b/backend/src/main/java/com/krrishg/dto/SiteResponse.java index 8bce05a..68da531 100644 --- a/backend/src/main/java/com/krrishg/dto/SiteResponse.java +++ b/backend/src/main/java/com/krrishg/dto/SiteResponse.java @@ -21,6 +21,7 @@ public class SiteResponse { private String subdomain; private String publishedUrl; private SiteStatus status; + private String deploymentTarget; private LocalDateTime publishedAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; @@ -34,6 +35,7 @@ public class SiteResponse { .subdomain(site.getSubdomain()) .publishedUrl(site.getPublishedUrl()) .status(site.getStatus()) + .deploymentTarget(site.getDeploymentTarget().name()) .publishedAt(site.getPublishedAt()) .createdAt(site.getCreatedAt()) .updatedAt(site.getUpdatedAt()) diff --git a/backend/src/main/java/com/krrishg/model/SelfHostedConfig.java b/backend/src/main/java/com/krrishg/model/SelfHostedConfig.java new file mode 100644 index 0000000..cb12f94 --- /dev/null +++ b/backend/src/main/java/com/krrishg/model/SelfHostedConfig.java @@ -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(); + } +} diff --git a/backend/src/main/java/com/krrishg/model/Site.java b/backend/src/main/java/com/krrishg/model/Site.java index d1ef3a3..f9f136e 100644 --- a/backend/src/main/java/com/krrishg/model/Site.java +++ b/backend/src/main/java/com/krrishg/model/Site.java @@ -39,6 +39,14 @@ public class Site { @Builder.Default 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; @Column(nullable = false, updatable = false) @@ -47,6 +55,10 @@ public class Site { @Column(nullable = false) private LocalDateTime updatedAt; + public enum DeploymentTarget { + GIT_PAGES, SELF_HOSTED, NONE + } + @PrePersist void onCreate() { if (id == null) { diff --git a/backend/src/main/java/com/krrishg/repository/SelfHostedConfigRepository.java b/backend/src/main/java/com/krrishg/repository/SelfHostedConfigRepository.java new file mode 100644 index 0000000..e801ba1 --- /dev/null +++ b/backend/src/main/java/com/krrishg/repository/SelfHostedConfigRepository.java @@ -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 { + + Optional findBySiteId(UUID siteId); + + Optional findByDomain(String domain); + + boolean existsBySiteId(UUID siteId); + + boolean existsByDomain(String domain); +} diff --git a/backend/src/main/java/com/krrishg/service/Deployer.java b/backend/src/main/java/com/krrishg/service/Deployer.java new file mode 100644 index 0000000..e1ebbcd --- /dev/null +++ b/backend/src/main/java/com/krrishg/service/Deployer.java @@ -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); +} diff --git a/backend/src/main/java/com/krrishg/service/DeploymentService.java b/backend/src/main/java/com/krrishg/service/DeploymentService.java index c377f91..8bff2ec 100644 --- a/backend/src/main/java/com/krrishg/service/DeploymentService.java +++ b/backend/src/main/java/com/krrishg/service/DeploymentService.java @@ -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")); - gitService.removePages(siteId.toString()); + 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); diff --git a/backend/src/main/java/com/krrishg/service/DomainService.java b/backend/src/main/java/com/krrishg/service/DomainService.java index 2b2058d..e715f05 100644 --- a/backend/src/main/java/com/krrishg/service/DomainService.java +++ b/backend/src/main/java/com/krrishg/service/DomainService.java @@ -1,6 +1,8 @@ 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; @@ -15,31 +17,41 @@ public class DomainService { private static final Logger log = LoggerFactory.getLogger(DomainService.class); private final SiteRepository siteRepository; + private final SelfHostedConfigRepository selfHostedConfigRepository; private final String siteDomain; public DomainService(SiteRepository siteRepository, + SelfHostedConfigRepository selfHostedConfigRepository, @Value("${indie.domain}") String siteDomain) { this.siteRepository = siteRepository; + this.selfHostedConfigRepository = selfHostedConfigRepository; this.siteDomain = siteDomain; } public Optional resolveSite(String hostname) { - String subdomain = extractSubdomain(hostname); + if (hostname == null || hostname.isBlank()) { + return Optional.empty(); + } + + String lower = hostname.toLowerCase().strip(); + + Optional config = selfHostedConfigRepository.findByDomain(lower); + if (config.isPresent()) { + return siteRepository.findById(config.get().getSiteId()); + } + + String subdomain = extractSubdomain(lower); if (subdomain != null) { return siteRepository.findBySubdomain(subdomain); } + return Optional.empty(); } private String extractSubdomain(String hostname) { - if (hostname == null || hostname.isBlank()) { - return null; - } - - String lower = hostname.toLowerCase(); String suffix = "." + siteDomain; - if (lower.endsWith(suffix)) { - String subdomain = lower.substring(0, lower.indexOf(suffix)); + if (hostname.endsWith(suffix)) { + String subdomain = hostname.substring(0, hostname.indexOf(suffix)); if (!subdomain.isEmpty() && !subdomain.contains(".")) { return subdomain; } diff --git a/backend/src/main/java/com/krrishg/service/GitService.java b/backend/src/main/java/com/krrishg/service/GitService.java index b548085..155b39d 100644 --- a/backend/src/main/java/com/krrishg/service/GitService.java +++ b/backend/src/main/java/com/krrishg/service/GitService.java @@ -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); } } diff --git a/backend/src/main/java/com/krrishg/service/SshRsyncDeployer.java b/backend/src/main/java/com/krrishg/service/SshRsyncDeployer.java new file mode 100644 index 0000000..506b383 --- /dev/null +++ b/backend/src/main/java/com/krrishg/service/SshRsyncDeployer.java @@ -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 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); + } + }); + } + } + } +} diff --git a/backend/src/test/java/com/krrishg/controller/DeploymentControllerTest.java b/backend/src/test/java/com/krrishg/controller/DeploymentControllerTest.java index 56f5aad..ce36974 100644 --- a/backend/src/test/java/com/krrishg/controller/DeploymentControllerTest.java +++ b/backend/src/test/java/com/krrishg/controller/DeploymentControllerTest.java @@ -3,6 +3,8 @@ 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; @@ -13,6 +15,7 @@ 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; @@ -31,6 +34,9 @@ class DeploymentControllerTest { @MockBean private DeploymentService deploymentService; + @MockBean + private SiteRepository siteRepository; + private UUID userId; private UUID siteId; private UserPrincipal principal; @@ -46,6 +52,13 @@ class DeploymentControllerTest { 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 diff --git a/backend/src/test/java/com/krrishg/service/DomainServiceTest.java b/backend/src/test/java/com/krrishg/service/DomainServiceTest.java index 92f8796..6938cff 100644 --- a/backend/src/test/java/com/krrishg/service/DomainServiceTest.java +++ b/backend/src/test/java/com/krrishg/service/DomainServiceTest.java @@ -2,6 +2,7 @@ package com.krrishg.service; import com.krrishg.model.Site; import com.krrishg.model.SiteStatus; +import com.krrishg.support.FakeSelfHostedConfigRepository; import com.krrishg.support.FakeSiteRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -16,12 +17,14 @@ class DomainServiceTest { private static final String SITE_DOMAIN = "indie.example.com"; private FakeSiteRepository siteRepository; + private FakeSelfHostedConfigRepository selfHostedConfigRepository; private DomainService domainService; @BeforeEach void setUp() { siteRepository = new FakeSiteRepository(); - domainService = new DomainService(siteRepository, SITE_DOMAIN); + selfHostedConfigRepository = new FakeSelfHostedConfigRepository(); + domainService = new DomainService(siteRepository, selfHostedConfigRepository, SITE_DOMAIN); } @Test diff --git a/backend/src/test/java/com/krrishg/support/FakeSelfHostedConfigRepository.java b/backend/src/test/java/com/krrishg/support/FakeSelfHostedConfigRepository.java new file mode 100644 index 0000000..98b510a --- /dev/null +++ b/backend/src/test/java/com/krrishg/support/FakeSelfHostedConfigRepository.java @@ -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 + 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 findBySiteId(UUID siteId) { + return store.values().stream() + .filter(c -> siteId.equals(c.getSiteId())) + .findFirst(); + } + + @Override + public Optional 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())); + } +} diff --git a/frontend/src/app/core/models/deployment.ts b/frontend/src/app/core/models/deployment.ts index 91e9381..271af52 100644 --- a/frontend/src/app/core/models/deployment.ts +++ b/frontend/src/app/core/models/deployment.ts @@ -23,3 +23,27 @@ export interface PublishResponse { message: 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; +} diff --git a/frontend/src/app/core/models/site.ts b/frontend/src/app/core/models/site.ts index 05e3178..f31cdce 100644 --- a/frontend/src/app/core/models/site.ts +++ b/frontend/src/app/core/models/site.ts @@ -7,6 +7,7 @@ export interface SiteResponse { publishedUrl: string | null; themeConfig: Record; status: 'DRAFT' | 'PUBLISHED'; + deploymentTarget: 'GIT_PAGES' | 'SELF_HOSTED' | 'NONE'; publishedAt: string | null; createdAt: string; updatedAt: string; @@ -16,5 +17,6 @@ export interface SiteRequest { name: string; description: string; subdomain: string; + deploymentTarget?: string; themeConfig?: Record; } diff --git a/frontend/src/app/core/services/site.service.ts b/frontend/src/app/core/services/site.service.ts index fbf09a4..527e409 100644 --- a/frontend/src/app/core/services/site.service.ts +++ b/frontend/src/app/core/services/site.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { SiteResponse, SiteRequest } from '../models/site'; +import { SelfHostedConfigRequest, SelfHostedConfigResponse } from '../models/deployment'; @Injectable({ providedIn: 'root' }) export class SiteService { @@ -26,4 +27,18 @@ export class SiteService { delete(id: string): Observable { return this.http.delete(`/api/sites/${id}`); } + + getSelfHostedConfig(id: string): Observable { + return this.http.get( + `/api/sites/${id}/self-hosted` + ); + } + + saveSelfHostedConfig(id: string, config: SelfHostedConfigRequest): Observable { + return this.http.put(`/api/sites/${id}/self-hosted`, config); + } + + deleteSelfHostedConfig(id: string): Observable { + return this.http.delete(`/api/sites/${id}/self-hosted`); + } } diff --git a/frontend/src/app/llm-workspace/settings/settings.component.ts b/frontend/src/app/llm-workspace/settings/settings.component.ts index f254a9b..49b9c74 100644 --- a/frontend/src/app/llm-workspace/settings/settings.component.ts +++ b/frontend/src/app/llm-workspace/settings/settings.component.ts @@ -1,5 +1,5 @@ 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 { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { MatToolbarModule } from '@angular/material/toolbar'; @@ -7,6 +7,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; import { MatCardModule } from '@angular/material/card'; import { MatDividerModule } from '@angular/material/divider'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; @@ -15,16 +16,16 @@ import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { SiteService } from '../../core/services/site.service'; import { DeploymentService } from '../../core/services/deployment.service'; -import { SiteResponse } from '../../core/models/site'; +import { SelfHostedConfigResponse } from '../../core/models/deployment'; @Component({ selector: 'app-settings', standalone: true, imports: [ - NgIf, NgFor, DatePipe, FormsModule, RouterModule, + NgIf, NgFor, NgSwitch, NgSwitchCase, NgSwitchDefault, DatePipe, FormsModule, RouterModule, MatToolbarModule, MatButtonModule, MatIconModule, - MatInputModule, MatFormFieldModule, MatCardModule, - MatDividerModule, MatSnackBarModule, MatProgressSpinnerModule, + MatInputModule, MatFormFieldModule, MatSelectModule, + MatCardModule, MatDividerModule, MatSnackBarModule, MatProgressSpinnerModule, ], template: `
@@ -66,6 +67,38 @@ import { SiteResponse } from '../../core/models/site'; + + +

Deployment Target

+

+ Choose where your site is published. Git push always runs if a remote is configured. +

+ + Target + + None + GitHub / GitLab Pages + Self-Hosted Server + + +
+
+ + Git Remote: {{ hasGitRemote ? 'Connected' : 'Not configured' }} + +
+
+ + Self-Hosted: {{ hasSelfHostedConfig ? 'Configured' : 'Not configured' }} +
+
+
+
+

Export

@@ -92,7 +125,13 @@ import { SiteResponse } from '../../core/models/site';

- 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 + + on GitHub/GitLab Pages + on your own server + . + + . The latest version will be rendered and deployed.

Your site is live at {{ liveUrl }} @@ -114,6 +153,66 @@ import { SiteResponse } from '../../core/models/site'; + + +

Self-Hosted Server

+
+ + Domain + + +
+ + SSH Host + + + + Port + + +
+ + SSH User + + + + Deploy Path + + Use {{ '{' }}domain{{ '}' }} as placeholder: /var/www/{{ '{' }}domain{{ '}' }}/public + + + Nginx Sites Path + + Path to nginx sites-available directory + +
+
Public Key
+

+ Add this key to ~/.ssh/authorized_keys on your server for the SSH user above. +

+
{{ shPublicKey }}
+ +
+

+ Last deployed: {{ shDeployedAt }} +

+
+ + +
+
+ + +
@@ -124,7 +223,7 @@ import { SiteResponse } from '../../core/models/site';

- 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.

@@ -147,10 +246,25 @@ export class SettingsComponent implements OnInit, OnDestroy { siteSubdomain = ''; siteStatus: 'DRAFT' | 'PUBLISHED' = 'DRAFT'; liveUrl = ''; + deploymentTarget: 'GIT_PAGES' | 'SELF_HOSTED' | 'NONE' = 'NONE'; + hasGitRemote = false; + hasSelfHostedConfig = false; saving = false; exporting = false; publishing = false; 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(); ngOnInit(): void { @@ -173,11 +287,64 @@ export class SettingsComponent implements OnInit, OnDestroy { this.siteSubdomain = site.subdomain || ''; this.siteStatus = site.status; this.liveUrl = site.publishedUrl || ''; + this.deploymentTarget = site.deploymentTarget || 'NONE'; + this.loadGitStatus(); + this.loadSelfHostedConfig(); }, 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 { if (!this.siteId || !this.siteName.trim()) return; this.saving = true; @@ -185,6 +352,7 @@ export class SettingsComponent implements OnInit, OnDestroy { name: this.siteName, description: this.siteDescription, subdomain: this.siteSubdomain, + deploymentTarget: this.deploymentTarget, }).pipe(takeUntil(this.destroy$)) .subscribe({ 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 { if (!this.siteId) return; 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 { if (this.siteId) this.router.navigate(['/llm', this.siteId]); }