publish site to custom server

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

View File

@@ -0,0 +1,30 @@
package com.krrishg.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@Component
public class SchemaMigration {
private static final Logger log = LoggerFactory.getLogger(SchemaMigration.class);
private final JdbcTemplate jdbcTemplate;
public SchemaMigration(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@EventListener(ApplicationReadyEvent.class)
public void migrate() {
try {
jdbcTemplate.execute("ALTER TABLE self_hosted_configs DROP COLUMN IF EXISTS encrypted_ssh_key");
log.info("Dropped deprecated column encrypted_ssh_key from self_hosted_configs");
} catch (Exception e) {
log.warn("Could not drop encrypted_ssh_key column: {}", e.getMessage());
}
}
}

View File

@@ -1,6 +1,8 @@
package com.krrishg.controller;
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;
}
}

View File

@@ -0,0 +1,126 @@
package com.krrishg.controller;
import com.krrishg.config.UserPrincipal;
import com.krrishg.dto.SelfHostedConfigRequest;
import com.krrishg.dto.SelfHostedConfigResponse;
import com.krrishg.model.SelfHostedConfig;
import com.krrishg.model.Site;
import com.krrishg.repository.SelfHostedConfigRepository;
import com.krrishg.repository.SiteRepository;
import com.krrishg.service.Deployer;
import com.krrishg.service.KeyManager;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/sites/{id}/self-hosted")
public class SelfHostedController {
private final SiteRepository siteRepository;
private final SelfHostedConfigRepository selfHostedConfigRepository;
private final Deployer deployer;
private final KeyManager keyManager;
public SelfHostedController(SiteRepository siteRepository,
SelfHostedConfigRepository selfHostedConfigRepository,
Deployer deployer,
KeyManager keyManager) {
this.siteRepository = siteRepository;
this.selfHostedConfigRepository = selfHostedConfigRepository;
this.deployer = deployer;
this.keyManager = keyManager;
}
@GetMapping
public ResponseEntity<?> getConfig(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID id) {
findSiteForUser(id, principal.id());
var config = selfHostedConfigRepository.findBySiteId(id);
if (config.isPresent()) {
String publicKey = keyManager.getPublicKey(id.toString());
return ResponseEntity.ok(SelfHostedConfigResponse.from(config.get(), publicKey));
}
return ResponseEntity.ok(Map.of("configured", false));
}
@Transactional
@PutMapping
public ResponseEntity<?> saveConfig(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID id,
@Valid @RequestBody SelfHostedConfigRequest request) {
Site site = findSiteForUser(id, principal.id());
selfHostedConfigRepository.findBySiteId(id).ifPresent(existing ->
selfHostedConfigRepository.delete(existing));
String deployPath = request.getDeployPath() != null && !request.getDeployPath().isBlank()
? request.getDeployPath()
: "/var/www/{domain}/public";
String nginxSitesPath = request.getNginxSitesPath() != null && !request.getNginxSitesPath().isBlank()
? request.getNginxSitesPath()
: "/etc/nginx/sites-available";
SelfHostedConfig config = SelfHostedConfig.builder()
.siteId(id)
.domain(request.getDomain())
.sshHost(request.getSshHost())
.sshPort(request.getSshPort())
.sshUser(request.getSshUser())
.deployPath(deployPath)
.nginxSitesPath(nginxSitesPath)
.build();
config = selfHostedConfigRepository.save(config);
String publicKey = keyManager.getPublicKey(id.toString());
return ResponseEntity.ok(SelfHostedConfigResponse.from(config, publicKey));
}
@Transactional
@DeleteMapping
public ResponseEntity<?> removeConfig(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID id) {
Site site = findSiteForUser(id, principal.id());
SelfHostedConfig config = selfHostedConfigRepository.findBySiteId(id).orElse(null);
if (config != null) {
try {
deployer.remove(config);
} catch (Exception e) {
}
selfHostedConfigRepository.delete(config);
}
return ResponseEntity.ok(Map.of("message", "Self-hosted configuration removed"));
}
@Transactional
@PostMapping("/deploy")
public ResponseEntity<?> deploy(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable UUID id) {
return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED)
.body(Map.of("error", "Use POST /api/sites/{id}/publish instead"));
}
private Site findSiteForUser(UUID siteId, UUID userId) {
Site site = siteRepository.findById(siteId)
.orElseThrow(() -> new IllegalArgumentException("Site not found"));
if (!site.getUserId().equals(userId)) {
throw new IllegalArgumentException("Site not found");
}
return site;
}
}

View File

@@ -41,11 +41,14 @@ public class SiteController {
throw new IllegalArgumentException("Subdomain already taken");
}
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;
}
}
}

View File

