Skip to content

Commit 2d7c87e

Browse files
committed
Add preview protected with auth
1 parent 122cf87 commit 2d7c87e

4 files changed

Lines changed: 284 additions & 8 deletions

File tree

src/com/potomushto/statik/cms/CmsRoutes.kt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import io.ktor.http.Cookie
55
import io.ktor.http.HttpStatusCode
66
import io.ktor.server.application.ApplicationCall
77
import io.ktor.server.request.receiveText
8+
import io.ktor.server.response.respondFile
89
import io.ktor.server.response.respondRedirect
910
import io.ktor.server.response.respondText
1011
import io.ktor.server.routing.Routing
@@ -134,12 +135,51 @@ fun Routing.installCmsRoutes(
134135
call.respondRedirect(basePath)
135136
}
136137

138+
suspend fun renderPreview(call: ApplicationCall, requestedPath: String) {
139+
val session = if (authService?.isEnabled() == true) {
140+
requireHtmlSession(call, basePath, authService) ?: return
141+
} else {
142+
null
143+
}
144+
145+
val service = requireCmsService(
146+
call = call,
147+
cmsServiceProvider = cmsServiceProvider,
148+
workspaceManager = workspaceManager,
149+
authService = authService,
150+
session = session
151+
) ?: return
152+
153+
val resolvedFile = service.resolvePreviewFile(requestedPath)
154+
if (resolvedFile != null) {
155+
call.respondFile(resolvedFile.toFile())
156+
return
157+
}
158+
159+
call.respondText(text = "404: Page Not Found", status = HttpStatusCode.NotFound)
160+
}
161+
137162
post("$basePath/logout") {
138163
authService?.clearSession(call.request.cookies[CMS_SESSION_COOKIE])
139164
call.response.cookies.append(expiredSessionCookie(basePath))
140165
call.respondText("logged out", ContentType.Text.Plain)
141166
}
142167

168+
get("$basePath/preview") {
169+
renderPreview(call, "")
170+
}
171+
172+
get("$basePath/preview/") {
173+
renderPreview(call, "")
174+
}
175+
176+
get("$basePath/preview/{path...}") {
177+
val requestedPath = call.parameters.getAll("path")
178+
?.joinToString("/")
179+
.orEmpty()
180+
renderPreview(call, requestedPath)
181+
}
182+
143183
get("$basePath/styles.css") {
144184
call.respondText(CmsWebAssets.stylesCss, ContentType.Text.CSS)
145185
}

