198 files

This commit is contained in:
Home Assistant Version Control
2026-10-11 23:51:59 +00:00
parent 4a64770282
commit 669d229519
198 changed files with 4891 additions and 2815 deletions
@@ -18,6 +18,7 @@ import type { HistoryEntry, MaintenanceObjectResponse } from "../types";
import { entrySpend } from "./parts-cost";
import { intervalSpanDays } from "./interval";
import { statusRank } from "../status-constants";
import { taskIsFor } from "./assignees";
import { addDaysIso, stampDate } from "./ha-time";
export const MAX_OCCURRENCES_PER_TASK = 5;
@@ -201,7 +202,8 @@ function projectTask(input: ProjectionInput): CalendarEvent[] {
* next_due and the overdue status count from that day,
* not from the browser's).
* @param windowDays Number of days to include (7 / 14 / 30).
* @param userFilter null/empty = all; otherwise filter tasks by responsible_user_id.
* @param userFilter null/empty = all; otherwise only the tasks meant for that
* user (helpers/assignees: theirs, or shared with nobody on duty).
*/
export function buildCalendarBuckets(
objects: MaintenanceObjectResponse[],
@@ -220,7 +222,7 @@ export function buildCalendarBuckets(
const tasks = obj.tasks || [];
for (const task of tasks) {
// User filter
if (userFilter && task.responsible_user_id !== userFilter) continue;
if (userFilter && !taskIsFor(task, userFilter)) continue;
// Disabled tasks never produce calendar events
if (task.enabled === false) continue;
const projected = projectTask({
@@ -368,7 +370,7 @@ export function buildPastBuckets(
const entryId = obj.entry_id;
const tasks = obj.tasks || [];
for (const task of tasks) {
if (userFilter && task.responsible_user_id !== userFilter) continue;
if (userFilter && !taskIsFor(task, userFilter)) continue;
const history = fullHistory[pastHistoryKey(entryId, task.id)] ?? ((task.history || []) as HistoryEntryShape[]);
for (const h of history) {
if (typeof h?.timestamp !== "string") continue;
@@ -1,11 +1,62 @@
/** Shared URL-safety guard for rendering user-supplied links.
*
* A user-entered URL (object/task documentation, spare-part shopping link,
* document weblink) must only ever be linkified when it is an absolute http(s)
* URL — never `javascript:`/`data:`/protocol-relative — else it is a stored-XSS
* vector. The `X && /^https?:\/\//i.test(X)` idiom was inlined at ~8 render
* sites; one forgotten copy is a hole, so it lives here once.
* document weblink) must never be linkified as `javascript:`/`data:`/
* protocol-relative, else it is a stored-XSS vector. The
* `X && /^https?:\/\//i.test(X)` idiom was inlined at ~8 render sites; one
* forgotten copy is a hole, so it lives here once.
*
* Since #207 a documentation link or a document's web link may also open an
* app (`obsidian://`, `onenote:`, `mailto:`): every scheme passes except the
* blocked ones, the twin of `helpers/url_safety.py` (a tripwire pins the two).
*/
/** Schemes a link may never use: they run code in the page, open the
* device's own files or the browser's internals, or start Windows handlers
* attackers have abused. */
export const BLOCKED_LINK_SCHEMES: ReadonlySet<string> = new Set([
"about",
"blob",
"data",
"file",
"filesystem",
"javascript",
"livescript",
"ms-appinstaller",
"ms-cxh",
"ms-cxh-full",
"ms-msdt",
"ms-officecmd",
"search",
"search-ms",
"view-source",
"vbscript",
]);
/** The link as a browser reads it before looking at the scheme: ASCII tabs
* and newlines anywhere dropped, control characters and spaces at either
* end trimmed ("java\tscript:" is javascript:). */
export function cleanLink(url: string): string {
// eslint-disable-next-line no-control-regex
return url.replace(/[\t\n\r]/g, "").replace(/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g, "");
}
/** The lower-cased scheme of an absolute link, else null. Two characters at
* least: "C:\manual.pdf" is a drive path, not a link. */
export function linkScheme(url: string | null | undefined): string | null {
const match = /^([a-z][a-z0-9+.-]+):/i.exec(cleanLink(url || ""));
return match ? match[1].toLowerCase() : null;
}
/** An absolute link safe to open: http(s) or an app link — never one of the
* blocked schemes, never relative. */
export function isSafeLink(url: string | null | undefined): url is string {
const scheme = linkScheme(url);
return scheme !== null && !BLOCKED_LINK_SCHEMES.has(scheme);
}
/** Only an absolute http(s) URL — for links that must be web pages (a
* spare part's shop page). */
export function isSafeHttpUrl(url: string | null | undefined): url is string {
return !!url && /^https?:\/\//i.test(url);
}