@@ -0,0 +1,23 @@
package com.krrishg.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class SelfHostedConfigRequest {
@NotBlank(message = "Domain is required")
private String domain;
@NotBlank(message = "SSH host is required")
private String sshHost;
private int sshPort = 22;
@NotBlank(message = "SSH user is required")
private String sshUser;
private String deployPath;
private String nginxSitesPath;
}

View File

@@ -0,0 +1,45 @@
package com.krrishg.dto;
import com.krrishg.model.SelfHostedConfig;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.UUID;
@Data
@AllArgsConstructor
@Builder
public class SelfHostedConfigResponse {
private UUID id;
private UUID siteId;
private String domain;
private String sshHost;
private int sshPort;
private String sshUser;
private String deployPath;
private String nginxSitesPath;
private String publicKey;
private LocalDateTime deployedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public static SelfHostedConfigResponse from(SelfHostedConfig config, String publicKey) {
return SelfHostedConfigResponse.builder()
.id(config.getId())
.siteId(config.getSiteId())
.domain(config.getDomain())
.sshHost(config.getSshHost())
.sshPort(config.getSshPort())
.sshUser(config.getSshUser())
.deployPath(config.getDeployPath())
.nginxSitesPath(config.getNginxSitesPath())
.publicKey(publicKey)
.deployedAt(config.getDeployedAt())
.createdAt(config.getCreatedAt())
.updatedAt(config.getUpdatedAt())
.build();
}
}

View File

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

View File

@@ -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())

View File

