mirror of
https://github.com/andwati/andwati.com.git
synced 2026-10-18 01:12:53 +00:00
feat(cms): implement Git publishing for content changes and add entrypoint script
- Added a Docker entrypoint script to manage content repository cloning and updates. - Updated server configuration to include public URL and proxy settings. - Enhanced content file utilities to return file paths for written entries. - Introduced GitPublisher class for handling Git operations and deployments. - Added tests for Git publishing functionality. - Updated Dockerfile and docker-compose for production setup. - Added new public PGP key and security.txt for contact information. - Created new About and Archive pages in the site. - Updated site configuration to include new navigation links.
This commit is contained in:
+6
-5
@@ -1,11 +1,12 @@
|
||||
/public
|
||||
node_modules
|
||||
**/node_modules
|
||||
.git
|
||||
manimations/*.*
|
||||
/zola
|
||||
/zola-v*.tar.gz
|
||||
/artifacts
|
||||
dist
|
||||
.strapi
|
||||
.tmp
|
||||
build
|
||||
**/dist
|
||||
**/.astro
|
||||
**/.strapi
|
||||
**/.tmp
|
||||
**/build
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Dokploy deployment
|
||||
|
||||
The production deployment is one Dokploy Compose application with two
|
||||
services:
|
||||
|
||||
- `site` builds the Astro app and serves its static output with
|
||||
`static-web-server`.
|
||||
- `cms` runs Strapi with persistent SQLite/admin data and a persistent Git
|
||||
checkout.
|
||||
|
||||
Markdown remains the source of truth. A Strapi edit writes into the checkout,
|
||||
commits and pushes the changed Markdown (plus a generated bookshelf cover when
|
||||
applicable), then calls Dokploy's deploy webhook. The redeploy builds Astro
|
||||
from that new commit.
|
||||
|
||||
## GitHub token
|
||||
|
||||
Create a fine-grained personal access token restricted to the
|
||||
`andwati/andwati.com` repository with:
|
||||
|
||||
- Repository contents: read and write
|
||||
- Metadata: read (automatically included)
|
||||
|
||||
The configured publishing branch must accept direct pushes from the token.
|
||||
Store the token only in Dokploy as `GITHUB_TOKEN`.
|
||||
|
||||
## Dokploy application
|
||||
|
||||
1. Create a Compose application from this repository and select
|
||||
`docker-compose.prod.yml`.
|
||||
2. Configure its source branch to match `CONTENT_GIT_BRANCH` (normally
|
||||
`main`).
|
||||
3. Route the public site domain to the `site` service on port `80`.
|
||||
4. Route the CMS domain (for example `cms.andwati.com`) to the `cms` service
|
||||
on port `1337`.
|
||||
5. Copy the application's deploy webhook URL into
|
||||
`DOKPLOY_DEPLOY_WEBHOOK_URL`.
|
||||
6. Add the environment variables below and deploy.
|
||||
|
||||
Required secrets:
|
||||
|
||||
```dotenv
|
||||
CMS_PUBLIC_URL=https://cms.andwati.com
|
||||
APP_KEYS=<four comma-separated random values>
|
||||
API_TOKEN_SALT=<random value>
|
||||
ADMIN_JWT_SECRET=<random value>
|
||||
TRANSFER_TOKEN_SALT=<random value>
|
||||
JWT_SECRET=<random value>
|
||||
ENCRYPTION_KEY=<random value>
|
||||
GITHUB_TOKEN=<fine-grained GitHub token>
|
||||
DOKPLOY_DEPLOY_WEBHOOK_URL=<Dokploy Compose deploy webhook>
|
||||
```
|
||||
|
||||
Optional publishing settings:
|
||||
|
||||
```dotenv
|
||||
CONTENT_GIT_REPOSITORY=andwati/andwati.com
|
||||
CONTENT_GIT_BRANCH=main
|
||||
CONTENT_GIT_COMMIT_NAME="andwati.com CMS"
|
||||
[email protected]
|
||||
```
|
||||
|
||||
Generate each random secret independently; do not reuse the local development
|
||||
values from `docker-compose.yml`.
|
||||
|
||||
## Persistent data
|
||||
|
||||
The production Compose file declares three named volumes:
|
||||
|
||||
- `cms_database` keeps Strapi's admin users, API tokens, and SQLite cache.
|
||||
- `cms_uploads` keeps files uploaded through Strapi.
|
||||
- `content_checkout` keeps the Git working tree used for publishing.
|
||||
|
||||
Back up `cms_database` and `cms_uploads`. The content checkout is recoverable
|
||||
from Git, but retaining it protects an edit if the process stops between the
|
||||
filesystem write and Git push.
|
||||
|
||||
On startup, the CMS fast-forwards a clean checkout to the configured branch.
|
||||
If it finds uncommitted files, it preserves them and starts without pulling so
|
||||
an interrupted edit is not overwritten. Resolve and push those files from the
|
||||
volume before forcing a reset.
|
||||
|
||||
## Local validation
|
||||
|
||||
Provide temporary values for required interpolation variables, then validate
|
||||
the production manifest:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env.production \
|
||||
-f docker-compose.prod.yml config
|
||||
docker compose --env-file .env.production \
|
||||
-f docker-compose.prod.yml build
|
||||
```
|
||||
|
||||
The existing `docker-compose.yml` remains the local Strapi development setup.
|
||||
+14
-15
@@ -1,21 +1,20 @@
|
||||
# Multi-stage build: generate llms.txt with Node, build the site with Zola,
|
||||
# then serve the static output with static-web-server.
|
||||
#
|
||||
# This is the Dokploy/self-hosted deployment path (the only one - the prior
|
||||
# Cloudflare Pages/Workers path has been retired).
|
||||
# Production image for the Astro site. Build from the workspace root:
|
||||
# docker build -t andwati-site .
|
||||
|
||||
FROM node:22-slim AS llms
|
||||
WORKDIR /project
|
||||
COPY . .
|
||||
RUN node scripts/generate-llms.mjs
|
||||
FROM node:24-bookworm-slim AS build
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
FROM ghcr.io/getzola/zola:v0.23.1 AS zola
|
||||
WORKDIR /project
|
||||
COPY . .
|
||||
COPY --from=llms /project/static/llms.txt static/llms.txt
|
||||
RUN ["/zola", "build"]
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY apps/site/package.json apps/site/package.json
|
||||
COPY apps/cms/package.json apps/cms/package.json
|
||||
COPY apps/site apps/site
|
||||
COPY content content
|
||||
COPY scripts scripts
|
||||
RUN pnpm install --frozen-lockfile --filter andwati-com --filter site
|
||||
RUN pnpm --filter site build
|
||||
|
||||
FROM ghcr.io/static-web-server/static-web-server:2
|
||||
COPY --from=zola /project/public /public
|
||||
COPY --from=build /app/apps/site/dist /public
|
||||
COPY sws.toml /sws.toml
|
||||
EXPOSE 80
|
||||
|
||||
@@ -164,10 +164,12 @@ approved plan for full rationale on each decision referenced below.
|
||||
`from-c-to-machine-code`
|
||||
- [x] Preserve slugs/URLs via redirects — the script generates 301s from
|
||||
`/posts/<slug>/` to `/writings/<slug>/` into `sws.toml`, idempotently
|
||||
- [ ] Migrate `content/archive/`, `content/about.md` equivalents — holding
|
||||
off: the Astro site doesn't have archive/about pages built yet, and
|
||||
"about" copy/framing is exactly the kind of thing you'd want to
|
||||
review/rewrite for the new "digital legacy" identity, not port as-is
|
||||
- [x] Migrate `content/archive/`, `content/about.md` equivalents —
|
||||
`/archive/` groups every published writing by year; `/about/` reframes
|
||||
the legacy copy around the site's "digital legacy" identity while
|
||||
preserving its PGP and security contact details. Both are in the main
|
||||
navigation, and the linked `.well-known` files are copied into Astro's
|
||||
public output
|
||||
- [ ] Verify RSS subscribers and inbound links survive the cutover — can
|
||||
only really be checked once this is deployed live (**needs your
|
||||
input**: this is a production-cutover verification, not a build-time
|
||||
@@ -175,14 +177,17 @@ approved plan for full rationale on each decision referenced below.
|
||||
|
||||
## Infra / deployment
|
||||
|
||||
- [ ] Wire Strapi into the Dokploy deployment alongside the Astro build —
|
||||
**needs your input**: requires your Dokploy access
|
||||
- [ ] Decide build trigger: webhook on Strapi publish → CI rebuild, vs.
|
||||
manual/scheduled rebuild — **needs your input**: your call, not an
|
||||
engineering constraint either way
|
||||
- [ ] Update `Dockerfile`/`sws.toml` for the new build output (currently
|
||||
Zola-specific) — holding off until the migration/cutover is actually
|
||||
ready to ship, so this doesn't drift out of sync in the meantime
|
||||
- [x] Wire Strapi into the Dokploy deployment alongside the Astro build —
|
||||
`docker-compose.prod.yml` defines both production services, persistent
|
||||
CMS/Git volumes, reverse-proxy settings, and secret-only configuration
|
||||
- [x] Build trigger decided and implemented: Strapi commits Markdown and
|
||||
generated covers to Git, pushes the production branch, then calls the
|
||||
Dokploy Compose deploy webhook so Astro rebuilds from the durable commit
|
||||
- [x] Update `Dockerfile`/`sws.toml` for Astro's `apps/site/dist` output,
|
||||
current security headers, legacy redirects, and the custom 404 page
|
||||
- [ ] Launch the production Compose application and configure its domains,
|
||||
secrets, GitHub token, and deploy webhook in Dokploy — repository-side
|
||||
setup is complete in `DEPLOYMENT.md`; host access is still required
|
||||
|
||||
## Cleanup
|
||||
|
||||
|
||||
@@ -6,3 +6,16 @@ ADMIN_JWT_SECRET=tobemodified
|
||||
TRANSFER_TOKEN_SALT=tobemodified
|
||||
JWT_SECRET=tobemodified
|
||||
ENCRYPTION_KEY=tobemodified
|
||||
|
||||
# Production reverse-proxy and Git publishing settings. Git publishing stays
|
||||
# disabled during local development unless explicitly enabled.
|
||||
PUBLIC_URL=http://localhost:1337
|
||||
IS_PROXIED=false
|
||||
GIT_PUBLISH_ENABLED=false
|
||||
CONTENT_REPOSITORY_ROOT=
|
||||
CONTENT_GIT_REPOSITORY=andwati/andwati.com
|
||||
CONTENT_GIT_BRANCH=main
|
||||
CONTENT_GIT_COMMIT_NAME="andwati.com CMS"
|
||||
CONTENT_GIT_COMMIT_EMAIL=[email protected]
|
||||
GITHUB_TOKEN=
|
||||
DOKPLOY_DEPLOY_WEBHOOK_URL=
|
||||
|
||||
+5
-1
@@ -26,8 +26,12 @@ RUN pnpm --filter cms build
|
||||
|
||||
FROM base AS runtime
|
||||
ENV NODE_ENV=production
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /app/node_modules /app/node_modules
|
||||
COPY --from=build /app/apps/cms /app/apps/cms
|
||||
COPY --chmod=755 apps/cms/docker-entrypoint.sh /usr/local/bin/andwati-cms-entrypoint
|
||||
WORKDIR /app/apps/cms
|
||||
EXPOSE 1337
|
||||
CMD ["pnpm", "start"]
|
||||
ENTRYPOINT ["andwati-cms-entrypoint"]
|
||||
|
||||
@@ -5,6 +5,8 @@ const config = ({
|
||||
}: Core.Config.Shared.ConfigParams): Core.Config.Server => ({
|
||||
host: env("HOST", "0.0.0.0"),
|
||||
port: env.int("PORT", 1337),
|
||||
url: env("PUBLIC_URL", "http://localhost:1337"),
|
||||
proxy: env.bool("IS_PROXIED", false),
|
||||
app: {
|
||||
keys: env.array("APP_KEYS")!,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
repository_root="${CONTENT_REPOSITORY_ROOT:-/content-repository}"
|
||||
repository="${CONTENT_GIT_REPOSITORY:-andwati/andwati.com}"
|
||||
branch="${CONTENT_GIT_BRANCH:-main}"
|
||||
|
||||
case "$repository" in
|
||||
*[!A-Za-z0-9._/-]* | /* | */ | *..*)
|
||||
echo "Invalid CONTENT_GIT_REPOSITORY: $repository" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
mkdir -p "$repository_root"
|
||||
|
||||
if [ ! -d "$repository_root/.git" ]; then
|
||||
if [ -n "$(find "$repository_root" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||||
echo "$repository_root is not empty and is not a Git checkout" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git clone \
|
||||
--branch "$branch" \
|
||||
--single-branch \
|
||||
"https://github.com/$repository.git" \
|
||||
"$repository_root"
|
||||
else
|
||||
git -C "$repository_root" fetch origin "$branch"
|
||||
git -C "$repository_root" checkout "$branch"
|
||||
|
||||
if [ -z "$(git -C "$repository_root" status --porcelain)" ]; then
|
||||
git -C "$repository_root" merge --ff-only "origin/$branch"
|
||||
else
|
||||
echo "Content checkout has unpublished changes; preserving them without pulling." >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
export CONTENT_REPOSITORY_ROOT="$repository_root"
|
||||
exec node node_modules/@strapi/strapi/bin/strapi.js start
|
||||
@@ -13,6 +13,7 @@
|
||||
"develop": "strapi develop",
|
||||
"start": "strapi start",
|
||||
"strapi": "strapi",
|
||||
"test:publisher": "tsx --test tests/git-publisher.test.ts",
|
||||
"upgrade": "npx @strapi/upgrade latest",
|
||||
"upgrade:dry": "npx @strapi/upgrade latest --dry"
|
||||
},
|
||||
@@ -32,6 +33,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { coversRoot } from "./content-paths";
|
||||
|
||||
/**
|
||||
* Covers are cached into the Astro app's public/ dir directly, so the built
|
||||
* site serves them from /covers/<slug>.jpg with no runtime dependency on the
|
||||
* source (Open Library, Semantic Scholar, ...).
|
||||
*/
|
||||
const COVERS_DIR = join(process.cwd(), "..", "site", "public", "covers");
|
||||
const COVERS_DIR = coversRoot();
|
||||
|
||||
async function download(url: string): Promise<Buffer | undefined> {
|
||||
const res = await fetch(url);
|
||||
|
||||
@@ -107,11 +107,12 @@ export function writeEntry(
|
||||
layout: "nested" | "flat",
|
||||
frontmatter: Record<string, unknown>,
|
||||
body: string,
|
||||
): void {
|
||||
): string {
|
||||
const file = entryPath(dir, slug, layout);
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
const toml = stringifyToml(compact(frontmatter)).trimEnd();
|
||||
writeFileSync(file, `+++\n${toml}\n+++\n\n${(body ?? "").trim()}\n`);
|
||||
return file;
|
||||
}
|
||||
|
||||
/** Deletes the file (and, for nested layout, its now-empty directory) for `slug`. */
|
||||
@@ -119,9 +120,10 @@ export function deleteEntry(
|
||||
dir: string,
|
||||
slug: string,
|
||||
layout: "nested" | "flat",
|
||||
): void {
|
||||
): string {
|
||||
const file = entryPath(dir, slug, layout);
|
||||
rmSync(file, { force: true });
|
||||
if (layout === "nested")
|
||||
rmSync(join(dir, slug), { recursive: true, force: true });
|
||||
return file;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { isAbsolute, join, resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Root of the Git checkout that owns content/ and apps/site/public/covers/.
|
||||
* Production points this at the persistent checkout prepared by the
|
||||
* container entrypoint; local development falls back to the workspace root.
|
||||
*/
|
||||
export function repositoryRoot(): string {
|
||||
const configured = process.env.CONTENT_REPOSITORY_ROOT;
|
||||
if (!configured) return resolve(process.cwd(), "..", "..");
|
||||
return isAbsolute(configured)
|
||||
? configured
|
||||
: resolve(process.cwd(), configured);
|
||||
}
|
||||
|
||||
export function contentRoot(): string {
|
||||
return join(repositoryRoot(), "content");
|
||||
}
|
||||
|
||||
export function coversRoot(): string {
|
||||
return join(repositoryRoot(), "apps", "site", "public", "covers");
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { join } from "node:path";
|
||||
import { rmSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import {
|
||||
cacheRemoteImage,
|
||||
fetchBookCover,
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
toTomlDate,
|
||||
writeEntry,
|
||||
} from "./content-file";
|
||||
import { contentRoot, coversRoot } from "./content-paths";
|
||||
import { publishContentChanges } from "./git-publisher";
|
||||
|
||||
const BOOKSHELF_UID = "api::bookshelf-entry.bookshelf-entry";
|
||||
|
||||
@@ -39,11 +42,6 @@ const isoDate = (value: unknown): string | undefined =>
|
||||
? value
|
||||
: undefined;
|
||||
|
||||
function contentRoot(): string {
|
||||
// apps/cms -> repo root/content (also the mount point in the dev container)
|
||||
return join(process.cwd(), "..", "..", "content");
|
||||
}
|
||||
|
||||
function buildConfigs(): Record<string, SyncConfig> {
|
||||
const root = contentRoot();
|
||||
|
||||
@@ -209,11 +207,28 @@ let importing = false;
|
||||
function writeEntryForConfig(
|
||||
config: SyncConfig,
|
||||
entry: Record<string, unknown>,
|
||||
): void {
|
||||
): string | undefined {
|
||||
const slug = entry.slug as string | undefined;
|
||||
if (!slug) return;
|
||||
if (!slug) return undefined;
|
||||
const { frontmatter, body } = config.toFile(entry);
|
||||
writeEntry(config.dir, slug, config.layout, frontmatter, body);
|
||||
return writeEntry(config.dir, slug, config.layout, frontmatter, body);
|
||||
}
|
||||
|
||||
function coverFileForEntry(
|
||||
entry: Record<string, unknown> | null,
|
||||
): string | undefined {
|
||||
const coverImage = entry?.cover_image;
|
||||
if (typeof coverImage !== "string" || !coverImage.startsWith("/covers/")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const root = resolve(coversRoot());
|
||||
const file = resolve(root, coverImage.slice("/covers/".length));
|
||||
const fromRoot = relative(root, file);
|
||||
if (fromRoot === ".." || fromRoot.startsWith("../")) {
|
||||
throw new Error(`Refusing unsafe local cover path: ${coverImage}`);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -325,6 +340,9 @@ async function enrichBookshelfEntry(
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) return entry;
|
||||
// Keep the nested update outside the network-failure guard. Its middleware
|
||||
// persists and publishes the enriched entry; publication errors must reach
|
||||
// the caller instead of being mistaken for a cover-provider outage.
|
||||
return documents(BOOKSHELF_UID).update({
|
||||
documentId: entry.documentId as string,
|
||||
data: patch,
|
||||
@@ -348,14 +366,13 @@ export function registerContentSync(strapi: {
|
||||
const config = SYNC_CONFIGS[ctx.uid];
|
||||
if (!config || importing) return next();
|
||||
|
||||
let preDeleteSlug: string | undefined;
|
||||
let preDeleteEntry: Record<string, unknown> | null = null;
|
||||
if (ctx.action === "delete") {
|
||||
const documentId = (ctx.params as { documentId?: string }).documentId;
|
||||
if (documentId) {
|
||||
const existing = await strapi
|
||||
preDeleteEntry = await strapi
|
||||
.documents(ctx.uid)
|
||||
.findOne({ documentId });
|
||||
preDeleteSlug = existing?.slug as string | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,14 +391,33 @@ export function registerContentSync(strapi: {
|
||||
result as Record<string, unknown>,
|
||||
)
|
||||
: (result as Record<string, unknown>);
|
||||
writeEntryForConfig(config, entry);
|
||||
} else if (ctx.action === "delete" && preDeleteSlug) {
|
||||
deleteEntry(config.dir, preDeleteSlug, config.layout);
|
||||
const entryFile = writeEntryForConfig(config, entry);
|
||||
if (entryFile) {
|
||||
const paths = [entryFile];
|
||||
const coverFile = coverFileForEntry(entry);
|
||||
if (coverFile) paths.push(coverFile);
|
||||
await publishContentChanges(
|
||||
paths,
|
||||
`content: ${ctx.action} ${entry.slug as string}`,
|
||||
);
|
||||
}
|
||||
} else if (ctx.action === "delete" && preDeleteEntry) {
|
||||
const slug = preDeleteEntry.slug as string | undefined;
|
||||
if (!slug) return result;
|
||||
|
||||
const paths = [deleteEntry(config.dir, slug, config.layout)];
|
||||
const coverFile = coverFileForEntry(preDeleteEntry);
|
||||
if (coverFile) {
|
||||
rmSync(coverFile, { force: true });
|
||||
paths.push(coverFile);
|
||||
}
|
||||
await publishContentChanges(paths, `content: delete ${slug}`);
|
||||
}
|
||||
} catch (err) {
|
||||
strapi.log.error(
|
||||
`content-sync: failed to write ${ctx.uid} to disk: ${(err as Error).message}`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { relative, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { repositoryRoot } from "./content-paths";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface GitPublisherConfig {
|
||||
worktree: string;
|
||||
remote?: string;
|
||||
branch?: string;
|
||||
token?: string;
|
||||
commitName?: string;
|
||||
commitEmail?: string;
|
||||
deployWebhookUrl?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
function gitAuthenticationEnv(token?: string): NodeJS.ProcessEnv {
|
||||
if (!token) return process.env;
|
||||
|
||||
const credentials = Buffer.from(`x-access-token:${token}`).toString("base64");
|
||||
return {
|
||||
...process.env,
|
||||
GIT_CONFIG_COUNT: "1",
|
||||
GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader",
|
||||
GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${credentials}`,
|
||||
};
|
||||
}
|
||||
|
||||
export class GitPublisher {
|
||||
private readonly worktree: string;
|
||||
private readonly remote: string;
|
||||
private readonly branch: string;
|
||||
private readonly token?: string;
|
||||
private readonly commitName: string;
|
||||
private readonly commitEmail: string;
|
||||
private readonly deployWebhookUrl?: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
private deployPending = false;
|
||||
|
||||
constructor(config: GitPublisherConfig) {
|
||||
this.worktree = resolve(config.worktree);
|
||||
this.remote = config.remote ?? "origin";
|
||||
this.branch = config.branch ?? "main";
|
||||
this.token = config.token;
|
||||
this.commitName = config.commitName ?? "andwati.com CMS";
|
||||
this.commitEmail =
|
||||
config.commitEmail ?? "[email protected]";
|
||||
this.deployWebhookUrl = config.deployWebhookUrl;
|
||||
this.fetchImpl = config.fetchImpl ?? fetch;
|
||||
}
|
||||
|
||||
publish(paths: string[], message: string): Promise<void> {
|
||||
const operation = this.queue.then(() => this.publishNow(paths, message));
|
||||
this.queue = operation.catch(() => undefined);
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async git(args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileAsync("git", args, {
|
||||
cwd: this.worktree,
|
||||
env: gitAuthenticationEnv(this.token),
|
||||
timeout: 30_000,
|
||||
});
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
private repositoryPaths(paths: string[]): string[] {
|
||||
return [...new Set(paths)].map((path) => {
|
||||
const absolute = resolve(path);
|
||||
const repositoryPath = relative(this.worktree, absolute);
|
||||
if (
|
||||
repositoryPath === "" ||
|
||||
repositoryPath === ".." ||
|
||||
repositoryPath.startsWith("../")
|
||||
) {
|
||||
throw new Error(
|
||||
`Refusing to publish path outside Git checkout: ${path}`,
|
||||
);
|
||||
}
|
||||
return repositoryPath;
|
||||
});
|
||||
}
|
||||
|
||||
private async hasStagedChanges(): Promise<boolean> {
|
||||
try {
|
||||
await this.git(["diff", "--cached", "--quiet"]);
|
||||
return false;
|
||||
} catch (error) {
|
||||
if ((error as { code?: number }).code === 1) return true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async publishNow(paths: string[], message: string): Promise<void> {
|
||||
const repositoryPaths = this.repositoryPaths(paths);
|
||||
await this.git(["add", "--all", "--", ...repositoryPaths]);
|
||||
|
||||
if (await this.hasStagedChanges()) {
|
||||
await this.git([
|
||||
"-c",
|
||||
`user.name=${this.commitName}`,
|
||||
"-c",
|
||||
`user.email=${this.commitEmail}`,
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"-m",
|
||||
message,
|
||||
]);
|
||||
}
|
||||
|
||||
await this.git(["fetch", this.remote, this.branch]);
|
||||
try {
|
||||
await this.git([
|
||||
"rebase",
|
||||
"--autostash",
|
||||
`${this.remote}/${this.branch}`,
|
||||
]);
|
||||
} catch (error) {
|
||||
await this.git(["rebase", "--abort"]).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const ahead = Number(
|
||||
await this.git([
|
||||
"rev-list",
|
||||
"--count",
|
||||
`${this.remote}/${this.branch}..HEAD`,
|
||||
]),
|
||||
);
|
||||
if (ahead > 0) {
|
||||
await this.git(["push", this.remote, `HEAD:${this.branch}`]);
|
||||
this.deployPending = true;
|
||||
}
|
||||
|
||||
if (this.deployPending && this.deployWebhookUrl) {
|
||||
const response = await this.fetchImpl(this.deployWebhookUrl, {
|
||||
method: "POST",
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Dokploy deploy webhook returned ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
this.deployPending = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let publisher: GitPublisher | undefined;
|
||||
|
||||
function publisherFromEnvironment(): GitPublisher | undefined {
|
||||
if (process.env.GIT_PUBLISH_ENABLED !== "true") return undefined;
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const deployWebhookUrl = process.env.DOKPLOY_DEPLOY_WEBHOOK_URL;
|
||||
if (!token) throw new Error("GITHUB_TOKEN is required for Git publishing");
|
||||
if (!deployWebhookUrl) {
|
||||
throw new Error(
|
||||
"DOKPLOY_DEPLOY_WEBHOOK_URL is required for Git publishing",
|
||||
);
|
||||
}
|
||||
|
||||
return new GitPublisher({
|
||||
worktree: repositoryRoot(),
|
||||
remote: process.env.CONTENT_GIT_REMOTE ?? "origin",
|
||||
branch: process.env.CONTENT_GIT_BRANCH ?? "main",
|
||||
token,
|
||||
commitName: process.env.CONTENT_GIT_COMMIT_NAME,
|
||||
commitEmail: process.env.CONTENT_GIT_COMMIT_EMAIL,
|
||||
deployWebhookUrl,
|
||||
});
|
||||
}
|
||||
|
||||
/** Publishes changed source files when production Git publishing is enabled. */
|
||||
export async function publishContentChanges(
|
||||
paths: string[],
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
publisher ??= publisherFromEnvironment();
|
||||
await publisher?.publish(paths, message);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { GitPublisher } from "../src/utils/git-publisher.ts";
|
||||
|
||||
function git(cwd: string, ...args: string[]): string {
|
||||
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function repositoryFixture(): {
|
||||
root: string;
|
||||
worktree: string;
|
||||
cleanup: () => void;
|
||||
} {
|
||||
const root = mkdtempSync(join(tmpdir(), "andwati-git-publisher-"));
|
||||
const remote = join(root, "remote.git");
|
||||
const worktree = join(root, "worktree");
|
||||
|
||||
git(root, "init", "--bare", remote);
|
||||
git(root, "clone", remote, worktree);
|
||||
writeFileSync(join(worktree, "README.md"), "seed\n");
|
||||
git(worktree, "add", "README.md");
|
||||
git(
|
||||
worktree,
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"[email protected]",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"-m",
|
||||
"seed",
|
||||
);
|
||||
git(worktree, "branch", "-M", "main");
|
||||
git(worktree, "push", "-u", "origin", "main");
|
||||
|
||||
return {
|
||||
root,
|
||||
worktree,
|
||||
cleanup: () => rmSync(root, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
test("commits, pushes, and deploys a changed source file", async () => {
|
||||
const fixture = repositoryFixture();
|
||||
let deploys = 0;
|
||||
const publisher = new GitPublisher({
|
||||
worktree: fixture.worktree,
|
||||
branch: "main",
|
||||
deployWebhookUrl: "https://deploy.invalid/hook",
|
||||
fetchImpl: async () => {
|
||||
deploys += 1;
|
||||
return new Response(null, { status: 200 });
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const contentFile = join(fixture.worktree, "content", "post.md");
|
||||
mkdirSync(join(fixture.worktree, "content"));
|
||||
writeFileSync(contentFile, "published\n", { flag: "w" });
|
||||
const concurrentFile = join(fixture.worktree, "content", "next.md");
|
||||
writeFileSync(concurrentFile, "still being edited\n");
|
||||
await publisher.publish([contentFile], "content: update post");
|
||||
|
||||
assert.equal(
|
||||
git(fixture.worktree, "show", "origin/main:content/post.md"),
|
||||
"published",
|
||||
);
|
||||
assert.equal(deploys, 1);
|
||||
assert.equal(readFileSync(concurrentFile, "utf8"), "still being edited\n");
|
||||
assert.match(git(fixture.worktree, "status", "--short"), /next\.md/);
|
||||
|
||||
await publisher.publish([contentFile], "content: update post");
|
||||
assert.equal(deploys, 1, "unchanged content must not trigger a deploy");
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("retries a pending deploy without creating another commit", async () => {
|
||||
const fixture = repositoryFixture();
|
||||
let deploys = 0;
|
||||
const publisher = new GitPublisher({
|
||||
worktree: fixture.worktree,
|
||||
branch: "main",
|
||||
deployWebhookUrl: "https://deploy.invalid/hook",
|
||||
fetchImpl: async () => {
|
||||
deploys += 1;
|
||||
return new Response(null, {
|
||||
status: deploys === 1 ? 503 : 200,
|
||||
statusText: deploys === 1 ? "Unavailable" : "OK",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const contentFile = join(fixture.worktree, "entry.md");
|
||||
writeFileSync(contentFile, "durable\n");
|
||||
|
||||
await assert.rejects(
|
||||
publisher.publish([contentFile], "content: create entry"),
|
||||
/Dokploy deploy webhook returned 503/,
|
||||
);
|
||||
await publisher.publish([contentFile], "content: create entry");
|
||||
|
||||
assert.equal(deploys, 2);
|
||||
assert.equal(
|
||||
git(fixture.worktree, "rev-list", "--count", "origin/main"),
|
||||
"2",
|
||||
"the webhook retry must not create a duplicate commit",
|
||||
);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mDMEalcjtBYJKwYBBAHaRw8BAQdA55X1mRCKw9DA35kCAy+7Z30ye0rUHWBZsl7O
|
||||
Wmar+RS0IklhbiBBbmR3YXRpIDxhbmR3YXRpaWFuQGdtYWlsLmNvbT6IkwQTFgoA
|
||||
OxYhBK1YqreiDYUjy/WaeR8g/tCqBbDaBQJqVyO0AhsDBQsJCAcCAiICBhUKCQgL
|
||||
AgQWAgMBAh4HAheAAAoJEB8g/tCqBbDace8BAN1SpkEMSYll+XupKHiaIUwnhm4N
|
||||
eS79G1i+345Gl6CyAQDjgn+3rzG6sexjKh+10EKiy5omsQEGzUW03s7gS0T1Drg4
|
||||
BGpXI7QSCisGAQQBl1UBBQEBB0CXofBIYYXG2+dZqgllhsbkNPF4qlyfPgbRW+zb
|
||||
7Kw3IAMBCAeIeAQYFgoAIBYhBK1YqreiDYUjy/WaeR8g/tCqBbDaBQJqVyO0AhsM
|
||||
AAoJEB8g/tCqBbDauMkA/RG4iqtPZOI3DQzFJYu7SnxaD81vdP0LyTLKDQkynySu
|
||||
AQDrlWyF8LRlad4iX1yQPCuOV0aUpTNq5kPlWurtX99/BQ==
|
||||
=uKsS
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
@@ -0,0 +1,17 @@
|
||||
-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA512
|
||||
|
||||
Contact: mailto:[email protected]
|
||||
Expires: 2027-07-16T00:00:00.000Z
|
||||
Encryption: https://andwati.com/.well-known/pgp-key.txt
|
||||
Encryption: https://keys.openpgp.org/vks/v1/by-fingerprint/AD58AAB7A20D8523CBF59A791F20FED0AA05B0DA
|
||||
Encryption: https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xAD58AAB7A20D8523CBF59A791F20FED0AA05B0DA
|
||||
Preferred-Languages: en
|
||||
Canonical: https://andwati.com/.well-known/security.txt
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iHUEARYKAB0WIQStWKq3og2FI8v1mnkfIP7QqgWw2gUCaljXlQAKCRAfIP7QqgWw
|
||||
2qAoAP0Q7Cc0YsP2dvV7fqDlqcnjuJepQQSTZBz7ehkPyjGV5gEA3K9ZqQmuI2ea
|
||||
KKADWxuKTTY43PBmuV9rMqLm8ZOPlAY=
|
||||
=NUkY
|
||||
-----END PGP SIGNATURE-----
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
import Base from "../layouts/Base.astro";
|
||||
import { siteConfig } from "../site.config";
|
||||
---
|
||||
|
||||
<Base
|
||||
title="About"
|
||||
description="About Ian Andwati, the software engineer behind Thoughts and Musings."
|
||||
>
|
||||
<article class="about">
|
||||
<header>
|
||||
<p class="eyebrow">About this place</p>
|
||||
<p class="lede">
|
||||
I’m Ian Andwati, a software engineer. This site is my durable record of
|
||||
what I learn, build, read, and find worth returning to.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<p>
|
||||
I spend my free time on hobby projects and occasionally write about the
|
||||
experience. I’m especially interested in offensive security, reverse
|
||||
engineering, computer science, and mathematics.
|
||||
</p>
|
||||
<p>
|
||||
The <a href="/writings/">writings</a> hold longer-form notes, the
|
||||
<a href="/portfolio/">portfolio</a> records finished work, and the
|
||||
<a href="/bookshelf/">bookshelf</a> keeps the sources that shaped both. The
|
||||
<a href="/archive/">archive</a> is the unfiltered chronology.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="contact">
|
||||
<div>
|
||||
<p class="eyebrow">Contact</p>
|
||||
<h2>Say hello</h2>
|
||||
</div>
|
||||
<p>
|
||||
Email is the most reliable way to reach me:
|
||||
<a href={`mailto:${siteConfig.social.email}`}
|
||||
>{siteConfig.social.email}</a
|
||||
>. You can also find me on
|
||||
<a href={siteConfig.social.github} rel="me noopener" target="_blank"
|
||||
>GitHub</a
|
||||
>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="eyebrow">For sensitive messages</p>
|
||||
<h2>PGP</h2>
|
||||
<p>
|
||||
My public key is available as a <a href="/.well-known/pgp-key.txt"
|
||||
>plain-text download</a
|
||||
>
|
||||
and on
|
||||
<a
|
||||
href="https://keys.openpgp.org/search?q=AD58AAB7A20D8523CBF59A791F20FED0AA05B0DA"
|
||||
rel="noopener"
|
||||
target="_blank">keys.openpgp.org</a
|
||||
>. The signed <a href="/.well-known/security.txt">security.txt</a> lists the
|
||||
same contact and encryption details.
|
||||
</p>
|
||||
<div class="fingerprint" aria-label="PGP fingerprint">
|
||||
<span>Fingerprint</span>
|
||||
<code>AD58 AAB7 A20D 8523 CBF5 9A79 1F20 FED0 AA05 B0DA</code>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.about > header {
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.about > header .eyebrow {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.lede {
|
||||
max-width: 36rem;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xl);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
section {
|
||||
padding-block: var(--space-6);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
section p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
section a {
|
||||
text-decoration-color: var(--border);
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
section a:hover {
|
||||
color: var(--accent);
|
||||
text-decoration-color: var(--accent);
|
||||
}
|
||||
|
||||
.contact {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(8rem, 1fr) 2fr;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.contact .eyebrow {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.fingerprint {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.fingerprint span {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.fingerprint code {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 34rem) {
|
||||
.contact {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
import { getCollection } from "astro:content";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
|
||||
const writings = (
|
||||
await getCollection("writings", ({ data }) => !data.draft)
|
||||
).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
|
||||
|
||||
const writingsByYear = Map.groupBy(writings, (entry) =>
|
||||
entry.data.date.getFullYear(),
|
||||
);
|
||||
---
|
||||
|
||||
<Base
|
||||
title="Archive"
|
||||
description="A chronological archive of every published writing on Thoughts and Musings."
|
||||
>
|
||||
<header class="archive-header">
|
||||
<h1>Archive</h1>
|
||||
</header>
|
||||
|
||||
<div class="archive">
|
||||
{
|
||||
[...writingsByYear].map(([year, entries]) => (
|
||||
<section class="archive-year" aria-labelledby={`year-${year}`}>
|
||||
<h2 id={`year-${year}`}>{year}</h2>
|
||||
<ol>
|
||||
{entries.map((entry) => (
|
||||
<li>
|
||||
<time datetime={entry.data.date.toISOString().slice(0, 10)}>
|
||||
{entry.data.date.toLocaleDateString("en", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
})}
|
||||
</time>
|
||||
<a href={`/writings/${entry.id}/`}>{entry.data.title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.archive-header {
|
||||
margin-bottom: var(--space-7);
|
||||
}
|
||||
|
||||
.archive-header .eyebrow {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.archive-header p:last-child {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.archive-year {
|
||||
display: grid;
|
||||
grid-template-columns: 5rem 1fr;
|
||||
gap: var(--space-5);
|
||||
padding-block: var(--space-5);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.archive-year h2 {
|
||||
position: sticky;
|
||||
top: var(--space-5);
|
||||
align-self: start;
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
ol {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem 1fr;
|
||||
gap: var(--space-3);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
time {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
li a {
|
||||
text-decoration-color: var(--border);
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
li a:hover {
|
||||
color: var(--accent);
|
||||
text-decoration-color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
.archive-year {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.archive-year h2 {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -12,9 +12,11 @@ export const siteConfig = {
|
||||
"Ian Andwati's personal digital-legacy site covering computer science and mathematics — writings, portfolio, and a curated bookshelf.",
|
||||
nav: [
|
||||
{ name: "Writings", url: "/writings/" },
|
||||
{ name: "Archive", url: "/archive/" },
|
||||
{ name: "Portfolio", url: "/portfolio/" },
|
||||
{ name: "Bookshelf", url: "/bookshelf/" },
|
||||
{ name: "Blogs", url: "/blogs/" },
|
||||
{ name: "About", url: "/about/" },
|
||||
],
|
||||
social: {
|
||||
github: "https://github.com/andwati",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: andwati-production
|
||||
|
||||
services:
|
||||
site:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "80"
|
||||
|
||||
cms:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/cms/Dockerfile
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
expose:
|
||||
- "1337"
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "1337"
|
||||
NODE_ENV: production
|
||||
PUBLIC_URL: ${CMS_PUBLIC_URL:?Set CMS_PUBLIC_URL in Dokploy}
|
||||
IS_PROXIED: "true"
|
||||
APP_KEYS: ${APP_KEYS:?Set APP_KEYS in Dokploy}
|
||||
API_TOKEN_SALT: ${API_TOKEN_SALT:?Set API_TOKEN_SALT in Dokploy}
|
||||
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:?Set ADMIN_JWT_SECRET in Dokploy}
|
||||
TRANSFER_TOKEN_SALT: ${TRANSFER_TOKEN_SALT:?Set TRANSFER_TOKEN_SALT in Dokploy}
|
||||
JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in Dokploy}
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?Set ENCRYPTION_KEY in Dokploy}
|
||||
DATABASE_CLIENT: sqlite
|
||||
DATABASE_FILENAME: .tmp/data.db
|
||||
CONTENT_REPOSITORY_ROOT: /content-repository
|
||||
CONTENT_GIT_REPOSITORY: ${CONTENT_GIT_REPOSITORY:-andwati/andwati.com}
|
||||
CONTENT_GIT_BRANCH: ${CONTENT_GIT_BRANCH:-main}
|
||||
CONTENT_GIT_COMMIT_NAME: ${CONTENT_GIT_COMMIT_NAME:-andwati.com CMS}
|
||||
CONTENT_GIT_COMMIT_EMAIL: ${CONTENT_GIT_COMMIT_EMAIL:[email protected]}
|
||||
GIT_PUBLISH_ENABLED: "true"
|
||||
GITHUB_TOKEN: ${GITHUB_TOKEN:?Set a GitHub contents-write token in Dokploy}
|
||||
DOKPLOY_DEPLOY_WEBHOOK_URL: ${DOKPLOY_DEPLOY_WEBHOOK_URL:?Set the Dokploy deploy webhook URL}
|
||||
volumes:
|
||||
- cms_database:/app/apps/cms/.tmp
|
||||
- cms_uploads:/app/apps/cms/public/uploads
|
||||
- content_checkout:/content-repository
|
||||
|
||||
volumes:
|
||||
cms_database:
|
||||
cms_uploads:
|
||||
content_checkout:
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "andwati-com",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n site,cms -c blue,magenta \"pnpm --filter site dev\" \"pnpm --filter cms develop\"",
|
||||
"dev:site": "pnpm --filter site dev",
|
||||
|
||||
Generated
+22
-7
@@ -75,6 +75,9 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ^18
|
||||
version: 18.3.7(@types/[email protected])
|
||||
tsx:
|
||||
specifier: ^4.23.13
|
||||
version: 4.23.13
|
||||
typescript:
|
||||
specifier: ^5
|
||||
version: 5.9.3
|
||||
@@ -98,7 +101,7 @@ importers:
|
||||
version: 5.3.0
|
||||
astro:
|
||||
specifier: ^7.2.9
|
||||
version: 7.2.9(@emnapi/[email protected])(@emnapi/[email protected])(@types/[email protected])([email protected])([email protected])
|
||||
version: 7.2.9(@emnapi/[email protected])(@emnapi/[email protected])(@types/[email protected])([email protected])([email protected])([email protected])
|
||||
highlight.js:
|
||||
specifier: ^11.12.0
|
||||
version: 11.12.0
|
||||
@@ -7231,6 +7234,11 @@ packages:
|
||||
resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==}
|
||||
engines: {node: '>=0.6.x'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
||||
|
||||
@@ -12109,7 +12117,7 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
[email protected](@emnapi/[email protected])(@emnapi/[email protected])(@types/[email protected])([email protected])([email protected]):
|
||||
[email protected](@emnapi/[email protected])(@emnapi/[email protected])(@types/[email protected])([email protected])([email protected])([email protected]):
|
||||
dependencies:
|
||||
'@astrojs/compiler-rs': 0.4.0(@emnapi/[email protected])(@emnapi/[email protected])
|
||||
'@astrojs/internal-helpers': 0.10.4
|
||||
@@ -12159,8 +12167,8 @@ snapshots:
|
||||
ultrahtml: 1.7.0
|
||||
unifont: 0.7.5
|
||||
unstorage: 1.17.5
|
||||
vite: 8.2.2(@types/[email protected])([email protected])([email protected])([email protected])
|
||||
vitefu: 1.1.3([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
|
||||
vite: 8.2.2(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
|
||||
vitefu: 1.1.3([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||
xxhash-wasm: 1.1.0
|
||||
yargs-parser: 22.0.0
|
||||
zod: 4.4.3
|
||||
@@ -16279,6 +16287,12 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
esbuild: 0.28.2
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
@@ -16516,7 +16530,7 @@ snapshots:
|
||||
lightningcss: 1.33.0
|
||||
terser: 5.51.2
|
||||
|
||||
[email protected](@types/[email protected])([email protected])([email protected])([email protected]):
|
||||
[email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
picomatch: 4.0.7
|
||||
@@ -16528,11 +16542,12 @@ snapshots:
|
||||
esbuild: 0.28.2
|
||||
fsevents: 2.3.3
|
||||
terser: 5.51.2
|
||||
tsx: 4.23.13
|
||||
yaml: 2.9.0
|
||||
|
||||
[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected])):
|
||||
[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])):
|
||||
optionalDependencies:
|
||||
vite: 8.2.2(@types/[email protected])([email protected])([email protected])([email protected])
|
||||
vite: 8.2.2(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
|
||||
|
||||
[email protected](@volar/[email protected]([email protected])):
|
||||
dependencies:
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
[general]
|
||||
page404 = "./404.html"
|
||||
|
||||
[advanced]
|
||||
|
||||
[[advanced.headers]]
|
||||
source = "**"
|
||||
[advanced.headers.headers]
|
||||
Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://utteranc.es https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://www.google-analytics.com; font-src 'self'; connect-src 'self' https://www.google-analytics.com https://cloudflareinsights.com; frame-src https://www.youtube-nocookie.com https://utteranc.es; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests"
|
||||
Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-src https://www.youtube-nocookie.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests"
|
||||
X-Frame-Options = "DENY"
|
||||
X-Content-Type-Options = "nosniff"
|
||||
Referrer-Policy = "strict-origin-when-cross-origin"
|
||||
|
||||
Reference in New Issue
Block a user