publish site to custom server
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<SiteResponse> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,6 @@ public class SiteRequest {
|
||||
private String description;
|
||||
|
||||
private String subdomain;
|
||||
|
||||
private String deploymentTarget;
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
13
backend/src/main/java/com/krrishg/service/Deployer.java
Normal file
13
backend/src/main/java/com/krrishg/service/Deployer.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import com.krrishg.service.RenderService.SiteOutput;
|
||||
|
||||
public interface Deployer {
|
||||
|
||||
String deploy(SiteOutput output, SelfHostedConfig config) throws Exception;
|
||||
|
||||
void remove(SelfHostedConfig config) throws Exception;
|
||||
|
||||
String buildUrl(SelfHostedConfig config);
|
||||
}
|
||||
@@ -2,10 +2,14 @@ package com.krrishg.service;
|
||||
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import com.krrishg.model.GitRemote;
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import com.krrishg.model.Site;
|
||||
import com.krrishg.model.Site.DeploymentTarget;
|
||||
import com.krrishg.model.SiteStatus;
|
||||
import com.krrishg.repository.GitRemoteRepository;
|
||||
import com.krrishg.repository.SelfHostedConfigRepository;
|
||||
import com.krrishg.repository.SiteRepository;
|
||||
import com.krrishg.service.RenderService.SiteOutput;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -23,17 +27,23 @@ public class DeploymentService {
|
||||
private final SiteRepository siteRepository;
|
||||
private final LLMService llmService;
|
||||
private final GitRemoteRepository gitRemoteRepository;
|
||||
private final SelfHostedConfigRepository selfHostedConfigRepository;
|
||||
private final Deployer deployer;
|
||||
|
||||
public DeploymentService(RenderService renderService,
|
||||
GitService gitService,
|
||||
SiteRepository siteRepository,
|
||||
LLMService llmService,
|
||||
GitRemoteRepository gitRemoteRepository) {
|
||||
GitRemoteRepository gitRemoteRepository,
|
||||
SelfHostedConfigRepository selfHostedConfigRepository,
|
||||
Deployer deployer) {
|
||||
this.renderService = renderService;
|
||||
this.gitService = gitService;
|
||||
this.siteRepository = siteRepository;
|
||||
this.llmService = llmService;
|
||||
this.gitRemoteRepository = gitRemoteRepository;
|
||||
this.selfHostedConfigRepository = selfHostedConfigRepository;
|
||||
this.deployer = deployer;
|
||||
}
|
||||
|
||||
public String publish(UUID siteId) {
|
||||
@@ -45,19 +55,46 @@ public class DeploymentService {
|
||||
throw new IllegalStateException("No generation versions found for site " + siteId);
|
||||
}
|
||||
|
||||
SiteOutput output = renderService.renderSite(structure);
|
||||
|
||||
GitRemote remote = gitRemoteRepository.findBySiteId(siteId).orElse(null);
|
||||
if (remote == null) {
|
||||
throw new IllegalStateException("No git remote configured. Connect a GitHub or GitLab remote first.");
|
||||
if (remote != null) {
|
||||
gitService.deployToPages(siteId.toString(), remote, structure);
|
||||
}
|
||||
|
||||
String url = gitService.deployToPages(siteId.toString(), remote, structure);
|
||||
SelfHostedConfig selfHostedConfig = selfHostedConfigRepository.findBySiteId(siteId).orElse(null);
|
||||
if (selfHostedConfig != null) {
|
||||
try {
|
||||
deployer.deploy(output, selfHostedConfig);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to deploy to custom server: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
String url = switch (site.getDeploymentTarget()) {
|
||||
case GIT_PAGES -> {
|
||||
if (remote == null) {
|
||||
throw new IllegalStateException(
|
||||
"Deployment target is GIT_PAGES but no git remote is configured");
|
||||
}
|
||||
yield PagesProvider.forRemote(remote).buildPagesUrl(remote);
|
||||
}
|
||||
case SELF_HOSTED -> {
|
||||
if (selfHostedConfig == null) {
|
||||
throw new IllegalStateException(
|
||||
"Deployment target is SELF_HOSTED but no self-hosted config exists");
|
||||
}
|
||||
yield deployer.buildUrl(selfHostedConfig);
|
||||
}
|
||||
case NONE -> null;
|
||||
};
|
||||
|
||||
site.setStatus(SiteStatus.PUBLISHED);
|
||||
site.setPublishedAt(LocalDateTime.now());
|
||||
site.setPublishedUrl(url);
|
||||
siteRepository.save(site);
|
||||
|
||||
log.info("Published site {} to {} at {}", siteId, remote.getProvider(), url);
|
||||
log.info("Published site {}: target={}, url={}", siteId, site.getDeploymentTarget(), url);
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -65,7 +102,19 @@ public class DeploymentService {
|
||||
Site site = siteRepository.findById(siteId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Site not found"));
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<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) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
162
backend/src/main/java/com/krrishg/service/SshRsyncDeployer.java
Normal file
162
backend/src/main/java/com/krrishg/service/SshRsyncDeployer.java
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface SiteResponse {
|
||||
publishedUrl: string | null;
|
||||
themeConfig: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: `
|
||||
<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>
|
||||
|
||||
<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-content class="!p-0">
|
||||
<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>
|
||||
</div>
|
||||
<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 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>
|
||||
@@ -114,6 +153,66 @@ import { SiteResponse } from '../../core/models/site';
|
||||
</mat-card-content>
|
||||
</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-content class="!p-0">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -124,7 +223,7 @@ import { SiteResponse } from '../../core/models/site';
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
@@ -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<void>();
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user