Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,18 @@ Create HTML widgets with theme support. See `server/widgets/README.md` for detai

## 🔒 Security

**HomeGlow is not secure, and it is not designed to be exposed to the internet.** It's a local-only app for a trusted home network. It has no logins or user accounts, so any device that can reach it can read and change its data.

- **Keep it on your home network.** Don't port-forward it or publish it. To reach it away from home, connect to your network with a VPN (WireGuard, Tailscale).
- **The admin PIN is tamper resistance, not access control.** It keeps household members who shouldn't make changes, such as children at a wall display or guests, out of the Admin Panel. It doesn't stop someone on your network who knows how to call HomeGlow's API directly.
- **What HomeGlow does enforce:**
- downloading a backup, restoring one, and Home Assistant unlock actions need the PIN on the server too;
- wrong PINs are rate-limited;
- other websites can't use a browser on your network to read or change HomeGlow's data.

DISCLAIMER: This project uses AI in its development process. While you are right to generally be skeptical about AI coding security, this project doesn't have any secure endpoints or credentials for AI to mishandle. Because there is no security implicitly or explicitly provided by this project, there cannot be any security failures from AI. When in doubt, don't expose services to the internet.

**Reverse Proxy**: Use SSL terminators/Cloudflare for HTTPS and custom domains
**Reverse Proxy**: A reverse proxy can add HTTPS and a custom name on your network. If yours reaches the internet (a Cloudflare Tunnel, for example), put a login in front of HomeGlow there, such as Cloudflare Access, because HomeGlow has none of its own.

## 🗂️ Data

Expand Down
5 changes: 3 additions & 2 deletions client/src/components/AdminPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import HomeAssistantPanels from './HomeAssistantPanels';
import ThemeLibrary from './ThemeLibrary';
import axios from 'axios';
import { API_BASE_URL } from '../utils/apiConfig.js';
import { pinErrorText } from '../utils/pinErrors.js';
import { revealTabPicker } from '../utils/revealTabPicker.js';
import { getDeviceApiBase, getDeviceName, setDeviceName, isValidDeviceName } from '../utils/deviceName.js';
import { fetchPinRemembered, setPinRemembered, forgetPinOnAllDevices, shouldPromptForPin } from '../utils/adminPinDevice.js';
Expand Down Expand Up @@ -1443,7 +1444,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
const response = await axios.post(`${API_BASE_URL}/api/admin-pin/verify`, { pin });
valid = response.data?.valid === true;
} catch (error) {
throw new Error(error.response?.data?.error || t('admin:vacation.pinWrong'));
throw new Error(pinErrorText(error, t, t('admin:vacation.pinWrong')));
}
if (!valid) throw new Error(t('admin:vacation.pinWrong'));
setVacationPinOpen(false);
Expand Down Expand Up @@ -1847,7 +1848,7 @@ const AdminPanel = ({ setWidgetSettings, onPluginsChanged, onTabsChanged, onRequ
}
}
} catch (error) {
throw new Error(error.response?.data?.error || 'Invalid PIN. Please try again.');
throw new Error(pinErrorText(error, t, 'Invalid PIN. Please try again.'));
}
};

Expand Down
52 changes: 20 additions & 32 deletions client/src/components/BackupRestoreSection.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Download, Upload } from '@mui/icons-material';
import { useTranslation } from 'react-i18next';
import { API_BASE_URL } from '../utils/apiConfig.js';
import PinModal from './PinModal';
import { useAdminPinPrompt } from '../hooks/useAdminPinPrompt.js';
import RestoreStatusAlerts from './RestoreStatusAlerts.jsx';
import GoogleDriveBackupSection from './GoogleDriveBackupSection.jsx';