src/com/potomushto/statik/cms/CmsService.kt

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ class CmsService(
2323
) {
2424
private val lock = Any()
2525
private val basePath = normalizeBasePath(config.cms.basePath)
26+
private val previewConfig = config.copy(
27+
theme = config.theme.copy(output = PREVIEW_OUTPUT_DIRECTORY)
28+
)
29+
private val previewGenerator = SiteGenerator(
30+
rootPath = rootPath.toString(),
31+
config = previewConfig,
32+
baseUrlOverride = "$basePath/preview/",
33+
isDevelopment = true
34+
)
35+
private val previewAssetManager = AssetManager(rootPath.toString(), previewConfig, FileWalker(rootPath.toString()))
36+
private var previewInitialized = false
2637

2738
init {
2839
repository.initialize()
@@ -34,6 +45,13 @@ class CmsService(
3445
}
3546
}
3647

48+
fun resolvePreviewFile(requestedPath: String): Path? {
49+
synchronized(lock) {
50+
ensurePreviewInitializedLocked()
51+
return resolveGeneratedFile(previewOutputRoot(), requestedPath)
52+
}
53+
}
54+
3755
fun list(type: CmsContentType? = null): CmsContentListResponse {
3856
val items = repository.list(type)
3957
.sortedWith(contentOrdering())
@@ -94,11 +112,20 @@ class CmsService(
94112
markContentDeleted(previousEntry.sourcePath, previousEntry.lastSyncedAt, previousEntry.dirty)
95113
if (previousEntry.outputPath != saved.outputPath) {
96114
deleteGeneratedContent(previousEntry.outputPath)
115+
if (previewInitialized) {
116+
deleteGeneratedPreviewContent(previousEntry.outputPath)
117+
}
97118
}
98119
}
99120

100121
repository.upsert(dirtyEntry)
101122
generator.regenerate(listOf(rootPath.resolve(saved.sourcePath)))
123+
if (saved.type == CmsContentType.POST && isDraft(saved.metadata)) {
124+
deleteGeneratedContent(saved.outputPath)
125+
}
126+
if (previewInitialized) {
127+
previewGenerator.regenerate(listOf(rootPath.resolve(saved.sourcePath)))
128+
}
102129

103130
val syncResponse = if (request.sync || config.cms.autoSyncOnSave) {
104131
syncLocked(request.commitMessage, null, accessToken)
@@ -126,6 +153,9 @@ class CmsService(
126153
val existing = repository.findMedia(composeMediaPath(targetDirectory, request.fileName))
127154
val uploaded = mediaFileService.upload(normalizedRequest)
128155
assetManager.copySingleAsset(rootPath.resolve(uploaded.sourcePath).normalize())
156+
if (previewInitialized) {
157+
previewAssetManager.copySingleAsset(rootPath.resolve(uploaded.sourcePath).normalize())
158+
}
129159
repository.upsertMedia(
130160
uploaded.copy(
131161
dirty = true,
@@ -151,6 +181,9 @@ class CmsService(
151181
mutation.deletedPaths.forEach { sourcePath ->
152182
markMediaDeleted(sourcePath)
153183
assetManager.deleteSingleAsset(sourcePath)
184+
if (previewInitialized) {
185+
previewAssetManager.deleteSingleAsset(sourcePath)
186+
}
154187
}
155188

156189
mutation.updatedEntries.forEach { entry ->
@@ -164,6 +197,9 @@ class CmsService(
164197
)
165198
)
166199
assetManager.copySingleAsset(rootPath.resolve(entry.sourcePath).normalize())
200+
if (previewInitialized) {
201+
previewAssetManager.copySingleAsset(rootPath.resolve(entry.sourcePath).normalize())
202+
}
167203
}
168204

169205
return CmsMediaMutationResponse(
@@ -184,6 +220,9 @@ class CmsService(
184220
mutation.deletedPaths.forEach { sourcePath ->
185221
markMediaDeleted(sourcePath)
186222
assetManager.deleteSingleAsset(sourcePath)
223+
if (previewInitialized) {
224+
previewAssetManager.deleteSingleAsset(sourcePath)
225+
}
187226
}
188227

189228
return CmsMediaMutationResponse(
@@ -224,6 +263,7 @@ class CmsService(
224263
repository.replaceFromScan(scannedEntries)
225264
val scannedMedia = mediaFileService.scanAll()
226265
repository.replaceMediaFromScan(scannedMedia)
266+
previewInitialized = false
227267
return CmsRefreshResponse(
228268
items = repository.count() + repository.mediaCount(),
229269
dirty = repository.dirtyCount() + repository.mediaDirtyCount()
@@ -286,7 +326,14 @@ class CmsService(
286326
}
287327

288328
private fun deleteGeneratedContent(outputPath: String) {
289-
val outputRoot = rootPath.resolve(config.theme.output).normalize()
329+
deleteGeneratedPath(rootPath.resolve(config.theme.output).normalize(), outputPath)
330+
}
331+
332+
private fun deleteGeneratedPreviewContent(outputPath: String) {
333+
deleteGeneratedPath(previewOutputRoot(), outputPath)
334+
}
335+
336+
private fun deleteGeneratedPath(outputRoot: Path, outputPath: String) {
290337
val target = if (outputPath.isBlank()) {
291338
outputRoot.resolve("index.html")
292339
} else {
@@ -310,6 +357,28 @@ class CmsService(
310357
}
311358
}
312359

360+
private fun ensurePreviewInitializedLocked() {
361+
if (previewInitialized) {
362+
return
363+
}
364+
365+
val outputRoot = previewOutputRoot()
366+
if (Files.exists(outputRoot)) {
367+
Files.walk(outputRoot).use { stream ->
368+
stream.sorted(Comparator.reverseOrder()).forEach { candidate ->
369+
Files.deleteIfExists(candidate)
370+
}
371+
}
372+
}
373+
374+
previewGenerator.generate()
375+
previewInitialized = true
376+
}
377+
378+
private fun previewOutputRoot(): Path {
379+
return rootPath.resolve(previewConfig.theme.output).normalize()
380+
}
381+
313382
private fun pruneEmptyGeneratedDirectories(start: Path?, stopAt: Path) {
314383
var current = start
315384
while (current != null && current.startsWith(stopAt) && current != stopAt) {
@@ -389,6 +458,10 @@ class CmsService(
389458
return left.sourcePath.compareTo(right.sourcePath)
390459
}
391460

461+
private fun isDraft(metadata: Map<String, Any?>): Boolean {
462+
return metadata["draft"]?.toString()?.lowercase() in setOf("true", "yes", "1")
463+
}
464+
392465
private fun String?.parseCmsDateOrNull(): LocalDateTime? {
393466
return runCatching { this?.let(LocalDateTime::parse) }.getOrNull()
394467
}
@@ -398,6 +471,30 @@ class CmsService(
398471
return if (path.isAbsolute) path else rootPath.resolve(path).normalize()
399472
}
400473

474+
private fun resolveGeneratedFile(outputRoot: Path, requestedPath: String): Path? {
475+
val normalizedRoot = outputRoot.toAbsolutePath().normalize()
476+
if (!Files.exists(normalizedRoot)) {
477+
return null
478+
}
479+
480+
val relativePath = requestedPath.trim().removePrefix("/").removeSuffix("/")
481+
val candidates = if (relativePath.isBlank()) {
482+
listOf(normalizedRoot.resolve("index.html"))
483+
} else {
484+
buildList {
485+
val direct = normalizedRoot.resolve(relativePath).normalize()
486+
add(direct)
487+
if (!relativePath.substringAfterLast('/', "").contains('.')) {
488+
add(direct.resolve("index.html").normalize())
489+
}
490+
}
491+
}
492+
493+
return candidates.firstOrNull { candidate ->
494+
candidate.startsWith(normalizedRoot) && Files.isRegularFile(candidate)
495+
}
496+
}
497+
401498
private fun maxOfNullable(left: Long?, right: Long?): Long? {
402499
return when {
403500
left == null -> right
@@ -414,5 +511,7 @@ class CmsService(
414511
private fun normalizeSourcePath(sourcePath: String): String {
415512
return sourcePath.trim().removePrefix("/").replace('\\', '/')
416513
}
514+
515+
private const val PREVIEW_OUTPUT_DIRECTORY = ".statik/cms-preview"
417516
}
418517
}

src/com/potomushto/statik/cms/CmsWebAssets.kt

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,13 +1179,48 @@ internal object CmsWebAssets {
11791179
: "/" + normalized + "/";
11801180
}
11811181
1182-
function setPreviewPath(path) {
1183-
state.currentPreviewPath = path || "/";
1182+
function previewHrefFromSitePath(path) {
1183+
const normalized = String(path || "").trim();
1184+
if (!normalized || normalized === "/") {
1185+
return basePath + "/preview/";
1186+
}
1187+
return basePath + "/preview" + (normalized.startsWith("/") ? normalized : "/" + normalized);
1188+
}
1189+
1190+
function setPreviewHref(href) {
1191+
state.currentPreviewPath = href || previewHrefFromSitePath("/");
11841192
if (elements.previewLink) {
11851193
elements.previewLink.href = state.currentPreviewPath;
11861194
}
11871195
}
11881196
1197+
function setContentPreviewPath(path) {
1198+
setPreviewHref(previewHrefFromSitePath(path));
1199+
}
1200+
1201+
async function openPreview(event) {
1202+
event.preventDefault();
1203+
let previewWindow = null;
1204+
try {
1205+
previewWindow = window.open("", "_blank");
1206+
if (previewWindow) {
1207+
previewWindow.opener = null;
1208+
}
1209+
await flushAutosave();
1210+
const nextHref = state.currentPreviewPath || previewHrefFromSitePath("/");
1211+
if (previewWindow) {
1212+
previewWindow.location.replace(nextHref);
1213+
} else {
1214+
window.open(nextHref, "_blank", "noopener,noreferrer");
1215+
}
1216+
} catch (error) {
1217+
if (previewWindow && !previewWindow.closed) {
1218+
previewWindow.close();
1219+
}
1220+
throw error;
1221+
}
1222+
}
1223+
11891224
function showEmptyEditor() {
11901225
state.mode = "empty";
11911226
state.selected = null;
@@ -1195,7 +1230,7 @@ internal object CmsWebAssets {
11951230
elements.source.value = "";
11961231
setEditorEditable(false);
11971232
lastSavedSnapshot = null;
1198-
setPreviewPath("/");
1233+
setContentPreviewPath("/");
11991234
setEditorHeading("Select a file", "Choose a post, page, or media item from the left.");
12001235
}
12011236
@@ -1439,7 +1474,7 @@ internal object CmsWebAssets {
14391474
elements.source.value = mediaEditorText(sourcePath, kind);
14401475
setEditorEditable(false);
14411476
const mediaItem = kind === "file" ? findMediaItem(sourcePath) : null;
1442-
setPreviewPath(mediaItem && mediaItem.publicPath ? mediaItem.publicPath : "/");
1477+
setPreviewHref(mediaItem && mediaItem.publicPath ? mediaItem.publicPath : previewHrefFromSitePath("/"));
14431478
setEditorHeading(fileNameFromPath(sourcePath), mediaSubtitle(sourcePath, kind));
14441479
renderList();
14451480
renderMediaTree();
@@ -1988,7 +2023,7 @@ internal object CmsWebAssets {
19882023
elements.sourcePath.value = response.sourcePath;
19892024
elements.source.value = serializeDocument(response.frontmatter || "", response.body || "");
19902025
setEditorEditable(true);
1991-
setPreviewPath(previewPathFromOutputPath(response.outputPath));
2026+
setContentPreviewPath(previewPathFromOutputPath(response.outputPath));
19922027
19932028
const subtitle = response.isDraft ? "draft · " + response.title : response.title;
19942029
setEditorHeading(fileNameFromPath(response.sourcePath), subtitle);
@@ -2022,7 +2057,7 @@ internal object CmsWebAssets {
20222057
elements.sourcePath.value = defaultPath(type);
20232058
elements.source.value = serializeDocument(defaultFrontmatter(type), "");
20242059
setEditorEditable(true);
2025-
setPreviewPath(previewPathFromOutputPath(""));
2060+
setContentPreviewPath(previewPathFromOutputPath(""));
20262061
setEditorHeading(fileNameFromPath(elements.sourcePath.value), "new file");
20272062
renderList();
20282063
renderMediaTree();
@@ -2059,7 +2094,7 @@ internal object CmsWebAssets {
20592094
20602095
state.selected = response.item.sourcePath;
20612096
state.renamingContentPath = null;
2062-
setPreviewPath(previewPathFromOutputPath(response.item.outputPath));
2097+
setContentPreviewPath(previewPathFromOutputPath(response.item.outputPath));
20632098
log((autosave ? "Autosaved " : "Saved ") + response.item.sourcePath + " and rebuilt the site.");
20642099
if (response.sync) {
20652100
updateSyncState(response.sync);
@@ -2266,6 +2301,11 @@ internal object CmsWebAssets {
22662301
elements.railToggle.addEventListener("click", () => {
22672302
setRailCollapsed(!state.railCollapsed);
22682303
});
2304+
if (elements.previewLink) {
2305+
elements.previewLink.addEventListener("click", event => {
2306+
openPreview(event).catch(error => log(error.message));
2307+
});
2308+
}
22692309
elements.logsButton.addEventListener("click", () => openLogs());
22702310
elements.closeLogs.addEventListener("click", () => closeLogs());
22712311
elements.uploadMedia.addEventListener("click", async () => {
@@ -2385,6 +2425,7 @@ internal object CmsWebAssets {
23852425
}
23862426
23872427
setActiveContentTab(state.activeContentTab);
2428+
setContentPreviewPath("/");
23882429
try {
23892430
setRailCollapsed(window.localStorage.getItem(RAIL_COLLAPSED_KEY) === "1");
23902431
} catch (_error) {

0 commit comments

Comments
 (0)