diff --git a/.gitignore b/.gitignore index 2931966..aa1890d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ node_modules npm-debug.log* yarn-debug.log* yarn-error.log* +/logs # local env files .env @@ -43,4 +44,4 @@ tsconfig.tsbuildinfo # Editor files # - IntelliJ IDEA .idea/ -*.iml +*.iml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 28bfca8..67eaee4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,7 @@ COPY --from=builder /app/node_modules ./node_modules VOLUME /app/config # VOLUME /app/data -ENV CONFIG_FILE=/app/config/notea.yml +ENV CONFIG_FILE=/app/config/notea.yml LOG_DIRECTORY=/app/logs EXPOSE 3000 diff --git a/components/debug/debug-info-copy-button.tsx b/components/debug/debug-info-copy-button.tsx new file mode 100644 index 0000000..6ab82c9 --- /dev/null +++ b/components/debug/debug-info-copy-button.tsx @@ -0,0 +1,74 @@ +import { Button } from '@material-ui/core'; +import useI18n from 'libs/web/hooks/use-i18n'; +import { FC } from 'react'; +import { logLevelToString, DebugInformation } from 'libs/shared/debugging'; + +export const DebugInfoCopyButton: FC<{ + debugInfo: DebugInformation; +}> = ({ debugInfo }) => { + const { t } = useI18n(); + + function generateDebugInfo(): string { + let data = + 'Notea debug information' + + '\nTime ' + + new Date(Date.now()).toISOString() + + '\n\n'; + function ensureNewline() { + if (!data.endsWith('\n') && data.length >= 1) { + data += '\n'; + } + } + + if (debugInfo.issues.length > 0) { + ensureNewline(); + data += 'Configuration errors: '; + let i = 1; + const prefixLength = debugInfo.issues.length.toString().length; + for (const issue of debugInfo.issues) { + const prefix = i.toString().padStart(prefixLength, ' ') + ': '; + const empty = ' '.repeat(prefixLength + 2); + + data += prefix + issue.name; + data += empty + '// ' + issue.cause; + data += empty + issue.description; + i++; + } + } else { + data += 'No detected configuration errors.'; + } + + if (debugInfo.logs.length > 0) { + ensureNewline(); + for (const log of debugInfo.logs) { + data += `[${new Date(log.time).toISOString()} ${log.name}] ${logLevelToString(log.level)}: ${log.msg}`; + } + } + + + + return data; + } + + function copyDebugInfo() { + const text = generateDebugInfo(); + + navigator.clipboard + .writeText(text) + .then(() => { + // nothing + }) + .catch((e) => { + console.error( + 'Error when trying to copy debugging information to clipboard: %O', + e + ); + }); + } + + return ( + + {t('Copy debugging information')} + + ); +}; diff --git a/components/debug/issue-list.tsx b/components/debug/issue-list.tsx new file mode 100644 index 0000000..185b3c3 --- /dev/null +++ b/components/debug/issue-list.tsx @@ -0,0 +1,17 @@ +import { FC } from 'react'; +import { Issue } from 'libs/shared/debugging'; +import { Issue as IssueDisplay } from './issue'; + +export const IssueList: FC<{ + issues: Array; +}> = ({ issues }) => { + return ( + + {issues.map((v, i) => { + return ( + + ); + })} + + ); +}; diff --git a/components/debug/issue.tsx b/components/debug/issue.tsx new file mode 100644 index 0000000..cf8050b --- /dev/null +++ b/components/debug/issue.tsx @@ -0,0 +1,165 @@ +import { FC } from 'react'; +import { + getNameFromRecommendation, + getNameFromSeverity, + Issue as IssueInfo, + IssueFix, + IssueSeverity +} from 'libs/shared/debugging'; +import { + Accordion as MuiAccordion, + AccordionDetails as MuiAccordionDetails, + AccordionSummary as MuiAccordionSummary, + withStyles +} from '@material-ui/core'; +import { ChevronDownIcon } from '@heroicons/react/outline'; +import useI18n from 'libs/web/hooks/use-i18n'; + +const Accordion = withStyles({ + root: { + boxShadow: 'none', + '&:not(:last-child)': { + borderBottom: 0, + }, + '&:before': { + display: 'none', + }, + '&$expanded': { + margin: 'auto 0', + }, + }, + expanded: { + }, +})(MuiAccordion); + +const AccordionSummary = withStyles({ + root: { + backgroundColor: 'rgba(0, 0, 0, .03)', + borderBottom: '1px solid rgba(0, 0, 0, .125)', + borderTopRightRadius: 'inherit', + borderBottomRightRadius: 'inherit', + marginBottom: -1, + minHeight: 56, + '&$expanded': { + minHeight: 56, + borderBottomRightRadius: '0' + }, + }, + content: { + '&$expanded': { + margin: '12px 0', + } + }, + expanded: {}, +})(MuiAccordionSummary); + +const AccordionDetails = withStyles((theme) => ({ + root: { + padding: theme.spacing(2), + }, +}))(MuiAccordionDetails); + +interface FixProps { + id: string; + fix: IssueFix; +} +const Fix: FC = ({ id, fix }) => { + const i18n = useI18n(); + const { t } = i18n; + const steps = fix.steps ?? []; + return ( + + } + aria-controls={`${id}-details`} + id={`${id}-summary`} + > + + {fix.recommendation === 0 && ( + {getNameFromRecommendation(fix.recommendation, i18n)} + )} + {fix.description} + + + + {steps.length > 0 ? ( + + {steps.map((step, i) => { + const stepId = `${id}-step-${i}`; + return ( + {step} + ); + })} + + ) : ( + {t('No steps were provided by Notea to perform this fix.')} + )} + + + ); +}; + +interface IssueProps { + issue: IssueInfo; + id: string; +} +export const Issue: FC = function (props) { + const { issue, id } = props; + const i18n = useI18n(); + const { t } = i18n; + + let borderColour: string; + switch (issue.severity) { + case IssueSeverity.SUGGESTION: + borderColour = "border-gray-500"; + break; + case IssueSeverity.WARNING: + borderColour = "border-yellow-100"; + break; + case IssueSeverity.ERROR: + borderColour = "border-red-500"; + break; + case IssueSeverity.FATAL_ERROR: + borderColour = "border-red-900"; + break; + } + + return ( + + } + aria-controls={`${id}-details`} + id={`${id}-summary`} + > + + {getNameFromSeverity(issue.severity, i18n)} + {issue.name} + + + + {issue.description ?? t('No description was provided for this issue.')} + + {issue.fixes.length > 0 ? ( + + {t('Potential fixes')} + + {issue.fixes.map((fix, i) => { + const fixId = `${id}-fix-${i}`; + return ( + + ); + })} + + + ) : ( + {t('No fixes are known by Notea for this issue.')} + )} + + + ); +}; \ No newline at end of file diff --git a/components/debug/logs.tsx b/components/debug/logs.tsx new file mode 100644 index 0000000..437316c --- /dev/null +++ b/components/debug/logs.tsx @@ -0,0 +1,27 @@ +import { FC } from 'react'; +import { logLevelToString, LogLike } from 'libs/shared/debugging'; + +interface LogsProps { + logs: Array +} + +export const Logs: FC = (props) => { + return ( + + {props.logs.length > 0 ? props.logs.map((log, i) => { + return ( + + + {logLevelToString(log.level)} at {new Date(log.time ?? 0).toLocaleString()} from {log.name} + + + {log.msg} + + + ); + }) : ( + No logs. + )} + + ); +}; \ No newline at end of file diff --git a/components/settings/debugging.tsx b/components/settings/debugging.tsx new file mode 100644 index 0000000..0d1fb9f --- /dev/null +++ b/components/settings/debugging.tsx @@ -0,0 +1,20 @@ +import { IssueList } from 'components/debug/issue-list'; +import type { FC } from 'react'; +import { DebugInfoCopyButton } from 'components/debug/debug-info-copy-button'; +import { DebugInformation, Issue } from 'libs/shared/debugging'; + +export const Debugging: FC<{ + debugInfo: DebugInformation; +}> = (props) => { + const issues: Array = [...props.debugInfo.issues].sort((a, b) => b.severity - a.severity); + return ( + + + + + + + ); +}; diff --git a/components/settings/settings-container.tsx b/components/settings/settings-container.tsx index e6f63cd..de831a8 100644 --- a/components/settings/settings-container.tsx +++ b/components/settings/settings-container.tsx @@ -8,6 +8,8 @@ import { ImportOrExport } from './import-or-export'; import { SnippetInjection } from './snippet-injection'; import useI18n from 'libs/web/hooks/use-i18n'; import { SettingsHeader } from './settings-header'; +import { Debugging } from 'components/settings/debugging'; +import { DebugInformation } from 'libs/shared/debugging'; export const defaultFieldConfig: TextFieldProps = { fullWidth: true, @@ -26,7 +28,9 @@ const HR = () => { return ; }; -export const SettingsContainer: FC = () => { +export const SettingsContainer: FC<{ + debugInfo: DebugInformation +}> = (props) => { const { t } = useI18n(); return ( @@ -36,6 +40,7 @@ export const SettingsContainer: FC = () => { + { 'Import a zip file containing markdown files to this location, or export all pages from this location.' )} > - + + + + + ); }; diff --git a/libs/server/config.ts b/libs/server/config.ts index a15dc0e..242b680 100644 --- a/libs/server/config.ts +++ b/libs/server/config.ts @@ -1,6 +1,10 @@ import yaml from 'js-yaml'; import * as env from 'libs/shared/env'; import { existsSync, readFileSync } from 'fs'; +import { isProbablyError } from 'libs/shared/util'; +import { createLogger, Issue, IssueCategory, IssueFixRecommendation, IssueSeverity } from 'libs/server/debugging'; + +const logger = createLogger("config"); export type BasicUser = { username: string; password: string }; type BasicMultiUserConfiguration = { @@ -45,23 +49,106 @@ export interface Configuration { // eslint-disable-next-line @typescript-eslint/no-unused-vars let loaded: Configuration | undefined = undefined; -export function loadConfig() { +enum ErrTitle { + CONFIG_FILE_READ_FAIL = 'Failed to load configuration file', + INVALID_AUTH_CONFIG = 'Invalid authorisation configuration', + CONFIG_FILE_PARSE_FAIL = 'Could not parse configuration file', +} + +export function loadConfigAndListErrors(): { + config?: Configuration; + errors: Array; +} { + logger.debug("Loading configuration from scratch (loadConfigAndListErrors)"); + // TODO: More config errors const configFile = env.getEnvRaw('CONFIG_FILE', false) ?? './notea.yml'; + const errors: Array = []; let baseConfig: Configuration = {} as Configuration; if (existsSync(configFile)) { - const data = readFileSync(configFile, 'utf-8'); - baseConfig = yaml.load(data) as Configuration; + let data; + try { + data = readFileSync(configFile, 'utf-8'); + } catch (e) { + let cause; + if (isProbablyError(e)) { + cause = e.message; + } + errors.push({ + name: ErrTitle.CONFIG_FILE_READ_FAIL, + description: "The configuration file couldn't be read.", + cause, + severity: IssueSeverity.WARNING, + category: IssueCategory.CONFIG, + fixes: [ + { + description: "Make sure Notea has read access to the configuration file", + recommendation: IssueFixRecommendation.NEUTRAL, + }, + { + description: 'Make sure no other programme is using the configuration file', + recommendation: IssueFixRecommendation.NEUTRAL + } + ], + }); + } + if (data) { + try { + baseConfig = yaml.load(data) as Configuration; + } catch (e) { + let cause; + if (isProbablyError(e)) { + cause = e.message; + } + errors.push({ + name: ErrTitle.CONFIG_FILE_PARSE_FAIL, + description: + 'The configuration file could not be parsed, probably due to a syntax error.', + severity: IssueSeverity.WARNING, + category: IssueCategory.CONFIG, + cause, + fixes: [ + { + description: 'Check your configuration file for syntax errors.', + recommendation: IssueFixRecommendation.RECOMMENDED + } + ], + }); + } + } } - const disablePassword = env.parseBool(env.getEnvRaw('DISABLE_PASSWORD', false), false); + const disablePassword = env.parseBool( + env.getEnvRaw('DISABLE_PASSWORD', false), + false + ); - let auth: AuthConfiguration; + let auth: AuthConfiguration = { type: 'none' }; if (!disablePassword) { const envPassword = env.getEnvRaw('PASSWORD', false); if (baseConfig.auth === undefined) { if (envPassword === undefined) { - throw new Error('Authentication undefined'); + errors.push({ + name: ErrTitle.INVALID_AUTH_CONFIG, + description: + 'Neither the configuration file, the PASSWORD environment variable, nor the DISABLE_PASSWORD environment variable was set.', + severity: IssueSeverity.FATAL_ERROR, + category: IssueCategory.CONFIG, + fixes: [ + { + description: 'Include an auth section the configuration file', + recommendation: IssueFixRecommendation.RECOMMENDED, + }, + { + description: 'Set the password using the environment variable', + recommendation: IssueFixRecommendation.NEUTRAL, + }, + { + description: 'Disable authentication', + recommendation: IssueFixRecommendation.NOT_ADVISED + } + ], + }); } else { auth = { type: 'basic', @@ -71,14 +158,39 @@ export function loadConfig() { } else { auth = baseConfig.auth; if (envPassword !== undefined) { - throw new Error( - 'Cannot specify PASSWORD when auth config section is present' - ); + errors.push({ + name: ErrTitle.INVALID_AUTH_CONFIG, + description: + 'The PASSWORD environment variable cannot be set when the file configuration contains an auth section.', + category: IssueCategory.CONFIG, + severity: IssueSeverity.FATAL_ERROR, + fixes: [ + { + description: "Don't set the PASSWORD environment variable prior to running Notea.", + recommendation: IssueFixRecommendation.RECOMMENDED + }, + { + description: 'Remove the auth section from your file configuration.', + recommendation: IssueFixRecommendation.NEUTRAL + } + ], + }); } if (auth.type === 'basic') { if (auth.users) { // TEMPORARILY; - throw new Error('Multiple users are not yet supported'); + errors.push({ + name: ErrTitle.INVALID_AUTH_CONFIG, + description: 'Multiple users are not yet supported', + severity: IssueSeverity.FATAL_ERROR, + category: IssueCategory.CONFIG, + fixes: [ + { + description: "Change to a single-user configuration.", + recommendation: IssueFixRecommendation.RECOMMENDED + } + ] + }); /*for (const user of auth.users) { user.username = user.username.toString(); @@ -132,7 +244,10 @@ export function loadConfig() { 'STORE_PREFIX', false, ) ?? store.prefix ?? ''; - store.proxyAttachments = env.parseBool(env.getEnvRaw('DIRECT_RESPONSE_ATTACHMENT', false), store.proxyAttachments ?? false); + store.proxyAttachments = env.parseBool( + env.getEnvRaw('DIRECT_RESPONSE_ATTACHMENT', false), + store.proxyAttachments ?? false + ); } let server: ServerConfiguration; @@ -142,20 +257,50 @@ export function loadConfig() { server = baseConfig.server; } { - server.useSecureCookies = env.parseBool(env.getEnvRaw('COOKIE_SECURE', false), server.useSecureCookies ?? process.env.NODE_ENV === 'production'); + server.useSecureCookies = env.parseBool( + env.getEnvRaw('COOKIE_SECURE', false), + server.useSecureCookies ?? process.env.NODE_ENV === 'production' + ); server.baseUrl = env.getEnvRaw('BASE_URL', false) ?? server.baseUrl; } - loaded = { - auth, - store, - server + return { + config: { + auth, + store, + server, + }, + errors, }; } +const MAX_ERRORS = 2; +export function loadConfig() { + const result = loadConfigAndListErrors(); + + if (!result.config) { + const { errors } = result; + let name = errors + .slice(0, MAX_ERRORS) + .map((v) => v.name) + .join(', '); + if (errors.length > MAX_ERRORS) { + const rest = errors.length - MAX_ERRORS; + name += ' and ' + rest + ' other error' + (rest > 1 ? 's' : ''); + } + throw new Error(name); + } + + loaded = result.config; + + return loaded; +} + export function config(): Configuration { if (!loaded) { + logger.debug("Loading configuration"); loadConfig(); + logger.debug("Successfully loaded configuration"); } return loaded as Configuration; diff --git a/libs/server/connect.ts b/libs/server/connect.ts index 60b483e..4760f6d 100644 --- a/libs/server/connect.ts +++ b/libs/server/connect.ts @@ -18,6 +18,7 @@ import { Settings } from 'libs/shared/settings'; import { TreeModel } from 'libs/shared/tree'; import { UserAgentType } from 'libs/shared/ua'; import { useStore } from './middlewares/store'; +import { DebugInformation } from 'libs/server/debugging'; export interface ServerState { store: StoreProvider; @@ -36,6 +37,7 @@ export interface ServerProps { tree?: TreeModel; ua?: UserAgentType; disablePassword: boolean; + debugInformation?: DebugInformation; } export type ApiRequest = NextApiRequest & { diff --git a/libs/server/debugging.ts b/libs/server/debugging.ts new file mode 100644 index 0000000..268cbad --- /dev/null +++ b/libs/server/debugging.ts @@ -0,0 +1,68 @@ +import { loadConfigAndListErrors } from 'libs/server/config'; +import pino from 'pino'; +import pinoPretty from 'pino-pretty'; +import Logger = pino.Logger; +import * as path from 'path'; +import * as fs from 'fs'; +import { DebugInformation, Issue } from 'libs/shared/debugging'; + +export * from 'libs/shared/debugging'; // here's a lil' lesson in trickery + +const runtimeIssues: Array = []; +export function reportRuntimeIssue(issue: Issue) { + runtimeIssues.push({ + ...issue, + isRuntime: true + }); +} + +export function findIssues(): Array { + const issues: Array = []; + + const cfg = loadConfigAndListErrors(); + issues.push(...cfg.errors); + + issues.push(...runtimeIssues); + + return issues; +} + +export function collectDebugInformation(): DebugInformation { + const issues = findIssues(); + return { + issues, + logs: [] + }; +} + +function getLogFile(name: string) { + const dir = path.resolve(process.cwd(), process.env.LOG_DIRECTORY ?? 'logs'); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { + recursive: true + }); + } + + return path.resolve(dir, `${name}.log`); +} + +const loggerTransport: Parameters[0] = [ + { + stream: fs.createWriteStream(getLogFile('debug'), { flags: 'a' }), + level: "debug" + }, + { + stream: pinoPretty(), + level: "info" + } +]; + +const multistream = pino.multistream(loggerTransport); + +export function createLogger(name: string): Logger { + return pino({ + name, + level: "trace", + }, multistream); +} \ No newline at end of file diff --git a/libs/server/middlewares/misconfiguration.ts b/libs/server/middlewares/misconfiguration.ts new file mode 100644 index 0000000..634c02d --- /dev/null +++ b/libs/server/middlewares/misconfiguration.ts @@ -0,0 +1,14 @@ +import { SSRMiddleware } from '../connect'; +import { collectDebugInformation } from 'libs/server/debugging'; + +export const applyMisconfiguration: SSRMiddleware = async (req, _res, next) => { + // const IS_DEMO = getEnv('IS_DEMO', false); + + const collected = collectDebugInformation(); + req.props = { + ...req.props, + debugInformation: collected + }; + + next(); +}; diff --git a/libs/server/middlewares/settings.ts b/libs/server/middlewares/settings.ts index 4eafa42..be81977 100644 --- a/libs/server/middlewares/settings.ts +++ b/libs/server/middlewares/settings.ts @@ -1,19 +1,31 @@ import { DEFAULT_SETTINGS } from 'libs/shared/settings'; import { getSettings } from 'pages/api/settings'; import { SSRMiddleware } from '../connect'; +import { Redirect } from 'next'; export const applySettings: SSRMiddleware = async (req, _res, next) => { - const settings = await getSettings(req.state.store); + let settings, redirect: Redirect = req.redirect; + try { + settings = await getSettings(req.state.store); + } catch (e) { + redirect = { + permanent: false, + destination: '/debug' + }; + } let lngDict = {}; - // import language dict - if (settings.locale && settings.locale !== DEFAULT_SETTINGS.locale) { - lngDict = (await import(`locales/${settings.locale}.json`)).default; + if (settings) { + // import language dict + if (settings.locale && settings.locale !== DEFAULT_SETTINGS.locale) { + lngDict = (await import(`locales/${settings.locale}.json`)).default; + } } req.props = { ...req.props, - ...{ settings, lngDict }, + ...{ settings, lngDict } }; + req.redirect = redirect; next(); }; diff --git a/libs/shared/debugging.ts b/libs/shared/debugging.ts new file mode 100644 index 0000000..3d3d9cf --- /dev/null +++ b/libs/shared/debugging.ts @@ -0,0 +1,89 @@ +import { ContextProps as I18nContextProps } from 'libs/web/utils/i18n-provider'; +import pino from 'pino'; + +export enum IssueCategory { + CONFIG = "config", + MISC = "misc", + STORE = "store", +} +export enum IssueSeverity { + /** + * Suggestions are issues that suggest things the user can do to improve + * their experience with Notea. + */ + SUGGESTION = 0, + /** + * Warnings are issues that aren't severe enough to cause major issues by + * themselves, but that *could* cause worse issues. + */ + WARNING = 1, + /** + * Errors are issues that are severe enough to cause major issues by themselves. + * They don't necessarily cause the entire instance to stop working though. + */ + ERROR = 2, + /** + * Fatal errors are issues that must be resolved before Notea starts working. + */ + FATAL_ERROR = 3 +} +export function getNameFromSeverity(severity: IssueSeverity, { t }: I18nContextProps) { + switch (severity) { + case IssueSeverity.SUGGESTION: + return t('Suggestion'); + case IssueSeverity.WARNING: + return t('Warning'); + case IssueSeverity.ERROR: + return t('Error'); + case IssueSeverity.FATAL_ERROR: + return t('Fatal error'); + } +} + +export enum IssueFixRecommendation { + NEUTRAL, + RECOMMENDED, + NOT_ADVISED +} +export function getNameFromRecommendation(recommendation: IssueFixRecommendation, { t }: I18nContextProps) { + switch (recommendation) { + case IssueFixRecommendation.NEUTRAL: + return t('Neutral'); + case IssueFixRecommendation.RECOMMENDED: + return t('Recommended'); + case IssueFixRecommendation.NOT_ADVISED: + return t('Not advised'); + } +} + +export interface IssueFix { + recommendation: IssueFixRecommendation; + description: string; + steps?: Array; +} + +export interface Issue { + name: string; + description?: string; + category: IssueCategory; + severity: IssueSeverity; + fixes: Array; + cause?: string; + isRuntime?: boolean; +} + +export interface LogLike { + name: string; + msg: string; + pid: number; + time: number; + level: number; +} +export interface DebugInformation { + issues: Array; + logs: Array; // TODO: Logging +} + +export function logLevelToString(level: number) { + return pino.levels.labels[level]; +} \ No newline at end of file diff --git a/libs/shared/util.ts b/libs/shared/util.ts new file mode 100644 index 0000000..6a376af --- /dev/null +++ b/libs/shared/util.ts @@ -0,0 +1,6 @@ +export function isProbablyError(e: any): e is Error { + if (!e) return false; + if (e instanceof Error) return true; + + return e && e.stack && e.message; +} diff --git a/libs/web/utils/i18n-provider.tsx b/libs/web/utils/i18n-provider.tsx index e7cb8b0..cfbf37b 100644 --- a/libs/web/utils/i18n-provider.tsx +++ b/libs/web/utils/i18n-provider.tsx @@ -10,7 +10,7 @@ const i18n = rosetta>(); export const defaultLanguage = DEFAULT_SETTINGS.locale; export const languages = values(Locale); -interface ContextProps { +export interface ContextProps { activeLocale: Locale; t: Rosetta>['t']; locale: (l: Locale, dict: Record) => void; diff --git a/package.json b/package.json index b98a924..b729bff 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,8 @@ "next-themes": "^0.0.14", "notistack": "^1.0.7", "outline-icons": "^1.27.0", + "pino": "^8.7.0", + "pino-pretty": "^9.1.1", "prosemirror-inputrules": "^1.1.3", "pupa": "^2.1.1", "qss": "^2.0.3", diff --git a/pages/api/settings.ts b/pages/api/settings.ts index 4955453..a775a3e 100644 --- a/pages/api/settings.ts +++ b/pages/api/settings.ts @@ -6,14 +6,31 @@ import { formatSettings, Settings } from 'libs/shared/settings'; import { isEqual } from 'lodash'; import { tryJSON } from 'libs/shared/str'; import { StoreProvider } from 'libs/server/store'; +import { IssueCategory, IssueFixRecommendation, IssueSeverity, reportRuntimeIssue } from 'libs/server/debugging'; export async function getSettings(store: StoreProvider): Promise { const settingsPath = getPathSettings(); let settings; - if (await store.hasObject(settingsPath)) { - settings = tryJSON( - await store.getObject(settingsPath) - ); + try { + if (await store.hasObject(settingsPath)) { + settings = tryJSON( + await store.getObject(settingsPath) + ); + } + } catch (e) { + reportRuntimeIssue({ + category: IssueCategory.STORE, + severity: IssueSeverity.FATAL_ERROR, + name: "Could not get settings", + cause: String(e), + fixes: [ + { + description: "Make sure Notea can connect to the store.", + recommendation: IssueFixRecommendation.RECOMMENDED + } + ] + }); + throw e; } const formatted = formatSettings(settings || {}); diff --git a/pages/debug.tsx b/pages/debug.tsx new file mode 100644 index 0000000..8790241 --- /dev/null +++ b/pages/debug.tsx @@ -0,0 +1,91 @@ +import { ServerProps, ssr, SSRContext } from 'libs/server/connect'; +import { applyMisconfiguration } from 'libs/server/middlewares/misconfiguration'; +import pkg from 'package.json'; +import { IssueList } from 'components/debug/issue-list'; +import { DebugInfoCopyButton } from 'components/debug/debug-info-copy-button'; +import { DebugInformation, IssueSeverity } from 'libs/shared/debugging'; +import { Logs } from 'components/debug/logs'; + +export function DebugPage({ debugInformation }: ServerProps) { + if (!debugInformation) throw new Error('Missing debug information'); + + const issues = debugInformation.issues; + const logs = debugInformation.logs ?? []; + + return ( + + + Backup debugging page + + + + + {issues.length > 0 && ( + + Issues + + + )} + {logs.length > 0 && ( + + Logs + + + )} + {issues.length < 1 && logs.length < 1 && ( + + No debug information available. + + )} + + + + + + ); +} + +export default DebugPage; + +export const getServerSideProps = async (ctx: SSRContext) => { + await ssr().use(applyMisconfiguration).run(ctx.req, ctx.res); + + // has to be cast to non-null + const debugInformation = ctx.req.props.debugInformation as DebugInformation; + + let redirect; + if (!debugInformation.issues.some((v) => v.severity === IssueSeverity.FATAL_ERROR)) { + redirect = { + destination: '/', + permanent: false, + }; + } + + return { + redirect, + props: ctx.req.props, + }; +}; diff --git a/pages/settings.tsx b/pages/settings.tsx index cac9abc..894d159 100644 --- a/pages/settings.tsx +++ b/pages/settings.tsx @@ -11,8 +11,10 @@ import { applyCsrf } from 'libs/server/middlewares/csrf'; import { SettingFooter } from 'components/settings/setting-footer'; import { SSRContext, ssr } from 'libs/server/connect'; import { applyReset } from 'libs/server/middlewares/reset'; +import { applyMisconfiguration } from 'libs/server/middlewares/misconfiguration'; +import { DebugInformation } from 'libs/shared/debugging'; -const SettingsPage: NextPage<{ tree: TreeModel }> = ({ tree }) => { +const SettingsPage: NextPage<{ debugInformation: DebugInformation, tree: TreeModel }> = ({ tree, debugInformation }) => { const { t } = useI18n(); return ( @@ -23,7 +25,7 @@ const SettingsPage: NextPage<{ tree: TreeModel }> = ({ tree }) => { {t('Settings')} - + @@ -41,6 +43,7 @@ export const getServerSideProps = async (ctx: SSRContext) => { .use(applySettings) .use(applyCsrf) .use(applyUA) + .use(applyMisconfiguration) .run(ctx.req, ctx.res); return { diff --git a/tailwind.config.js b/tailwind.config.js index cf453ad..9428289 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -9,6 +9,8 @@ module.exports = { colors: { gray: colors.gray, blue: colors.blue, + red: colors.red, + yellow: colors.yellow, transparent: 'transparent', current: 'currentColor', }, @@ -26,6 +28,7 @@ module.exports = { }, fontFamily: { sans: ['Noto Sans'].concat(defaultConfig.theme.fontFamily['sans']), + mono: defaultConfig.theme.fontFamily['mono'] }, }, variants: { diff --git a/yarn.lock b/yarn.lock index 36fa019..b0e0c5e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3225,6 +3225,11 @@ at-least-node@^1.0.0: resolved "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz" integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== +atomic-sleep@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" + integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== + autoprefixer@^10.2.4: version "10.4.12" resolved "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.12.tgz" @@ -3817,7 +3822,7 @@ color@^4.0.1: color-convert "^2.0.1" color-string "^1.9.0" -colorette@^2.0.16: +colorette@^2.0.16, colorette@^2.0.7: version "2.0.19" resolved "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz" integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== @@ -4117,6 +4122,11 @@ data-urls@^2.0.0: whatwg-mimetype "^2.3.0" whatwg-url "^8.0.0" +dateformat@^4.6.3: + version "4.6.3" + resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-4.6.3.tgz#556fa6497e5217fedb78821424f8a1c22fa3f4b5" + integrity sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA== + dayjs@^1.10.4: version "1.11.5" resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.5.tgz" @@ -4846,6 +4856,11 @@ expect@^27.5.1: jest-matcher-utils "^27.5.1" jest-message-util "^27.5.1" +fast-copy@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/fast-copy/-/fast-copy-3.0.0.tgz#875ebf33b13948ae012b6e51d33da5e6e7571ab8" + integrity sha512-4HzS+9pQ5Yxtv13Lhs1Z1unMXamBdn5nA4bEi1abYpDNSpSp7ODYQ1KPMF6nTatfEzgH6/zPvXKU1zvHiUjWlA== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" @@ -4872,6 +4887,11 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-redact@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.1.2.tgz#d58e69e9084ce9fa4c1a6fa98a3e1ecf5d7839aa" + integrity sha512-+0em+Iya9fKGfEQGcd62Yv6onjBmmhV1uh86XVfOU8VwAe6kaFdQCWI9s0/Nnugx5Vd9tdbZ7e6gE2tR9dzXdw== + fast-safe-stringify@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz" @@ -5182,6 +5202,17 @@ glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, gl once "^1.3.0" path-is-absolute "^1.0.0" +glob@^8.0.0: + version "8.0.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-8.0.3.tgz#415c6eb2deed9e502c68fa44a272e6da6eeca42e" + integrity sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^5.0.1" + once "^1.3.0" + globals@^11.1.0: version "11.12.0" resolved "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz" @@ -5301,6 +5332,14 @@ he@^1.2.0: resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +help-me@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/help-me/-/help-me-4.1.0.tgz#c105e78ba490d6fcaa61a3d0cd06e0054554efab" + integrity sha512-5HMrkOks2j8Fpu2j5nTLhrBhT7VwHwELpqnSnx802ckofys5MO2SkLpgSz3dgNFHV7IYFX2igm5CM75SmuYidw== + dependencies: + glob "^8.0.0" + readable-stream "^3.6.0" + hex-color-regex@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz" @@ -6283,6 +6322,11 @@ jest@^27.1.0: import-local "^3.0.2" jest-cli "^27.5.1" +joycon@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03" + integrity sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" @@ -7191,6 +7235,11 @@ object.values@^1.1.5: define-properties "^1.1.3" es-abstract "^1.19.1" +on-exit-leak-free@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.0.tgz#5c703c968f7e7f851885f6459bf8a8a57edc9cc4" + integrity sha512-VuCaZZAjReZ3vUwgOB8LxAosIurDiAW0s13rI1YwmaP++jvcxP77AWoQvenZebpCA2m8WC1/EosPYPMjnRAp/w== + once@1.4.0, once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz" @@ -7445,6 +7494,56 @@ pinkie@^2.0.0: resolved "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz" integrity sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg== +pino-abstract-transport@^1.0.0, pino-abstract-transport@v1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-1.0.0.tgz#cc0d6955fffcadb91b7b49ef220a6cc111d48bb3" + integrity sha512-c7vo5OpW4wIS42hUVcT5REsL8ZljsUfBjqV/e2sFxmFEFZiq1XLUp5EYLtuDH6PEHq9W1egWqRbnLUP5FuZmOA== + dependencies: + readable-stream "^4.0.0" + split2 "^4.0.0" + +pino-pretty@^9.1.1: + version "9.1.1" + resolved "https://registry.yarnpkg.com/pino-pretty/-/pino-pretty-9.1.1.tgz#e7d64c1db98266ca428ab56567b844ba780cd0e1" + integrity sha512-iJrnjgR4FWQIXZkUF48oNgoRI9BpyMhaEmihonHeCnZ6F50ZHAS4YGfGBT/ZVNsPmd+hzkIPGzjKdY08+/yAXw== + dependencies: + colorette "^2.0.7" + dateformat "^4.6.3" + fast-copy "^3.0.0" + fast-safe-stringify "^2.1.1" + help-me "^4.0.1" + joycon "^3.1.1" + minimist "^1.2.6" + on-exit-leak-free "^2.1.0" + pino-abstract-transport "^1.0.0" + pump "^3.0.0" + readable-stream "^4.0.0" + secure-json-parse "^2.4.0" + sonic-boom "^3.0.0" + strip-json-comments "^3.1.1" + +pino-std-serializers@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-6.0.0.tgz#4c20928a1bafca122fdc2a7a4a171ca1c5f9c526" + integrity sha512-mMMOwSKrmyl+Y12Ri2xhH1lbzQxwwpuru9VjyJpgFIH4asSj88F2csdMwN6+M5g1Ll4rmsYghHLQJw81tgZ7LQ== + +pino@^8.7.0: + version "8.7.0" + resolved "https://registry.yarnpkg.com/pino/-/pino-8.7.0.tgz#58621608a3d8540ae643cdd9194cdd94130c78d9" + integrity sha512-l9sA5uPxmZzwydhMWUcm1gI0YxNnYl8MfSr2h8cwLvOAzQLBLewzF247h/vqHe3/tt6fgtXeG9wdjjoetdI/vA== + dependencies: + atomic-sleep "^1.0.0" + fast-redact "^3.1.1" + on-exit-leak-free "^2.1.0" + pino-abstract-transport v1.0.0 + pino-std-serializers "^6.0.0" + process-warning "^2.0.0" + quick-format-unescaped "^4.0.3" + real-require "^0.2.0" + safe-stable-stringify "^2.3.1" + sonic-boom "^3.1.0" + thread-stream "^2.0.0" + pirates@^4.0.4, pirates@^4.0.5: version "4.0.5" resolved "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz" @@ -7574,6 +7673,11 @@ prismjs@~1.27.0: resolved "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz" integrity sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA== +process-warning@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-2.0.0.tgz#341dbeaac985b90a04ebcd844d50097c7737b2ee" + integrity sha512-+MmoAXoUX+VTHAlwns0h+kFUWFs/3FZy+ZuchkgjyOu3oioLAo2LB5aCfKPh2+P9O18i3m43tUEv3YqttSy0Ww== + process@^0.11.10: version "0.11.10" resolved "https://registry.npmjs.org/process/-/process-0.11.10.tgz" @@ -7809,6 +7913,11 @@ queue-microtask@^1.2.2: resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +quick-format-unescaped@^4.0.3: + version "4.0.4" + resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz#93ef6dd8d3453cbc7970dd614fad4c5954d6b5a7" + integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg== + quick-lru@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz" @@ -7978,6 +8087,11 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +real-require@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/real-require/-/real-require-0.2.0.tgz#209632dea1810be2ae063a6ac084fee7e33fba78" + integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg== + reduce-css-calc@^2.1.8: version "2.1.8" resolved "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-2.1.8.tgz" @@ -8252,6 +8366,11 @@ safe-regex-test@^1.0.0: get-intrinsic "^1.1.3" is-regex "^1.1.4" +safe-stable-stringify@^2.3.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.4.1.tgz#34694bd8a30575b7f94792aa51527551bd733d61" + integrity sha512-dVHE6bMtS/bnL2mwualjc6IxEv1F+OCUpA46pKUj6F8uDbUM0jCCulPqRNPSnWwGNKx5etqMjZYdXtrm5KJZGA== + "safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.1.0: version "2.1.2" resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz" @@ -8302,6 +8421,11 @@ scroll-into-view-if-needed@^2.2.28: dependencies: compute-scroll-into-view "^1.0.17" +secure-json-parse@^2.4.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-2.5.0.tgz#f929829df2adc7ccfb53703569894d051493a6ac" + integrity sha512-ZQruFgZnIWH+WyO9t5rWt4ZEGqCKPwhiw+YbzTwpmT9elgLrLcfuyUiSnwwjUiVy9r4VM3urtbNF1xmEh9IL2w== + semver-compare@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz" @@ -8448,6 +8572,13 @@ smooth-scroll-into-view-if-needed@^1.1.29: dependencies: scroll-into-view-if-needed "^2.2.28" +sonic-boom@^3.0.0, sonic-boom@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/sonic-boom/-/sonic-boom-3.2.0.tgz#ce9f2de7557e68be2e52c8df6d9b052e7d348143" + integrity sha512-SbbZ+Kqj/XIunvIAgUZRlqd6CGQYq71tRRbXR92Za8J/R3Yh4Av+TWENiSiEgnlwckYLyP0YZQWVfyNC0dzLaA== + dependencies: + atomic-sleep "^1.0.0" + source-list-map@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz" @@ -8529,6 +8660,11 @@ split.js@^1.6.0: resolved "https://registry.npmjs.org/split.js/-/split.js-1.6.5.tgz" integrity sha512-mPTnGCiS/RiuTNsVhCm9De9cCAUsrNFFviRbADdKiiV+Kk8HKp/0fWu7Kr8pi3/yBmsqLFHuXGT9UUZ+CNLwFw== +split2@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/split2/-/split2-4.1.0.tgz#101907a24370f85bb782f08adaabe4e281ecf809" + integrity sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ== + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz" @@ -8858,6 +8994,13 @@ text-table@^0.2.0: resolved "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz" integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== +thread-stream@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-2.2.0.tgz#310c03a253f729094ce5d4638ef5186dfa80a9e8" + integrity sha512-rUkv4/fnb4rqy/gGy7VuqK6wE1+1DOCOWy4RMeaV69ZHMP11tQKZvZSip1yTgrKCMZzEMcCL/bKfHvSfDHx+iQ== + dependencies: + real-require "^0.2.0" + throat@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/throat/-/throat-6.0.1.tgz"