Expand Down Expand Up @@ -55,10 +56,12 @@ export default function BackupRestoreSection({ googleConnected = false }) {
const [message, setMessage] = useState(null);
const [busy, setBusy] = useState(false);
const [restarting, setRestarting] = useState(false);
const [exportPinOpen, setExportPinOpen] = useState(false);
const [chosenFile, setChosenFile] = useState(null);
const [uploadProgress, setUploadProgress] = useState(null);
const [confirmRollback, setConfirmRollback] = useState(false);
// Downloading a backup, restoring one and rolling back ask for the admin
// PIN when one is set; the server checks it too.
const { ask: askPin, pinModalProps } = useAdminPinPrompt({ wrongPin: t('admin:backup.wrongPin') });

const loadStatus = useCallback(async () => {
try {
Expand All @@ -79,40 +82,21 @@ export default function BackupRestoreSection({ googleConnected = false }) {
saveBlob(res.data, filenameFrom(res.headers['content-disposition']));
};

const startExport = async () => {
const runExport = async (pin) => {
setMessage(null);
setBusy(true);
try {
const { data } = await axios.get(`${API_BASE_URL}/api/admin-pin/exists`);
if (data?.exists === true) {
setExportPinOpen(true);
return;
}
await download();
setMessage({ type: 'success', text: t('admin:backup.exported') });
} catch (error) {
setMessage({ type: 'error', text: (await readBlobError(error)) || t('admin:backup.exportFailed') });
} finally {
setBusy(false);
}
};

// The PIN dialog: a wrong PIN stays in the dialog; anything else closes it.
const exportWithPin = async (pin) => {
try {
setBusy(true);
await download(pin);
setExportPinOpen(false);
setMessage({ type: 'success', text: t('admin:backup.exported') });
} catch (error) {
if (error?.response?.status === 403) throw new Error(t('admin:backup.wrongPin'));
setExportPinOpen(false);
setMessage({ type: 'error', text: (await readBlobError(error)) || t('admin:backup.exportFailed') });
} finally {
setBusy(false);
}
};

const startExport = () => askPin(t('admin:backup.pinTitle'), runExport);

// --- import -------------------------------------------------------------------

const chooseFile = (event) => {
Expand All @@ -121,14 +105,20 @@ export default function BackupRestoreSection({ googleConnected = false }) {
if (file) setChosenFile(file);
};

const importFile = async () => {
const confirmImport = () => {
const file = chosenFile;
setChosenFile(null);
if (file) void askPin(t('admin:backup.pinTitleImport'), (pin) => importFile(file, pin));
};

const importFile = async (file, pin) => {
setMessage(null);
setBusy(true);
setUploadProgress(0);
try {
const form = new FormData();
// Before the file: the server checks the PIN before it reads the upload.
if (pin) form.append('pin', pin);
form.append('file', file, file.name);
await axios.post(`${API_BASE_URL}/api/backup/local/import`, form, {
onUploadProgress: (event) => {
Expand Down Expand Up @@ -173,7 +163,10 @@ export default function BackupRestoreSection({ googleConnected = false }) {
const cancel = () => act(() => axios.post(`${API_BASE_URL}/api/backup/drive/restore/cancel`), t('admin:backup.cancelled'));
const rollBack = () => {
setConfirmRollback(false);
void act(() => axios.post(`${API_BASE_URL}/api/backup/drive/restore/rollback`), t('admin:backup.rollbackStaged'));
void askPin(t('admin:backup.pinTitleRollback'), (pin) => act(
() => axios.post(`${API_BASE_URL}/api/backup/drive/restore/rollback`, pin ? { pin } : {}),
t('admin:backup.rollbackStaged'),
));
};

return (
Expand Down Expand Up @@ -248,7 +241,7 @@ export default function BackupRestoreSection({ googleConnected = false }) {
</DialogContent>
<DialogActions>
<Button onClick={() => setChosenFile(null)}>{t('common:actions.cancel')}</Button>
<Button color="warning" variant="contained" onClick={importFile}>{t('admin:backup.importConfirm')}</Button>
<Button color="warning" variant="contained" onClick={confirmImport}>{t('admin:backup.importConfirm')}</Button>
</DialogActions>
</Dialog>

Expand All @@ -263,12 +256,7 @@ export default function BackupRestoreSection({ googleConnected = false }) {
</DialogActions>
</Dialog>

<PinModal
open={exportPinOpen}
onClose={() => setExportPinOpen(false)}
onVerify={exportWithPin}
title={t('admin:backup.pinTitle')}
/>
<PinModal {...pinModalProps} />
</Box>
);
}
8 changes: 7 additions & 1 deletion client/src/components/ChoreWidget.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { useTranslation } from 'react-i18next';
import LoadingBackdrop from './LoadingBackdrop';
import PinModal from './PinModal';
import { API_BASE_URL } from '../utils/apiConfig.js';
import { pinErrorText } from '../utils/pinErrors.js';
import { getDeviceApiBase } from '../utils/deviceName.js';
import { fetchPinRemembered, setPinRemembered, shouldPromptForPin } from '../utils/adminPinDevice.js';
import { shouldShowChoreToday, getTodayDateString, convertDaysToCrontab, getDueDateStatus, formatDueDate, hasOutstandingBonusChore, countsTowardDailyBonus } from '../utils/choreHelpers.js';
Expand Down Expand Up @@ -850,7 +851,12 @@ const ChoreWidget = ({ refreshNonce = 0, hiddenControls = [] }) => {
};

const handlePinVerify = async (pin, remember) => {
const response = await axios.post(`${API_BASE_URL}/api/admin-pin/verify`, { pin });
let response;
try {
response = await axios.post(`${API_BASE_URL}/api/admin-pin/verify`, { pin });
} catch (error) {
throw new Error(pinErrorText(error, t, 'Incorrect PIN. Please try again.'));
}
if (!response.data?.valid) {
throw new Error('Incorrect PIN. Please try again.');
}
Expand Down
57 changes: 20 additions & 37 deletions client/src/components/GoogleDriveBackupSection.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { CloudUpload, Refresh, Schedule, SettingsBackupRestore, Key } from '@mui
import axios from 'axios';
import { API_BASE_URL } from '../utils/apiConfig.js';
import PinModal from './PinModal';
import { useAdminPinPrompt } from '../hooks/useAdminPinPrompt.js';

// Google Drive backup, as part of Backup & restore (BackupRestoreSection, in
// System → Connections; shown once a Google account is connected). Nightly or
Expand All @@ -24,6 +25,10 @@ export default function GoogleDriveBackupSection({ restore = {}, onRestoreChange
const [timezone, setTimezone] = useState('');

const [confirmFile, setConfirmFile] = useState(null);
// Staging a restore and downloading a backup's key ask for the admin PIN
// when one is set, even on a display that remembers it; the server checks
// it too.
const { ask: askPin, pinModalProps } = useAdminPinPrompt({ wrongPin: 'Incorrect PIN. Please try again.' });

const fetchConfigAndBackups = useCallback(async () => {
setLoading(true);
Expand Down Expand Up @@ -72,14 +77,17 @@ export default function GoogleDriveBackupSection({ restore = {}, onRestoreChange
}
};

const handleConfirmRestore = async () => {
const handleConfirmRestore = () => {
const file = confirmFile;
setConfirmFile(null);
if (!file) return;
if (file) void askPin('Enter the admin PIN to restore this backup', (pin) => stageRestore(file, pin));
};

const stageRestore = async (file, pin) => {
setLoading(true);
setMessage(null);
try {
await axios.post(`${API_BASE_URL}/api/backup/drive/restore/stage`, { fileId: file.id });
await axios.post(`${API_BASE_URL}/api/backup/drive/restore/stage`, { fileId: file.id, ...(pin ? { pin } : {}) });
setMessage({
type: 'success',
text: `Restore of "${file.name}" is staged and verified. Restart HomeGlow to apply it.`
Expand All @@ -92,11 +100,6 @@ export default function GoogleDriveBackupSection({ restore = {}, onRestoreChange
}
};

// The key unlocks every stored credential, so the server asks for the
// admin PIN (when one is set) before handing it over, even on a device
// that remembers the PIN for the rest of the panel.
const [keyPinOpen, setKeyPinOpen] = useState(false);

const saveBackupKey = async (fileId, pin) => {
const res = await axios.post(
`${API_BASE_URL}/api/backup/drive/backup-key`,
Expand All @@ -113,32 +116,17 @@ export default function GoogleDriveBackupSection({ restore = {}, onRestoreChange
URL.revokeObjectURL(url);
};

const handleDownloadBackupKey = async () => {
// The key unlocks every stored credential.
const handleDownloadBackupKey = () => {
const file = confirmFile;
if (!file) return;
try {
const { data } = await axios.get(`${API_BASE_URL}/api/admin-pin/exists`);
if (data?.exists === true) {
setKeyPinOpen(true);
return;
void askPin('Enter the admin PIN to download the encryption key', async (pin) => {
try {
await saveBackupKey(file.id, pin);
} catch (err) {
setMessage({ type: 'error', text: 'That backup does not contain an encryption key, or it could not be downloaded.' });
}
await saveBackupKey(file.id);
} catch (err) {
setMessage({ type: 'error', text: 'That backup does not contain an encryption key, or it could not be downloaded.' });
}
};

const handleKeyPin = async (pin) => {
const file = confirmFile;
if (!file) return;
try {
await saveBackupKey(file.id, pin);
setKeyPinOpen(false);
} catch (err) {
if (err.response?.status === 403) throw new Error('Incorrect PIN. Please try again.');
setKeyPinOpen(false);
setMessage({ type: 'error', text: 'That backup does not contain an encryption key, or it could not be downloaded.' });
}
});
};

const formatFileDate = (isoString) => {
Expand Down Expand Up @@ -267,12 +255,7 @@ export default function GoogleDriveBackupSection({ restore = {}, onRestoreChange
</Button>
</DialogActions>
</Dialog>
<PinModal
open={keyPinOpen}
onClose={() => setKeyPinOpen(false)}
onVerify={handleKeyPin}
title="Enter the admin PIN to download the encryption key"
/>
<PinModal {...pinModalProps} />
</Box>
);
}
59 changes: 59 additions & 0 deletions client/src/hooks/useAdminPinPrompt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useState } from 'react';
import axios from 'axios';
import { useTranslation } from 'react-i18next';
import { API_BASE_URL } from '../utils/apiConfig.js';
import { pinErrorText } from '../utils/pinErrors.js';

/**
* For an action the server itself guards with the admin PIN: a backup
* download, staging a restore or a rollback (server/utils/adminPin.js).
*
* `ask(title, run)` runs `run()` straight away when no PIN is set. Otherwise
* it asks for the PIN, even on a display that remembers it, checks it, then
* closes the dialog and calls `run(pin)`. The action runs after the dialog
* closes, so a long upload or download shows its progress on the page, and
* `run` reports its own failures.
*
* Spread `pinModalProps` onto a PinModal.
*/
export function useAdminPinPrompt({ wrongPin }) {
const { t } = useTranslation('common');
const [pending, setPending] = useState(null);

const ask = async (title, run) => {
try {
const { data } = await axios.get(`${API_BASE_URL}/api/admin-pin/exists`);
if (data?.exists === true) {
setPending({ title, run });
return;
}
} catch {
// The action itself reports what went wrong.
}
await run();
};

const verify = async (pin) => {
let valid = false;
try {
const { data } = await axios.post(`${API_BASE_URL}/api/admin-pin/verify`, { pin });
valid = data?.valid === true;
} catch (error) {
throw new Error(pinErrorText(error, t, wrongPin));
}
if (!valid) throw new Error(wrongPin);
const { run } = pending;
setPending(null);
await run(pin);
};

return {
ask,
pinModalProps: {
open: Boolean(pending),
title: pending?.title || '',
onClose: () => setPending(null),
onVerify: verify,
},
};
}
2 changes: 2 additions & 0 deletions client/src/i18n/locales/en/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,8 @@
"exported": "Backup downloaded.",
"exportFailed": "The backup could not be downloaded.",
"pinTitle": "Enter the admin PIN to download a backup",
"pinTitleImport": "Enter the admin PIN to restore a backup",
"pinTitleRollback": "Enter the admin PIN to roll back the restore",
"wrongPin": "Incorrect PIN. Please try again.",
"importHeading": "Restore from a backup",
"importHelp": "Choose a backup file (.tar.gz) made here or by Google Drive backup. It is checked first, your current data is saved so you can roll back, and nothing changes until HomeGlow restarts.",
Expand Down
3 changes: 2 additions & 1 deletion client/src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
},
"pin": {
"confirmWithAdminPin": "Confirm with Admin PIN",
"statusCheckFailed": "Could not confirm admin PIN status. Please try again."
"statusCheckFailed": "Could not confirm admin PIN status. Please try again.",
"tooManyTries": "Too many wrong PINs. Try again in {{seconds}} seconds."
},
"language": {
"label": "Language",
Expand Down
2 changes: 2 additions & 0 deletions client/src/i18n/locales/es/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,8 @@
"exported": "Copia descargada.",
"exportFailed": "No se pudo descargar la copia.",
"pinTitle": "Introduce el PIN de administrador para descargar una copia",
"pinTitleImport": "Introduce el PIN de administrador para restaurar una copia",
"pinTitleRollback": "Introduce el PIN de administrador para deshacer la restauración",
"wrongPin": "PIN incorrecto. Inténtalo de nuevo.",
"importHeading": "Restaurar desde una copia",
"importHelp": "Elige un archivo de copia (.tar.gz) hecho aquí o con la copia de Google Drive. Primero se comprueba, tus datos actuales se guardan para poder deshacerlo y nada cambia hasta que HomeGlow se reinicie.",
Expand Down
3 changes: 2 additions & 1 deletion client/src/i18n/locales/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
},
"pin": {
"confirmWithAdminPin": "Confirmar con el PIN de administrador",
"statusCheckFailed": "No se pudo comprobar el estado del PIN. Inténtalo de nuevo."
"statusCheckFailed": "No se pudo comprobar el estado del PIN. Inténtalo de nuevo.",
"tooManyTries": "Demasiados PIN incorrectos. Inténtalo de nuevo en {{seconds}} segundos."
},
"language": {
"label": "Idioma",
Expand Down
Loading
Loading