From 1a3972d19e686ab92d7b23c6177b38933d5313bd Mon Sep 17 00:00:00 2001
From: Benjamin Evans Chodroff
<4411206+benjaminchodroff@users.noreply.github.com>
Date: Sat, 16 May 2026 10:55:17 +0400
Subject: [PATCH 1/5] Allow SSL certificate on the default site (#422)
Adds the ability to select an SSL certificate and force HTTPS for the
"Default Site" used when nginx is hit with an unknown host. The default
site now listens on 443 with the chosen certificate (custom or
Let's Encrypt) when configured, and optionally redirects HTTP to HTTPS
when ssl_forced is enabled. The "congratulations" option keeps its
existing behavior and ignores SSL fields.
- backend/templates/default.conf: add SSL listener, certificates
include, and optional force-SSL block
- backend/internal/setting.js: hydrate the default-site row with its
certificate before rendering nginx config
- backend/schema/paths/settings/settingID/put.json: accept
certificate_id and ssl_forced in meta
- frontend/src/pages/Settings/DefaultSite.tsx: certificate picker and
force-SSL toggle in the Default Site settings page
- test/cypress/e2e/api/Settings.cy.js: cover new meta fields
---
backend/internal/setting.js | 38 +++++++++++++-
.../schema/paths/settings/settingID/put.json | 9 ++++
backend/templates/default.conf | 17 +++++++
frontend/src/pages/Settings/DefaultSite.tsx | 51 ++++++++++++++++++-
test/cypress/e2e/api/Settings.cy.js | 21 ++++++++
5 files changed, 133 insertions(+), 3 deletions(-)
diff --git a/backend/internal/setting.js b/backend/internal/setting.js
index f8fc711454..630d1a0ddc 100644
--- a/backend/internal/setting.js
+++ b/backend/internal/setting.js
@@ -1,8 +1,42 @@
import fs from "node:fs";
import errs from "../lib/error.js";
+import certificateModel from "../models/certificate.js";
import settingModel from "../models/setting.js";
import internalNginx from "./nginx.js";
+/**
+ * For the default-site setting, fetch the related SSL certificate
+ * (if any) and attach it so the nginx template can render an HTTPS
+ * server block.
+ *
+ * @param {Object} row The default-site setting row
+ * @returns {Promise}
+ */
+const expandDefaultSiteCertificate = async (row) => {
+ if (row?.id !== "default-site") {
+ return row;
+ }
+ const certificateId = Number.parseInt(row?.meta?.certificate_id, 10) || 0;
+ row.certificate_id = certificateId;
+ row.ssl_forced = !!row?.meta?.ssl_forced;
+ row.certificate = null;
+ if (certificateId > 0) {
+ const cert = await certificateModel
+ .query()
+ .where("id", certificateId)
+ .andWhere("is_deleted", 0)
+ .first();
+ if (cert) {
+ row.certificate = cert;
+ } else {
+ // Certificate doesn't exist anymore, reset to no SSL
+ row.certificate_id = 0;
+ row.ssl_forced = false;
+ }
+ }
+ return row;
+};
+
const internalSetting = {
/**
* @param {Access} access
@@ -31,13 +65,15 @@ const internalSetting = {
id: data.id,
});
})
- .then((row) => {
+ .then(async (row) => {
if (row.id === "default-site") {
// write the html if we need to
if (row.value === "html") {
fs.writeFileSync("/data/nginx/default_www/index.html", row.meta.html, { encoding: "utf8" });
}
+ await expandDefaultSiteCertificate(row);
+
// Configure nginx
return internalNginx
.deleteConfig("default")
diff --git a/backend/schema/paths/settings/settingID/put.json b/backend/schema/paths/settings/settingID/put.json
index 050ad44125..4eda4368c6 100644
--- a/backend/schema/paths/settings/settingID/put.json
+++ b/backend/schema/paths/settings/settingID/put.json
@@ -46,6 +46,15 @@
},
"html": {
"type": "string"
+ },
+ "certificate_id": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Certificate ID to use for SSL on the default site, or 0 for none"
+ },
+ "ssl_forced": {
+ "type": "boolean",
+ "description": "Whether to redirect HTTP to HTTPS on the default site"
}
},
"example": {
diff --git a/backend/templates/default.conf b/backend/templates/default.conf
index cc590f9d85..5f66b1eebd 100644
--- a/backend/templates/default.conf
+++ b/backend/templates/default.conf
@@ -10,6 +10,14 @@ server {
listen [::]:80 default;
{% else -%}
#listen [::]:80 default;
+{% endif %}
+{% if certificate and certificate_id > 0 -%}
+ listen 443 ssl default;
+{% if ipv6 -%}
+ listen [::]:443 ssl default;
+{% else -%}
+ #listen [::]:443 ssl default;
+{% endif %}
{% endif %}
server_name default-host.localhost;
access_log /data/logs/default-host_access.log combined;
@@ -18,6 +26,15 @@ server {
include conf.d/include/letsencrypt-acme-challenge.conf;
+{% include "_certificates.conf" %}
+{% if certificate and certificate_id > 0 -%}
+{% if ssl_forced == 1 or ssl_forced == true %}
+ # Force SSL
+ set $trust_forwarded_proto "F";
+ include conf.d/include/force-ssl.conf;
+{% endif %}
+{% endif %}
+
{%- if value == "404" %}
location / {
return 404;
diff --git a/frontend/src/pages/Settings/DefaultSite.tsx b/frontend/src/pages/Settings/DefaultSite.tsx
index 1ee1cb5ef5..b6db537730 100644
--- a/frontend/src/pages/Settings/DefaultSite.tsx
+++ b/frontend/src/pages/Settings/DefaultSite.tsx
@@ -1,13 +1,43 @@
import CodeEditor from "@uiw/react-textarea-code-editor";
-import { Field, Form, Formik } from "formik";
+import cn from "classnames";
+import { Field, Form, Formik, useFormikContext } from "formik";
import { type ReactNode, useState } from "react";
import { Alert } from "react-bootstrap";
-import { Button, Loading } from "src/components";
+import { Button, Loading, SSLCertificateField } from "src/components";
import { useSetSetting, useSetting } from "src/hooks";
import { intl, T } from "src/locale";
import { validateString } from "src/modules/Validations";
import { showObjectSuccess } from "src/notifications";
+function ForceSSLField() {
+ const { values, setFieldValue } = useFormikContext();
+ const v: any = values || {};
+ const hasCertificate = v?.certificateId && v?.certificateId > 0;
+ const sslForced = !!v?.sslForced;
+
+ const toggleClasses = "form-check-input";
+ const toggleEnabled = cn(toggleClasses, "bg-teal");
+
+ return (
+
+ {({ field }: any) => (
+
+ )}
+
+ );
+}
+
export default function DefaultSite() {
const { data, isLoading, error } = useSetting("default-site");
const { mutate: setSetting } = useSetSetting();
@@ -19,12 +49,17 @@ export default function DefaultSite() {
setIsSubmitting(true);
setErrorMsg(null);
+ const certificateId =
+ typeof values.certificateId === "number" && values.certificateId > 0 ? values.certificateId : 0;
+
const payload = {
id: "default-site",
value: values.value,
meta: {
redirect: values.redirect,
html: values.html,
+ certificate_id: certificateId,
+ ssl_forced: certificateId > 0 ? !!values.sslForced : false,
},
};
@@ -69,6 +104,8 @@ export default function DefaultSite() {
value: data?.value || "congratulations",
redirect: data?.meta?.redirect || "",
html: data?.meta?.html || "",
+ certificateId: data?.meta?.certificate_id || 0,
+ sslForced: !!data?.meta?.ssl_forced,
} as any
}
onSubmit={onSubmit}
@@ -247,6 +284,16 @@ export default function DefaultSite() {
)}
)}
+ {values.value !== "congratulations" && (
+
+
+
+
+ )}
diff --git a/test/cypress/e2e/api/Settings.cy.js b/test/cypress/e2e/api/Settings.cy.js
index a925b2d374..26c7d642b0 100644
--- a/test/cypress/e2e/api/Settings.cy.js
+++ b/test/cypress/e2e/api/Settings.cy.js
@@ -122,4 +122,25 @@ describe('Settings endpoints', () => {
expect(data.meta.html).to.be.equal('
hello world
');
});
});
+
+ it('Default Site with SSL disabled (certificate_id 0)', () => {
+ cy.task('backendApiPut', {
+ token: token,
+ path: '/api/settings/default-site',
+ data: {
+ value: '404',
+ meta: {
+ certificate_id: 0,
+ ssl_forced: false,
+ },
+ },
+ }).then((data) => {
+ cy.validateSwaggerSchema('put', 200, '/settings/{settingID}', data);
+ expect(data).to.have.property('id');
+ expect(data.id).to.be.equal('default-site');
+ expect(data).to.have.property('meta');
+ expect(data.meta.certificate_id).to.be.equal(0);
+ expect(data.meta.ssl_forced).to.be.equal(false);
+ });
+ });
});
From 56164e12aac680979f3ae6b594047be0c088282c Mon Sep 17 00:00:00 2001
From: Benjamin Evans Chodroff
<4411206+benjaminchodroff@users.noreply.github.com>
Date: Sat, 16 May 2026 16:03:39 +0400
Subject: [PATCH 2/5] Fix default-site setting response to match schema (#422)
---
backend/internal/setting.js | 37 +++++++++++++++++++------------------
1 file changed, 19 insertions(+), 18 deletions(-)
diff --git a/backend/internal/setting.js b/backend/internal/setting.js
index 630d1a0ddc..b493afd207 100644
--- a/backend/internal/setting.js
+++ b/backend/internal/setting.js
@@ -5,21 +5,18 @@ import settingModel from "../models/setting.js";
import internalNginx from "./nginx.js";
/**
- * For the default-site setting, fetch the related SSL certificate
- * (if any) and attach it so the nginx template can render an HTTPS
- * server block.
+ * Build the nginx render context for the default-site setting by
+ * resolving its SSL certificate (if any). Returns a NEW object so the
+ * underlying row sent back to the API client is not mutated with
+ * fields that aren't part of the setting-object schema.
*
* @param {Object} row The default-site setting row
- * @returns {Promise}
+ * @returns {Promise
)}
diff --git a/test/cypress/e2e/api/Settings.cy.js b/test/cypress/e2e/api/Settings.cy.js
index 26c7d642b0..2bb3a143fe 100644
--- a/test/cypress/e2e/api/Settings.cy.js
+++ b/test/cypress/e2e/api/Settings.cy.js
@@ -143,4 +143,165 @@ describe('Settings endpoints', () => {
expect(data.meta.ssl_forced).to.be.equal(false);
});
});
+
+ it('Default Site with SSL enabled (certificate_id > 0, ssl_forced true)', () => {
+ // Create a custom cert and upload PEM material so the rendered
+ // nginx config can actually point at on-disk files.
+ cy.task('backendApiPost', {
+ token: token,
+ path: '/api/nginx/certificates',
+ data: {
+ provider: 'other',
+ nice_name: 'Default Site Cert',
+ },
+ }).then((cert) => {
+ expect(cert).to.have.property('id');
+ const certID = cert.id;
+
+ cy.task('backendApiPostFiles', {
+ token: token,
+ path: `/api/nginx/certificates/${certID}/upload`,
+ files: {
+ certificate: 'test.example.com.pem',
+ certificate_key: 'test.example.com-key.pem',
+ },
+ }).then(() => {
+ // Attach the cert + force SSL to the default site
+ cy.task('backendApiPut', {
+ token: token,
+ path: '/api/settings/default-site',
+ data: {
+ value: '404',
+ meta: {
+ certificate_id: certID,
+ ssl_forced: true,
+ },
+ },
+ }).then((data) => {
+ cy.validateSwaggerSchema('put', 200, '/settings/{settingID}', data);
+ expect(data.meta.certificate_id).to.be.equal(certID);
+ expect(data.meta.ssl_forced).to.be.equal(true);
+ // Response must NOT leak the joined certificate row or
+ // the resolved certificate_id / ssl_forced at the top
+ // level — those are template-only fields.
+ expect(data).to.not.have.property('certificate');
+ expect(data).to.not.have.property('certificate_id');
+ expect(data).to.not.have.property('ssl_forced');
+ });
+
+ // Clean up so the next test starts fresh
+ cy.task('backendApiDelete', {
+ token: token,
+ path: `/api/nginx/certificates/${certID}`,
+ });
+ });
+ });
+ });
+
+ it('Default Site is unaffected when an unrelated certificate is deleted', () => {
+ // Two custom certs: A is attached to the default-site, B is unrelated.
+ // Deleting B must not touch the default-site's meta.
+ const createCert = (name) => cy.task('backendApiPost', {
+ token: token,
+ path: '/api/nginx/certificates',
+ data: { provider: 'other', nice_name: name },
+ }).then((cert) => {
+ return cy.task('backendApiPostFiles', {
+ token: token,
+ path: `/api/nginx/certificates/${cert.id}/upload`,
+ files: {
+ certificate: 'test.example.com.pem',
+ certificate_key: 'test.example.com-key.pem',
+ },
+ }).then(() => cert.id);
+ });
+
+ createCert('Default-Site Cert A').then((aID) => {
+ createCert('Unrelated Cert B').then((bID) => {
+ cy.task('backendApiPut', {
+ token: token,
+ path: '/api/settings/default-site',
+ data: {
+ value: '404',
+ meta: { certificate_id: aID, ssl_forced: true },
+ },
+ });
+
+ // Delete B (the unrelated one)
+ cy.task('backendApiDelete', {
+ token: token,
+ path: `/api/nginx/certificates/${bID}`,
+ }).then(() => {
+ cy.task('backendApiGet', {
+ token: token,
+ path: '/api/settings/default-site',
+ }).then((data) => {
+ cy.validateSwaggerSchema('get', 200, '/settings/{settingID}', data);
+ // Default-site should still reference A unchanged
+ expect(data.meta.certificate_id).to.be.equal(aID);
+ expect(data.meta.ssl_forced).to.be.equal(true);
+ });
+
+ // Cleanup
+ cy.task('backendApiDelete', {
+ token: token,
+ path: `/api/nginx/certificates/${aID}`,
+ });
+ });
+ });
+ });
+ });
+
+ it('Default Site falls back to no-SSL when its certificate is deleted', () => {
+ cy.task('backendApiPost', {
+ token: token,
+ path: '/api/nginx/certificates',
+ data: {
+ provider: 'other',
+ nice_name: 'Disposable Cert',
+ },
+ }).then((cert) => {
+ const certID = cert.id;
+
+ cy.task('backendApiPostFiles', {
+ token: token,
+ path: `/api/nginx/certificates/${certID}/upload`,
+ files: {
+ certificate: 'test.example.com.pem',
+ certificate_key: 'test.example.com-key.pem',
+ },
+ }).then(() => {
+ cy.task('backendApiPut', {
+ token: token,
+ path: '/api/settings/default-site',
+ data: {
+ value: '404',
+ meta: {
+ certificate_id: certID,
+ ssl_forced: true,
+ },
+ },
+ }).then((data) => {
+ expect(data.meta.certificate_id).to.be.equal(certID);
+ expect(data.meta.ssl_forced).to.be.equal(true);
+ });
+
+ // Delete the cert that the default-site references
+ cy.task('backendApiDelete', {
+ token: token,
+ path: `/api/nginx/certificates/${certID}`,
+ }).then(() => {
+ // The default-site row should have been cleaned up
+ cy.task('backendApiGet', {
+ token: token,
+ path: '/api/settings/default-site',
+ }).then((data) => {
+ cy.validateSwaggerSchema('get', 200, '/settings/{settingID}', data);
+ expect(data.meta.certificate_id).to.be.equal(0);
+ expect(data.meta.ssl_forced).to.be.equal(false);
+ });
+ });
+ });
+ });
+ });
});
From 77c5655edf9cf913e71889cdce38dbf7d02f284a Mon Sep 17 00:00:00 2001
From: Benjamin Evans Chodroff
<4411206+benjaminchodroff@users.noreply.github.com>
Date: Mon, 18 May 2026 18:01:26 +0400
Subject: [PATCH 5/5] retrigger ci