21 Commits
Author SHA1 Message Date
Tomáš DvořákandDevin 56c63d43cd chore(release): v0.8.5 (#51)
CI / Backend Tests (push) Canceled after 0s
CI / Frontend Tests (push) Canceled after 0s
CI / Build All Services (push) Canceled after 0s
CI / Docker Build (auth) (push) Canceled after 0s
CI / Docker Build (backend) (push) Canceled after 0s
CI / Docker Build (frontend) (push) Canceled after 0s
CI / Migration Up/Down (push) Canceled after 0s
CI / Compose E2E Smoke (push) Canceled after 0s
CI / Lint (push) Canceled after 0s
CI / Publish edge images (auth) (push) Canceled after 0s
CI / Publish edge images (backend) (push) Canceled after 0s
CI / Publish edge images (frontend) (push) Canceled after 0s
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 22:33:52 +02:00
Tomáš DvořákandDevin 6d758c0089 fix(frontend): center capped content column on wide viewports (#50)
The .page column was max-width 72rem but left-aligned, leaving a large
empty band on the right above ~1200px. margin-inline: auto centers it;
narrow widths are unchanged since the column fills the viewport anyway.

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 22:31:19 +02:00
Tomáš DvořákandDevin 361538aabc chore(release): v0.8.4 (#49)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 21:56:16 +02:00
Tomáš DvořákandDevin 5eec80bbca feat(frontend): auto-derive slugs from names on create forms (#48)
The org/project/bucket/collection create forms required typing a slug
by hand. While the slug field is untouched (empty or still equal to the
derived value), naming the entity fills its slug; a manual slug edit
stops the derivation and is sanitized to slug form as you type.

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 21:53:27 +02:00
Tomáš DvořákandDevin 40c189c431 fix(install): stop exec from silencing the tty prompt (#47)
exec 9</dev/tty 2>/dev/null inside the if condition redirected the whole
shell's stderr to /dev/null permanently — the read -p prompt then wrote
into the void, leaving the installer blocked on an invisible question.
Replace with a plain </dev/tty redirect on read itself plus a quiet
true </dev/tty probe: the prompt displays, answers land, and no-tty
runs fall back to the default without error noise or the exec
redirect-failure shell-exit hazard.

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 21:39:54 +02:00
Tomáš DvořákandDevin bc8b165ad9 fix(install): fail fast when GitHub fetch stalls (#46)
curl without timeouts hangs silently on a stalled connection — the
install looked dead between 'Fetching compose files' and the image
pulls. --connect-timeout/--max-time/--retry-all-errors turn it into a
clear error naming the file.

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 21:14:15 +02:00
Tomáš Dvořák 61f0974c61 Merge pull request #45 from Dvorinka/release/v0.8.3
chore(release): v0.8.3
2026-09-28 20:40:17 +02:00
Tomas DvorakandDevin 769d04471f chore(release): v0.8.3
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 20:36:35 +02:00
Tomáš Dvořák da294aaa2b Merge pull request #44 from Dvorinka/fix/install-prompt-tty
fix(install): prompt on /dev/tty under curl|bash
2026-09-28 20:22:14 +02:00
Tomas DvorakandDevin 1226d44872 fix(install): prompt on /dev/tty so it works under curl|bash
stdin is the script pipe when run as `curl ... | bash`, so the -t 0
check silently skipped the domain question. Read from the controlling
terminal instead; no tty (CI/cron) still defaults to localhost.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 20:19:36 +02:00
Tomáš Dvořák ac4ca9a3ae Merge pull request #43 from Dvorinka/feat/install-public-url
feat(install): ask for the public URL on fresh installs
2026-09-28 19:34:27 +02:00
Tomas DvorakandDevin 597db3287e feat(install): ask for the public URL, wire it into .env
install.sh now asks one question on fresh installs — the URL the app is
opened on. Accepts https://primora.example.com (TLS at the user's proxy),
primora.example.com, or 192.168.1.50:8085; the port becomes NGINX_PORT.
The answer rewrites every public URL var and COOKIE_DOMAIN for real DNS
names. PUBLIC_URL env keeps it non-interactive; legacy DOMAIN/NGINX_PORT
still work; reruns over an existing .env skip the question and display
the URL from the file.

Also trust both URL schemes on the serving host in trustedOrigins —
external TLS termination makes nginx report http while browsers arrive
over https.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 19:31:54 +02:00
Tomáš Dvořák bbc57f9c0d Merge pull request #42 from Dvorinka/fix/sbom-permissions
CI / Backend Tests (push) Canceled after 0s
CI / Frontend Tests (push) Canceled after 0s
CI / Build All Services (push) Canceled after 0s
CI / Docker Build (auth) (push) Canceled after 0s
CI / Docker Build (backend) (push) Canceled after 0s
CI / Docker Build (frontend) (push) Canceled after 0s
CI / Migration Up/Down (push) Canceled after 0s
CI / Compose E2E Smoke (push) Canceled after 0s
CI / Lint (push) Canceled after 0s
CI / Publish edge images (auth) (push) Canceled after 0s
CI / Publish edge images (backend) (push) Canceled after 0s
CI / Publish edge images (frontend) (push) Canceled after 0s
fix(release): contents:write for SBOM asset upload
2026-09-28 16:44:05 +02:00
Tomas DvorakandDevin 9ab9b550d1 fix(release): contents:write for SBOM asset upload
The sbom job's `permissions: packages: read` replaces the default token
set, so `gh release upload` got 403 Resource not accessible. Grant
contents:write alongside packages:read.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 16:41:17 +02:00
Tomáš Dvořák 49ca9067a9 Merge pull request #41 from Dvorinka/release/v0.8.2
chore(release): v0.8.2
2026-09-28 16:28:11 +02:00
Tomas DvorakandDevin 805a58e32f chore(release): v0.8.2
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 16:26:11 +02:00
Tomáš Dvořák d09b0a1c2d Merge pull request #40 from Dvorinka/fix/signup-bounce
fix(auth): sign-up bounce — surface errors, dynamic trusted origins, unbreakable mail probe
2026-09-28 16:25:24 +02:00
Tomas DvorakandDevin 92b1810c07 fix(auth): surface sign-up failures, trust serving origin, unbreakable mail probe
Self-hosted sign-up bounced back to the bootstrap screen for three
compounding reasons:

- better-auth's client resolves { data, error } on HTTP failure; the UI
  ignored res.error and showed "Account created — signing you in", hiding
  the real 403/429/500.
- trustedOrigins was a static localhost-only list. Browsing the instance
  via LAN IP or custom port failed every auth POST once cookies existed.
  Origins are now derived from the request's public host (forwarded by
  nginx), so any origin serving the app works.
- hasMailTransport threw when core.settings was unreadable (fresh volume,
  backend still migrating), so sign-up 500'd and the startup backfill
  could crash-loop the service. Both now degrade to "no transport".

Verified live: LAN-style origin sign-up succeeds, missing core.settings
logs a warn instead of exiting, and the frontend shows real errors.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 16:23:01 +02:00
Tomáš Dvořák 3ad37c190b Merge pull request #39 from Dvorinka/fix/sbom-output-dir
fix: create dist-release dir before SBOM output writes
2026-09-28 16:02:16 +02:00
Tomas DvorakandDevin cb56cedb1f fix: create dist-release dir before SBOM output writes
anchore/sbom-action does not create the output directory — the v0.8.1
run scanned images fine (lowercase owner works) but ENOENT'd writing
dist-release/sbom-*.cdx.json.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 16:00:00 +02:00
Tomáš Dvořák 20b4ea6a07 Merge pull request #38 from Dvorinka/release/v0.8.1
release: v0.8.1
2026-09-28 15:48:12 +02:00
13 changed files with 244 additions and 29 deletions

No files matched your search

+4
View File
@@ -327,6 +327,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
packages: read packages: read
# contents: write — the job uploads SBOM assets onto the release.
contents: write
env: env:
VERSION: ${{ needs.verify.outputs.version }} VERSION: ${{ needs.verify.outputs.version }}
steps: steps:
@@ -339,6 +341,8 @@ jobs:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
# anchore/sbom-action does not create the output directory.
- run: mkdir -p dist-release
- uses: anchore/sbom-action@v0 - uses: anchore/sbom-action@v0
with: with:
image: ghcr.io/${{ env.GHCR_OWNER }}/primora-backend:${{ needs.verify.outputs.version }} image: ghcr.io/${{ env.GHCR_OWNER }}/primora-backend:${{ needs.verify.outputs.version }}
+35
View File
@@ -4,6 +4,41 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac
## [Unreleased] ## [Unreleased]
## [0.8.5] - 2026-09-28
### Fixed
- **Content column centers on wide screens** — `.page` was capped at 72rem but left-aligned, so above ~1200px the dashboard hugged the left with a growing dead zone on the right. It now centers (`margin-inline: auto`) while staying full-width below the cap.
## [0.8.4] - 2026-09-28
### Added
- **Slugs auto-derive from names** — typing an organization, project, bucket, or collection name now fills its slug (`TDvorak` → `tdvorak`); hand-editing the slug stops the derivation, and manual edits are sanitized as you type. Edit forms stay manual.
### Fixed
- **Installer URL prompt was invisible under `curl | bash`** — `exec 9</dev/tty 2>/dev/null` sent the whole shell's stderr to `/dev/null` permanently, so the `read` prompt rendered nowhere while the script blocked on input. The prompt now reads `/dev/tty` directly with a quiet probe; no-tty runs default silently.
- **Installer fetches fail fast** — the compose-file and source-tarball `curl` calls now carry connect/max-time timeouts plus retries, so a stalled GitHub connection errors within seconds naming the file instead of hanging forever.
## [0.8.3] - 2026-09-28
### Added
- **Install script asks for the public URL** — `install.sh` now prompts for where you'll open the app and accepts a full URL: `https://primora.example.com` (TLS at your proxy, hints printed at the end), `primora.example.com`, or `192.168.1.50:8085` (the port becomes `NGINX_PORT`). The answer rewrites every public URL var in `.env` and `COOKIE_DOMAIN` for real DNS names. The prompt reads the controlling terminal so it appears under `curl | bash`; no tty (CI, cron) falls back to `localhost`. Non-interactive via `PUBLIC_URL=`; legacy `DOMAIN`/`NGINX_PORT` envs still work; reruns over an existing `.env` skip the question.
### Fixed
- **Auth origins behind external TLS terminators** — trusted origins now accept both URL schemes on the serving host. Verified live over a Cloudflare quick tunnel: a real browser session cookie + `https://*.trycloudflare.com` origin was 403'd on v0.8.2 (nginx reports `X-Forwarded-Proto: http` inbound); the same request now signs in cleanly while cross-site origins still 403.
## [0.8.2] - 2026-09-28
### Fixed
- **Self-hosted sign-up works on any origin** — trusted origins are now derived from the request's public host (forwarded by nginx), so browsing the instance via a LAN IP, hostname, or custom port no longer fails every auth POST with a 403. Static `VITE_APP_URL`/`AUTH_BASE_URL` origins and `BETTER_AUTH_TRUSTED_ORIGINS` still apply.
- **Sign-up/sign-in failures are surfaced, not swallowed** — better-auth resolves `{ data, error }` instead of throwing, so a failed request previously showed "Account created — signing you in" and bounced back to the bootstrap screen. The real server error (invalid origin, rate limit, disabled sign-up) is now displayed.
- **Mail probe can no longer break auth** — `hasMailTransport` degrades to "no transport" instead of throwing when `core.settings` is unreadable, and the no-transport startup backfill is best-effort. Previously, auth could crash-loop while the backend was still migrating on a fresh volume — every sign-up 502'd during the window.
## [0.8.1] - 2026-09-28 ## [0.8.1] - 2026-09-28
### Fixed ### Fixed
+3 -1
View File
@@ -77,7 +77,9 @@ Prerequisites: Docker with the Compose plugin.
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Primora/master/install.sh | bash curl -fsSL https://raw.githubusercontent.com/Dvorinka/Primora/master/install.sh | bash
``` ```
Installs into `./primora`, generates secrets, and starts the stack on port 80. Installs into `./primora`, asks one question — the URL you'll open the app on
(domain, IP, optional port) — generates secrets, and starts the stack.
Non-interactive: `PUBLIC_URL=https://primora.example.com bash install.sh`.
Or from a clone: Or from a clone:
```bash ```bash
+22 -2
View File
@@ -53,7 +53,22 @@ export const auth = betterAuth({
appName: "Primora", appName: "Primora",
secret: env.BETTER_AUTH_SECRET, secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL, baseURL: env.BETTER_AUTH_URL,
trustedOrigins: [env.VITE_APP_URL, env.AUTH_BASE_URL], // Self-host: the browser reaches /auth through the same nginx origin as the
// app, so whatever host actually served this request is trusted — static
// localhost defaults alone 403 every sign-up on LAN IPs or custom ports.
// An attacker's cross-site Origin still won't match the server host.
trustedOrigins: (request) => {
const origins = [env.VITE_APP_URL, env.AUTH_BASE_URL];
const host = request?.headers.get("x-forwarded-host") ?? request?.headers.get("host");
if (host) {
const proto = request?.headers.get("x-forwarded-proto") ?? "http";
origins.push(`${proto}://${host}`);
// TLS often terminates at an external proxy — nginx then reports http.
// Both schemes on the serving host are legitimately this instance.
origins.push(proto === "https" ? `http://${host}` : `https://${host}`);
}
return origins;
},
database: authPool, database: authPool,
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
@@ -155,10 +170,15 @@ export async function runAuthMigrations() {
await migrations.runMigrations(); await migrations.runMigrations();
} }
/** No transport → existing unverified users can never resolve that state. */ /** No transport → existing unverified users can never resolve that state.
* Best-effort: a startup failure here must never take the service down. */
export async function verifyUsersWithoutMailTransport() { export async function verifyUsersWithoutMailTransport() {
try {
if (await hasMailTransport()) return; if (await hasMailTransport()) return;
await authPool.query(`update "user" set "emailVerified" = true where "emailVerified" = false`); await authPool.query(`update "user" set "emailVerified" = true where "emailVerified" = false`);
} catch (error) {
console.warn(JSON.stringify({ level: "warn", msg: "verify_without_transport_failed", error }));
}
} }
/** Bootstrap admins by email — run after migrations so the role column exists. */ /** Bootstrap admins by email — run after migrations so the role column exists. */
+7
View File
@@ -32,10 +32,17 @@ async function resolveMailConfig() {
// True when a mail transport exists (Resend key or SMTP host). Verification // True when a mail transport exists (Resend key or SMTP host). Verification
// and reset mails only make sense when mail can actually leave the box. // and reset mails only make sense when mail can actually leave the box.
// Mail is optional — a failed settings read must degrade to "no transport",
// never break sign-up.
export async function hasMailTransport(): Promise<boolean> { export async function hasMailTransport(): Promise<boolean> {
try {
const cfg = await resolveMailConfig(); const cfg = await resolveMailConfig();
if ("resendApiKey" in cfg && cfg.resendApiKey) return true; if ("resendApiKey" in cfg && cfg.resendApiKey) return true;
return "smtp" in cfg && Boolean(cfg.smtp); return "smtp" in cfg && Boolean(cfg.smtp);
} catch (error) {
console.warn(JSON.stringify({ level: "warn", msg: "mail_transport_probe_failed", error }));
return false;
}
} }
export async function sendTransactionalEmail(input: { export async function sendTransactionalEmail(input: {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@primora/desktop", "name": "@primora/desktop",
"version": "0.8.1", "version": "0.8.5",
"private": true, "private": true,
"description": "Primora desktop shell — thin Tauri client for a self-hosted deployment", "description": "Primora desktop shell — thin Tauri client for a self-hosted deployment",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Primora", "productName": "Primora",
"version": "0.8.1", "version": "0.8.5",
"identifier": "dev.tdvorak.primora", "identifier": "dev.tdvorak.primora",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
+19 -7
View File
@@ -28,6 +28,7 @@ import { Show, createEffect, createMemo, createResource, createSignal, onCleanup
import { authClient, fetchApiToken } from "./lib/auth-client"; import { authClient, fetchApiToken } from "./lib/auth-client";
import { configureApiToken, errorMessage } from "./lib/api"; import { configureApiToken, errorMessage } from "./lib/api";
import { isDemoMode, disableDemoMode, enableDemoMode, demoSession, demoService } from "./lib/demo-mode"; import { isDemoMode, disableDemoMode, enableDemoMode, demoSession, demoService } from "./lib/demo-mode";
import { applySlugField } from "./lib/slug";
import { import {
AgentSetupModal, AgentSetupModal,
AppShell, AppShell,
@@ -735,11 +736,22 @@ export default function App() {
await runPending("auth-submit", async () => { await runPending("auth-submit", async () => {
setAuthMessage(""); setAuthMessage("");
try { try {
// better-auth client resolves { data, error } instead of throwing on
// HTTP failures — surface res.error or the UI claims success while
// nothing was created.
if (mode() === "sign-up") { if (mode() === "sign-up") {
await authClient.signUp.email({ email: email(), password: password(), name: name() }); const res = await authClient.signUp.email({ email: email(), password: password(), name: name() });
if (res.error) {
setAuthMessage(res.error.message ?? "Sign-up failed");
return;
}
setAuthMessage("Account created — signing you in."); setAuthMessage("Account created — signing you in.");
} else { } else {
await authClient.signIn.email({ email: email(), password: password() }); const res = await authClient.signIn.email({ email: email(), password: password() });
if (res.error) {
setAuthMessage(res.error.message ?? "Sign-in failed");
return;
}
} }
} catch (error) { } catch (error) {
setAuthMessage(getErrorMessage(error, "Authentication failed")); setAuthMessage(getErrorMessage(error, "Authentication failed"));
@@ -1659,14 +1671,14 @@ export default function App() {
label="Organization name" label="Organization name"
placeholder="Acme Corporation" placeholder="Acme Corporation"
value={organizationInput().name} value={organizationInput().name}
onInput={(e) => setOrganizationInput((c) => ({ ...c, name: e.currentTarget.value }))} onInput={(e) => setOrganizationInput((c) => applySlugField(c, "name", e.currentTarget.value))}
required required
/> />
<Input <Input
label="Slug" label="Slug"
placeholder="acme" placeholder="acme"
value={organizationInput().slug} value={organizationInput().slug}
onInput={(e) => setOrganizationInput((c) => ({ ...c, slug: e.currentTarget.value }))} onInput={(e) => setOrganizationInput((c) => applySlugField(c, "slug", e.currentTarget.value))}
required required
/> />
<button type="submit" class="btn btn-primary w-full" disabled={workspacePending()}> <button type="submit" class="btn btn-primary w-full" disabled={workspacePending()}>
@@ -1712,7 +1724,7 @@ export default function App() {
projectMessage={projectMessage()} projectMessage={projectMessage()}
projectPending={projectPending()} projectPending={projectPending()}
canUpdateProject={canUpdateProject()} canUpdateProject={canUpdateProject()}
onProjectInputChange={(field, value) => setProjectInput((c) => ({ ...c, [field]: value }))} onProjectInputChange={(field, value) => setProjectInput((c) => applySlugField(c, field, value))}
onProjectEditInputChange={(field, value) => setProjectEditInput((c) => ({ ...c, [field]: value }))} onProjectEditInputChange={(field, value) => setProjectEditInput((c) => ({ ...c, [field]: value }))}
onCreateProject={createProject} onCreateProject={createProject}
onUpdateProject={updateActiveProject} onUpdateProject={updateActiveProject}
@@ -1811,7 +1823,7 @@ export default function App() {
previewLoading={objectPreview.loading} previewLoading={objectPreview.loading}
renameObjectKey={renameObjectKeyInput()} renameObjectKey={renameObjectKeyInput()}
moveDestinationBucketID={moveDestinationBucketID()} moveDestinationBucketID={moveDestinationBucketID()}
onBucketInputChange={(field, value) => setBucketInput((c) => ({ ...c, [field]: value }))} onBucketInputChange={(field, value) => setBucketInput((c) => applySlugField(c, field, value))}
onBucketEditInputChange={(field, value) => setBucketEditInput((c) => ({ ...c, [field]: value }))} onBucketEditInputChange={(field, value) => setBucketEditInput((c) => ({ ...c, [field]: value }))}
onCreateBucket={createBucket} onCreateBucket={createBucket}
onUpdateBucket={updateSelectedBucket} onUpdateBucket={updateSelectedBucket}
@@ -1848,7 +1860,7 @@ export default function App() {
collectionPending={collectionPending()} collectionPending={collectionPending()}
documentPending={documentPending()} documentPending={documentPending()}
canUpdate={canUpdateProject()} canUpdate={canUpdateProject()}
onCollectionInputChange={(field, value) => setCollectionInput((c) => ({ ...c, [field]: value }))} onCollectionInputChange={(field, value) => setCollectionInput((c) => applySlugField(c, field, value))}
onCreateCollection={createCollection} onCreateCollection={createCollection}
onDeleteCollection={deleteCollection} onDeleteCollection={deleteCollection}
onSelectCollection={setSelectedCollectionID} onSelectCollection={setSelectedCollectionID}
+2
View File
@@ -1096,6 +1096,8 @@ code {
/* ---------- page scaffold ---------- */ /* ---------- page scaffold ---------- */
.page { .page {
max-width: 72rem; max-width: 72rem;
width: 100%;
margin-inline: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { applySlugField, slugify } from "../slug";
describe("slugify", () => {
it("derives lowercase hyphenated slugs", () => {
expect(slugify("TDvorak")).toBe("tdvorak");
expect(slugify("Acme Corporation")).toBe("acme-corporation");
expect(slugify(" My_App!! v2 ")).toBe("my-app-v2");
});
it("strips accents and non-ascii characters", () => {
expect(slugify("Zürich — Héllo")).toBe("zurich-hello");
expect(slugify("日本語")).toBe("");
});
});
describe("applySlugField", () => {
const form = { name: "", slug: "", description: "" };
it("fills the slug while it is untouched", () => {
const next = applySlugField(form, "name", "My Org");
expect(next.slug).toBe("my-org");
// keeps following name edits
expect(applySlugField(next, "name", "Renamed Org").slug).toBe("renamed-org");
});
it("stops deriving once the slug was hand-edited", () => {
const custom = applySlugField(applySlugField(form, "name", "My Org"), "slug", "custom-slug");
expect(applySlugField(custom, "name", "New Name").slug).toBe("custom-slug");
});
it("resumes deriving if the slug is cleared", () => {
const cleared = applySlugField(applySlugField(form, "name", "My Org"), "slug", "");
expect(applySlugField(cleared, "name", "Other").slug).toBe("other");
});
it("sanitizes manual slug edits", () => {
expect(applySlugField(form, "slug", "Not A Slug!").slug).toBe("not-a-slug");
});
it("passes through unrelated fields", () => {
expect(applySlugField(form, "description", "hi").description).toBe("hi");
});
});
+27
View File
@@ -0,0 +1,27 @@
export function slugify(value: string): string {
return value
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 63);
}
/**
* Applies a field edit to a {name, slug, ...} form. While the slug is
* untouched — empty or still equal to the derived value — renaming the
* entity re-derives its slug. Explicit slug edits are sanitized in place.
*/
export function applySlugField<T extends { name: string; slug: string }>(
current: T,
field: string,
value: string,
): T {
if (field === "slug") return { ...current, slug: slugify(value) };
if (field === "name" && (current.slug === "" || current.slug === slugify(current.name))) {
return { ...current, name: value, slug: slugify(value) };
}
return { ...current, [field]: value };
}
+77 -16
View File
@@ -3,8 +3,14 @@
# #
# curl -fsSL https://raw.githubusercontent.com/Dvorinka/Primora/master/install.sh | bash # curl -fsSL https://raw.githubusercontent.com/Dvorinka/Primora/master/install.sh | bash
# #
# Installs into ./primora (override with PRIMORA_DIR). Non-interactive: # Installs into ./primora (override with PRIMORA_DIR). On a fresh install,
# DOMAIN=example.com NGINX_PORT=8080 bash install.sh # interactive shells get one question — the URL the app will be opened on
# (domain or IP, optional port). Non-interactive:
# PUBLIC_URL=https://primora.example.com bash install.sh
# DOMAIN=192.168.1.50 NGINX_PORT=8085 bash install.sh (legacy form)
#
# https URLs mean TLS terminates at your own proxy in front of Primora's
# bundled nginx (which always speaks http on NGINX_PORT).
# #
# Pulls prebuilt GHCR images when available; otherwise downloads the source # Pulls prebuilt GHCR images when available; otherwise downloads the source
# tarball and builds locally. Either way the result is the same compose stack. # tarball and builds locally. Either way the result is the same compose stack.
@@ -14,8 +20,8 @@ set -euo pipefail
REPO="Dvorinka/Primora" REPO="Dvorinka/Primora"
REF="${PRIMORA_REF:-master}" REF="${PRIMORA_REF:-master}"
DIR="${PRIMORA_DIR:-primora}" DIR="${PRIMORA_DIR:-primora}"
DOMAIN="${DOMAIN:-localhost}"
NGINX_PORT="${NGINX_PORT:-80}" NGINX_PORT="${NGINX_PORT:-80}"
PUBLIC_URL="${PUBLIC_URL:-}"
info() { echo "==> $*"; } info() { echo "==> $*"; }
die() { echo "ERROR: $*" >&2; exit 1; } die() { echo "ERROR: $*" >&2; exit 1; }
@@ -32,12 +38,62 @@ mkdir -p "$DIR/infra/nginx"
cd "$DIR" cd "$DIR"
# The compose stack needs exactly these three files from the repo. # The compose stack needs exactly these three files from the repo.
# Timeouts + retries: a stalled connection must fail loudly, not hang.
info "Fetching compose files ($REF)" info "Fetching compose files ($REF)"
for f in docker-compose.yml .env.example infra/nginx/default.conf; do for f in docker-compose.yml .env.example infra/nginx/default.conf; do
curl -fsSL "https://raw.githubusercontent.com/$REPO/$REF/$f" -o "$f" curl -fsSL --connect-timeout 10 --max-time 60 --retry 3 --retry-delay 1 --retry-all-errors \
"https://raw.githubusercontent.com/$REPO/$REF/$f" -o "$f" \
|| die "could not fetch $f — check connectivity to raw.githubusercontent.com"
done done
if [ ! -f .env ]; then if [ -f .env ]; then
info "Existing .env kept"
# Display URL comes from the file — answers here would change nothing.
PUBLIC_URL="$(grep -E '^VITE_APP_URL=' .env | tail -1 | cut -d= -f2- || true)"
[ -z "$PUBLIC_URL" ] && PUBLIC_URL="http://localhost"
else
# Public URL — the single question. Env wins, then prompt, then default.
if [ -z "$PUBLIC_URL" ] && [ -n "${DOMAIN:-}" ]; then
PUBLIC_URL="http://${DOMAIN}"
[ "$NGINX_PORT" != "80" ] && PUBLIC_URL="$PUBLIC_URL:$NGINX_PORT"
fi
if [ -z "$PUBLIC_URL" ]; then
DEFAULT_URL="http://localhost"
[ "$NGINX_PORT" != "80" ] && DEFAULT_URL="http://localhost:$NGINX_PORT"
# Under `curl | bash` stdin is the script pipe — ask on the controlling
# terminal instead. `true </dev/tty` probes the open quietly; read's own
# redirect fails non-fatally (default applies) where there is no tty.
# Never `exec ... 2>/dev/null` here — exec redirections persist and would
# silence stderr, including the read prompt below, for the whole script.
if true 2>/dev/null </dev/tty; then
read -rp "URL where you'll open Primora (domain or IP, e.g. https://primora.example.com) [$DEFAULT_URL]: " PUBLIC_URL </dev/tty || true
fi
PUBLIC_URL="${PUBLIC_URL:-$DEFAULT_URL}"
fi
# Normalize: default scheme http, strip trailing slash.
case "$PUBLIC_URL" in
http://*|https://*) ;;
*) PUBLIC_URL="http://$PUBLIC_URL" ;;
esac
PUBLIC_URL="${PUBLIC_URL%/}"
# Split host[:port]; URL paths aren't supported — nginx mounts auth at /auth.
REST="${PUBLIC_URL#*://}"
HOSTPORT="${REST%%/*}"
[ "$REST" != "$HOSTPORT" ] && die "URL paths are not supported — use a domain or port instead: $PUBLIC_URL"
if [[ "$HOSTPORT" == *:* ]]; then
HOST="${HOSTPORT%%:*}"
PORT="${HOSTPORT##*:}"
else
HOST="$HOSTPORT"; PORT=""
fi
[ -z "$HOST" ] && die "could not parse a host from: $PUBLIC_URL"
if [ -n "$PORT" ]; then
[[ "$PORT" =~ ^[0-9]+$ ]] || die "invalid port in: $PUBLIC_URL"
NGINX_PORT="$PORT"
fi
info "Generating .env with random secrets" info "Generating .env with random secrets"
cp .env.example .env cp .env.example .env
@@ -53,12 +109,15 @@ if [ ! -f .env ]; then
"${SED[@]}" "s|^PRIMORA_ENCRYPTION_KEY=.*|PRIMORA_ENCRYPTION_KEY=$ENCRYPTION_KEY|" .env "${SED[@]}" "s|^PRIMORA_ENCRYPTION_KEY=.*|PRIMORA_ENCRYPTION_KEY=$ENCRYPTION_KEY|" .env
"${SED[@]}" "s|^NGINX_PORT=.*|NGINX_PORT=$NGINX_PORT|" .env "${SED[@]}" "s|^NGINX_PORT=.*|NGINX_PORT=$NGINX_PORT|" .env
if [ "$DOMAIN" != "localhost" ]; then # Every public URL var (VITE_APP_URL, BETTER_AUTH_URL, AUTH_BASE_URL,
"${SED[@]}" "s|http://localhost|http://$DOMAIN|g" .env # VITE_AUTH_BASE_URL, VITE_API_BASE_URL, BACKEND_PUBLIC_URL) is built on the
"${SED[@]}" "s|^COOKIE_DOMAIN=.*|COOKIE_DOMAIN=$DOMAIN|" .env # http://localhost prefix in .env.example — one rewrite covers them all.
"${SED[@]}" "s|http://localhost|$PUBLIC_URL|g" .env
# Cookie domains only apply to real DNS names — never IPs or localhost.
if [ "$HOST" != "localhost" ] && ! [[ "$HOST" =~ ^[0-9.]+$ ]]; then
"${SED[@]}" "s|^COOKIE_DOMAIN=.*|COOKIE_DOMAIN=$HOST|" .env
fi fi
else
info "Existing .env kept"
fi fi
# Compose v5 skips `pull` for services that declare build:, so fetch the # Compose v5 skips `pull` for services that declare build:, so fetch the
@@ -75,15 +134,17 @@ if docker pull "ghcr.io/$GHCR_OWNER/primora-backend:latest" \
else else
# GHCR packages are private until first published release — build from source. # GHCR packages are private until first published release — build from source.
info "Prebuilt images unavailable; building from source" info "Prebuilt images unavailable; building from source"
curl -fsSL "https://codeload.github.com/$REPO/tar.gz/refs/heads/$REF" | tar xz --strip-components=1 curl -fsSL --connect-timeout 10 --max-time 120 --retry 3 --retry-delay 1 --retry-all-errors \
"https://codeload.github.com/$REPO/tar.gz/refs/heads/$REF" | tar xz --strip-components=1
docker compose up -d --build docker compose up -d --build
fi fi
DISPLAY_URL="http://$DOMAIN"
[ "$NGINX_PORT" != "80" ] && DISPLAY_URL="$DISPLAY_URL:$NGINX_PORT"
info "Primora is starting" info "Primora is starting"
echo " Dashboard: $DISPLAY_URL" echo " Dashboard: $PUBLIC_URL"
echo " Health: $DISPLAY_URL/api/v1/health/liveness" echo " Health: $PUBLIC_URL/api/v1/health/liveness"
echo " Logs: cd $DIR && docker compose logs -f" echo " Logs: cd $DIR && docker compose logs -f"
echo " Stop: cd $DIR && docker compose down" echo " Stop: cd $DIR && docker compose down"
if [[ "$PUBLIC_URL" == https://* ]]; then
echo
echo " Primora listens on http port $NGINX_PORT — point your TLS proxy there."
fi
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "primora", "name": "primora",
"version": "0.8.1", "version": "0.8.5",
"private": true, "private": true,
"packageManager": "[email protected]", "packageManager": "[email protected]",
"workspaces": [ "workspaces": [