@@ -0,0 +1,65 @@
package com.krrishg.model;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "self_hosted_configs")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SelfHostedConfig {
@Id
private UUID id;
@Column(nullable = false, unique = true)
private UUID siteId;
@Column(nullable = false)
private String domain;
@Column(nullable = false)
private String sshHost;
@Column(nullable = false)
@Builder.Default
private int sshPort = 22;
@Column(nullable = false)
private String sshUser;
@Column(nullable = false)
@Builder.Default
private String deployPath = "/var/www/{domain}/public";
@Column(nullable = false)
@Builder.Default
private String nginxSitesPath = "/etc/nginx/sites-available";
private LocalDateTime deployedAt;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private LocalDateTime updatedAt;
@PrePersist
void onCreate() {
if (id == null) {
id = UUID.randomUUID();
}
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -39,6 +39,14 @@ public class Site {
@Builder.Default
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) {

View File

@@ -0,0 +1,18 @@
package com.krrishg.repository;
import com.krrishg.model.SelfHostedConfig;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface SelfHostedConfigRepository extends JpaRepository<SelfHostedConfig, UUID> {
Optional<SelfHostedConfig> findBySiteId(UUID siteId);
Optional<SelfHostedConfig> findByDomain(String domain);
boolean existsBySiteId(UUID siteId);
boolean existsByDomain(String domain);
}

View File

@@ -0,0 +1,13 @@
package com.krrishg.service;
import com.krrishg.model.SelfHostedConfig;
import com.krrishg.service.RenderService.SiteOutput;
public interface Deployer {
String deploy(SiteOutput output, SelfHostedConfig config) throws Exception;
void remove(SelfHostedConfig config) throws Exception;
String buildUrl(SelfHostedConfig config);
}

View File

@@ -2,10 +2,14 @@ package com.krrishg.service;
import com.krrishg.dto.SiteStructure;
import com.krrishg.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);

View File

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

View File

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

View File

@@ -0,0 +1,162 @@
package com.krrishg.service;
import com.krrishg.model.SelfHostedConfig;
import com.krrishg.service.RenderService.SiteOutput;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.Map;
@Component
public class SshRsyncDeployer implements Deployer {
private static final Logger log = LoggerFactory.getLogger(SshRsyncDeployer.class);
private final KeyManager keyManager;
public SshRsyncDeployer(KeyManager keyManager) {
this.keyManager = keyManager;
}
@Override
public String deploy(SiteOutput output, SelfHostedConfig config) throws Exception {
Path tempDir = Files.createTempDirectory("indie-deploy-" + config.getSiteId());
try {
String resolvedDeployPath = config.getDeployPath().replace("{domain}", config.getDomain());
String resolvedNginxPath = config.getNginxSitesPath().replace("{domain}", config.getDomain());
writeSiteFiles(tempDir, output);
String keyFile = keyManager.getPrivateKeyPath(config.getSiteId().toString()).toString();
ssh(keyFile, config, "mkdir -p " + resolvedDeployPath);
rsync(tempDir.resolve("site"), keyFile, config, resolvedDeployPath);
String nginxConfig = buildNginxConfig(config.getDomain(), resolvedDeployPath);
Path nginxConfPath = tempDir.resolve(config.getDomain());
Files.writeString(nginxConfPath, nginxConfig);
String remoteTemp = "/tmp/" + config.getDomain();
rsyncSingleFile(nginxConfPath, keyFile, config, "/tmp", config.getDomain());
ssh(keyFile, config,
"sudo cp " + remoteTemp + " " + resolvedNginxPath + "/" + config.getDomain()
+ " && rm " + remoteTemp
+ " && sudo ln -sf " + resolvedNginxPath + "/" + config.getDomain()
+ " /etc/nginx/sites-enabled/ && sudo nginx -s reload");
log.info("Deployed site {} to {} ({}", config.getSiteId(), config.getDomain(), resolvedDeployPath);
return buildUrl(config);
} finally {
deleteDirectory(tempDir);
}
}
@Override
public void remove(SelfHostedConfig config) throws Exception {
Path tempDir = Files.createTempDirectory("indie-deploy-remove-" + config.getSiteId());
try {
String resolvedDeployPath = config.getDeployPath().replace("{domain}", config.getDomain());
String keyFile = keyManager.getPrivateKeyPath(config.getSiteId().toString()).toString();
ssh(keyFile, config, "rm -rf " + resolvedDeployPath);
String resolvedNginxPath = config.getNginxSitesPath().replace("{domain}", config.getDomain());
ssh(keyFile, config,
"sudo rm -f " + resolvedNginxPath + "/" + config.getDomain()
+ " /etc/nginx/sites-enabled/" + config.getDomain()
+ " && sudo nginx -s reload");
log.info("Removed deployment for site {} ({})", config.getSiteId(), config.getDomain());
} finally {
deleteDirectory(tempDir);
}
}
@Override
public String buildUrl(SelfHostedConfig config) {
return "https://" + config.getDomain() + "/";
}
private void writeSiteFiles(Path tempDir, SiteOutput output) throws IOException {
Path siteDir = tempDir.resolve("site");
Files.createDirectories(siteDir);
for (Map.Entry<String, byte[]> entry : output.asFileMap().entrySet()) {
Path filePath = siteDir.resolve(entry.getKey());
Files.createDirectories(filePath.getParent());
Files.write(filePath, entry.getValue());
}
}
private void ssh(String keyFile, SelfHostedConfig config, String command) throws Exception {
String sshCmd = String.format(
"ssh -i %s -p %d -o StrictHostKeyChecking=accept-new %s@%s \"%s\"",
keyFile, config.getSshPort(), config.getSshUser(), config.getSshHost(), command
);
execute(sshCmd);
}
private void rsync(Path sourceDir, String keyFile, SelfHostedConfig config, String targetDir) throws Exception {
String rsyncCmd = String.format(
"rsync -avz --delete -e 'ssh -p %d -i %s -o StrictHostKeyChecking=accept-new' %s/ %s@%s:%s/",
config.getSshPort(), keyFile, sourceDir.toString(),
config.getSshUser(), config.getSshHost(), targetDir
);
execute(rsyncCmd);
}
private void rsyncSingleFile(Path sourceFile, String keyFile, SelfHostedConfig config,
String targetDir, String filename) throws Exception {
ssh(keyFile, config, "mkdir -p " + targetDir);
String rsyncCmd = String.format(
"rsync -avz -e 'ssh -p %d -i %s -o StrictHostKeyChecking=accept-new' %s %s@%s:%s/%s",
config.getSshPort(), keyFile, sourceFile.toString(),
config.getSshUser(), config.getSshHost(), targetDir, filename
);
execute(rsyncCmd);
}
private String buildNginxConfig(String domain, String rootPath) {
return "server {\n"
+ " listen 80;\n"
+ " server_name " + domain + ";\n"
+ " root " + rootPath + ";\n"
+ " index index.html;\n"
+ " location / {\n"
+ " try_files $uri $uri/ /index.html;\n"
+ " }\n"
+ "}\n";
}
private void execute(String command) throws Exception {
ProcessBuilder pb = new ProcessBuilder("sh", "-c", command);
pb.redirectErrorStream(true);
Process process = pb.start();
int exitCode = process.waitFor();
if (exitCode != 0) {
String output = new String(process.getInputStream().readAllBytes());
throw new RuntimeException("Command failed (exit " + exitCode + "): " + command + "\n" + output);
}
}
private void deleteDirectory(Path path) throws IOException {
if (Files.exists(path)) {
try (var walk = Files.walk(path)) {
walk.sorted(Comparator.reverseOrder())
.forEach(p -> {
try {
Files.deleteIfExists(p);
} catch (IOException e) {
log.warn("Failed to delete {}", p);
}
});
}
}
}
}

View File

@@ -3,6 +3,8 @@ package com.krrishg.controller;
import com.krrishg.config.GlobalExceptionHandler;
import com.krrishg.config.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

View File

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

View File

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