mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-13 02:02:48 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
385f4059ee |
@@ -274,13 +274,6 @@ http {
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
location /api/logout {
|
||||
auth_request off;
|
||||
rewrite ^/api(/.*)$ $1 break;
|
||||
proxy_pass http://frigate_api;
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
# Allow unauthenticated access to the first_time_login endpoint
|
||||
# so the login page can load help text before authentication.
|
||||
location /api/auth/first_time_login {
|
||||
|
||||
+5
-13
@@ -415,7 +415,7 @@ def create_encoded_jwt(user, role, expiration, secret):
|
||||
)
|
||||
|
||||
|
||||
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure):
|
||||
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, secure):
|
||||
# TODO: ideally this would set secure as well, but that requires TLS
|
||||
# SameSite is intentionally left unset (browsers default to Lax). Setting
|
||||
# SameSite=Lax/Strict would stop the cookie from being sent in cross-origin
|
||||
@@ -427,7 +427,7 @@ def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure
|
||||
key=cookie_name,
|
||||
value=encoded_jwt,
|
||||
httponly=True,
|
||||
max_age=max_age,
|
||||
expires=expiration,
|
||||
secure=secure,
|
||||
)
|
||||
|
||||
@@ -762,7 +762,7 @@ def auth(request: Request):
|
||||
success_response,
|
||||
JWT_COOKIE_NAME,
|
||||
new_encoded_jwt,
|
||||
JWT_SESSION_LENGTH,
|
||||
new_expiration,
|
||||
JWT_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
@@ -875,11 +875,7 @@ def login(request: Request, body: AppPostLoginBody):
|
||||
encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token)
|
||||
response = Response("", 200)
|
||||
set_jwt_cookie(
|
||||
response,
|
||||
JWT_COOKIE_NAME,
|
||||
encoded_jwt,
|
||||
JWT_SESSION_LENGTH,
|
||||
JWT_COOKIE_SECURE,
|
||||
response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE
|
||||
)
|
||||
# Clear admin_first_time_login flag after successful admin login so the
|
||||
# UI stops showing the first-time login documentation link.
|
||||
@@ -1041,11 +1037,7 @@ async def update_password(
|
||||
)
|
||||
# Set new JWT cookie on response
|
||||
set_jwt_cookie(
|
||||
response,
|
||||
JWT_COOKIE_NAME,
|
||||
encoded_jwt,
|
||||
JWT_SESSION_LENGTH,
|
||||
JWT_COOKIE_SECURE,
|
||||
response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
+7
-28
@@ -7,7 +7,7 @@ import operator
|
||||
import time
|
||||
from datetime import datetime
|
||||
from functools import reduce
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
@@ -37,7 +37,6 @@ from frigate.api.defs.response.chat_response import (
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.api.event import _build_attribute_filter_clause, events
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.classification import SemanticSearchModelEnum
|
||||
from frigate.genai.prompts import (
|
||||
build_chat_system_prompt,
|
||||
get_attribute_classifications,
|
||||
@@ -87,23 +86,10 @@ def get_tools(request: Request) -> JSONResponse:
|
||||
tools = get_tool_definitions(
|
||||
semantic_search_enabled=semantic_search_enabled,
|
||||
attribute_classifications=attribute_classifications,
|
||||
embeddings_language=_embeddings_language(config),
|
||||
)
|
||||
return JSONResponse(content={"tools": tools})
|
||||
|
||||
|
||||
def _embeddings_language(config: FrigateConfig) -> Literal["english", "multi"]:
|
||||
"""Return the language capability of the configured embeddings model.
|
||||
|
||||
JinaV1 is English-only; every other option (JinaV2 or a GenAI embeddings
|
||||
provider) handles multiple languages.
|
||||
"""
|
||||
if config.semantic_search.model == SemanticSearchModelEnum.jinav1:
|
||||
return "english"
|
||||
|
||||
return "multi"
|
||||
|
||||
|
||||
def _resolve_zones(
|
||||
zones: list[str],
|
||||
config: FrigateConfig,
|
||||
@@ -112,14 +98,11 @@ def _resolve_zones(
|
||||
"""Map zone names to their canonical config keys, case-insensitively.
|
||||
|
||||
LLMs frequently echo a user's casing ("Front Yard") instead of the
|
||||
configured key ("front_yard"), or fall back to a zone's friendly name
|
||||
("Front Walkway") instead of its ID ("front_walk"). The downstream zone
|
||||
filter is a SQLite GLOB over the JSON-encoded zones column, which stores
|
||||
config keys and is case-sensitive — so an unnormalized name silently
|
||||
returns zero matches. Build a lookup over the relevant cameras' configured
|
||||
zones, keyed by both the config key and the friendly name, and substitute
|
||||
when we find a match; unknown names pass through so behavior matches what
|
||||
the model asked for.
|
||||
configured key ("front_yard"). The downstream zone filter is a SQLite GLOB
|
||||
over the JSON-encoded zones column, which is case-sensitive — so an
|
||||
unnormalized name silently returns zero matches. Build a lookup over the
|
||||
relevant cameras' configured zones and substitute when we find a match;
|
||||
unknown names pass through so behavior matches what the model asked for.
|
||||
"""
|
||||
if not zones:
|
||||
return zones
|
||||
@@ -129,11 +112,8 @@ def _resolve_zones(
|
||||
camera_config = config.cameras.get(camera_id)
|
||||
if camera_config is None:
|
||||
continue
|
||||
for zone_name, zone_config in camera_config.zones.items():
|
||||
for zone_name in camera_config.zones.keys():
|
||||
lookup.setdefault(zone_name.lower(), zone_name)
|
||||
lookup.setdefault(
|
||||
zone_config.get_formatted_name(zone_name).lower(), zone_name
|
||||
)
|
||||
|
||||
return [lookup.get(z.lower(), z) for z in zones]
|
||||
|
||||
@@ -1154,7 +1134,6 @@ async def chat_completion(
|
||||
tools = get_tool_definitions(
|
||||
semantic_search_enabled=semantic_search_enabled,
|
||||
attribute_classifications=attribute_classifications,
|
||||
embeddings_language=_embeddings_language(config),
|
||||
)
|
||||
conversation = []
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
import regex
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
|
||||
REGEXP_TIMEOUT_SECONDS = 1.0
|
||||
|
||||
|
||||
class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
||||
def __init__(
|
||||
@@ -36,10 +34,8 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
||||
if item is None:
|
||||
return False
|
||||
try:
|
||||
return (
|
||||
regex.search(expr, item, timeout=REGEXP_TIMEOUT_SECONDS) is not None
|
||||
)
|
||||
except (regex.error, TimeoutError):
|
||||
return re.search(expr, item) is not None
|
||||
except re.error:
|
||||
return False
|
||||
|
||||
conn.create_function("REGEXP", 2, regexp)
|
||||
|
||||
@@ -6,7 +6,7 @@ transport.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
@@ -249,7 +249,6 @@ def get_attribute_classifications(config: FrigateConfig) -> list[dict[str, Any]]
|
||||
def get_tool_definitions(
|
||||
semantic_search_enabled: bool = False,
|
||||
attribute_classifications: list[dict[str, Any]] | None = None,
|
||||
embeddings_language: Literal["english", "multi"] = "multi",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get OpenAI-compatible tool definitions for Frigate.
|
||||
@@ -259,9 +258,7 @@ def get_tool_definitions(
|
||||
tool exposes an additional `semantic_query` parameter for descriptive
|
||||
queries (e.g. "person riding a lawn mower") and find_similar_objects is
|
||||
included. When attribute classification models are configured, an
|
||||
`attribute` parameter is exposed for filtering by their labels. When the
|
||||
embeddings model only understands English (JinaV1), the `semantic_query`
|
||||
description instructs the model to write the query in English.
|
||||
`attribute` parameter is exposed for filtering by their labels.
|
||||
"""
|
||||
search_objects_properties: dict[str, Any] = {
|
||||
"camera": {
|
||||
@@ -352,14 +349,6 @@ def get_tool_definitions(
|
||||
"When set, combine with label/time/camera/zone filters as "
|
||||
"usual (e.g. label='person', semantic_query='riding a lawn "
|
||||
"mower', after='2024-05-01T00:00:00Z')."
|
||||
+ (
|
||||
" The configured embeddings model only understands "
|
||||
"English, so always write semantic_query in English, "
|
||||
"translating the user's description if they phrased it "
|
||||
"in another language."
|
||||
if embeddings_language == "english"
|
||||
else ""
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
@@ -693,17 +682,14 @@ def build_chat_system_prompt(
|
||||
if camera_config.friendly_name
|
||||
else camera_id.replace("_", " ").title()
|
||||
)
|
||||
zone_descriptors = [
|
||||
f"{zone_config.get_formatted_name(zone_name)} (ID: {zone_name})"
|
||||
for zone_name, zone_config in camera_config.zones.items()
|
||||
]
|
||||
zone_names = list(camera_config.zones.keys())
|
||||
if not has_speed_zone:
|
||||
has_speed_zone = any(
|
||||
zone.distances for zone in camera_config.zones.values()
|
||||
)
|
||||
if zone_descriptors:
|
||||
if zone_names:
|
||||
cameras_info.append(
|
||||
f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_descriptors)})"
|
||||
f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_names)})"
|
||||
)
|
||||
else:
|
||||
cameras_info.append(f" - {friendly_name} (ID: {camera_id})")
|
||||
@@ -713,7 +699,7 @@ def build_chat_system_prompt(
|
||||
cameras_section = (
|
||||
"\n\nAvailable cameras:\n"
|
||||
+ "\n".join(cameras_info)
|
||||
+ "\n\nWhen users refer to cameras or zones by their friendly name (e.g., 'Back Deck Camera', 'Front Walkway'), use the corresponding ID (e.g., 'back_deck_cam', 'front_walk') in tool calls. Tool results also identify zones by their ID, so when presenting cameras or zones back to the user, translate the ID to its friendly name."
|
||||
+ "\n\nWhen users refer to cameras by their friendly name (e.g., 'Back Deck Camera'), use the corresponding camera ID (e.g., 'back_deck_cam') in tool calls."
|
||||
)
|
||||
|
||||
speed_units_section = ""
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Tests for the REGEXP function registered on the main Frigate database.
|
||||
|
||||
Regression coverage for GHSA-q8jx-q884-jcq9: an attacker-controlled
|
||||
catastrophic (ReDoS) pattern reaching the REGEXP sink must not be able to
|
||||
stall the serialized database worker thread.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from frigate.db.sqlitevecq import REGEXP_TIMEOUT_SECONDS, SqliteVecQueueDatabase
|
||||
|
||||
|
||||
class TestRegexpFunction(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
# autostart=False keeps the queue worker thread from spinning up; we
|
||||
# only need the REGEXP registration, exercised on our own connection.
|
||||
self.db = SqliteVecQueueDatabase(":memory:", autostart=False)
|
||||
self.conn = sqlite3.connect(":memory:")
|
||||
self.db._register_regexp(self.conn)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.conn.close()
|
||||
|
||||
def _regexp(self, value: str | None, pattern: str) -> int | None:
|
||||
# SQLite maps "value REGEXP pattern" to regexp(pattern, value).
|
||||
return self.conn.execute("SELECT ? REGEXP ?", (value, pattern)).fetchone()[0]
|
||||
|
||||
def test_normal_patterns_still_match(self) -> None:
|
||||
self.assertTrue(self._regexp("ABC123", "^ABC"))
|
||||
self.assertTrue(self._regexp("ABC123", "ABC.*"))
|
||||
self.assertTrue(self._regexp("ABC123", "[0-9]+$"))
|
||||
self.assertFalse(self._regexp("ABC123", "^XYZ"))
|
||||
|
||||
def test_null_value_does_not_match(self) -> None:
|
||||
self.assertFalse(self._regexp(None, ".*"))
|
||||
|
||||
def test_invalid_pattern_does_not_raise(self) -> None:
|
||||
self.assertFalse(self._regexp("ABC123", "(unclosed"))
|
||||
|
||||
def test_catastrophic_pattern_is_time_bounded(self) -> None:
|
||||
# Without the timeout this evaluation backtracks for minutes to hours
|
||||
# and wedges the whole database thread (GHSA-q8jx-q884-jcq9).
|
||||
catastrophic = "(a{2,})+c"
|
||||
subject = "a" * 4000
|
||||
|
||||
start = time.monotonic()
|
||||
result = self._regexp(subject, catastrophic)
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
# The pattern does not match; the guarantee is that it returns quickly.
|
||||
self.assertFalse(result)
|
||||
self.assertLess(elapsed, REGEXP_TIMEOUT_SECONDS + 2.0)
|
||||
@@ -169,13 +169,6 @@ const detect: SectionConfigOverrides = {
|
||||
resolution: ["width", "height", "fps"],
|
||||
tracking: ["min_initialized", "max_disappeared"],
|
||||
},
|
||||
uiSchema: {
|
||||
annotation_offset: {
|
||||
"ui:options": {
|
||||
signed: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
advancedFields: [
|
||||
"min_initialized",
|
||||
|
||||
@@ -57,7 +57,6 @@ const record: SectionConfigOverrides = {
|
||||
"ui:options": {
|
||||
suppressMultiSchema: true,
|
||||
ffmpegPresetField: "hwaccel_args",
|
||||
ffmpegGlobalFieldPath: "export.hwaccel_args",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -17,4 +17,3 @@ export {
|
||||
isSubtreeModified,
|
||||
} from "./overrides";
|
||||
export { getSizedFieldClassName } from "./fieldSizing";
|
||||
export { getNumericInputMode } from "./inputMode";
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { RJSFSchema } from "@rjsf/utils";
|
||||
|
||||
type NumericInputOptions = {
|
||||
signed?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Derive the on-screen keyboard hint for a schema field.
|
||||
*
|
||||
* Numeric config fields render as text inputs because RJSF's NumberField
|
||||
* relies on the widget echoing raw strings back, so that trailing "." and "0"
|
||||
* characters survive while a value is being typed. That means the numeric
|
||||
* keypad has to be requested explicitly. Desktop browsers ignore inputMode, so
|
||||
* this only affects virtual keyboards.
|
||||
*
|
||||
* Fields accepting negative values opt out, since the iOS numeric and decimal
|
||||
* keypads have no minus key. Most numeric fields declare no minimum even
|
||||
* though they are non-negative, so signed fields are marked explicitly with
|
||||
* ui:options.signed.
|
||||
*
|
||||
* Args:
|
||||
* schema: The JSON schema for the field being rendered
|
||||
* options: The resolved ui:options for the field
|
||||
*
|
||||
* Returns:
|
||||
* The inputMode to apply, or undefined to leave the keyboard alone
|
||||
*/
|
||||
export function getNumericInputMode(
|
||||
schema: RJSFSchema,
|
||||
options: unknown,
|
||||
): "numeric" | "decimal" | undefined {
|
||||
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
||||
const isInteger = types.includes("integer");
|
||||
|
||||
if (!isInteger && !types.includes("number")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const numericOptions =
|
||||
typeof options === "object" && options !== null
|
||||
? (options as NumericInputOptions)
|
||||
: undefined;
|
||||
|
||||
const minimum = schema.minimum ?? schema.exclusiveMinimum;
|
||||
|
||||
if (numericOptions?.signed || (minimum ?? 0) < 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return isInteger ? "numeric" : "decimal";
|
||||
}
|
||||
@@ -120,12 +120,6 @@ export function FfmpegArgsWidget(props: WidgetProps) {
|
||||
id,
|
||||
} = props;
|
||||
const presetField = options?.ffmpegPresetField as PresetField | undefined;
|
||||
// Path to this field within its config section. This is usually the same as
|
||||
// the preset field, but the two diverge when the field sits below the
|
||||
// section root: record.export.hwaccel_args uses the hwaccel_args preset list
|
||||
// while living at export.hwaccel_args inside the record section.
|
||||
const globalFieldPath =
|
||||
(options?.ffmpegGlobalFieldPath as string | undefined) ?? presetField;
|
||||
const allowInherit = options?.allowInherit === true;
|
||||
const hideDescription = options?.hideDescription === true;
|
||||
const useSplitLayout = options?.splitLayout !== false;
|
||||
@@ -137,18 +131,11 @@ export function FfmpegArgsWidget(props: WidgetProps) {
|
||||
|
||||
// Extract the global value for this specific field to detect inheritance
|
||||
const globalFieldValue = useMemo(() => {
|
||||
if (
|
||||
!showUseGlobalSetting ||
|
||||
!formContext?.globalValue ||
|
||||
!globalFieldPath
|
||||
) {
|
||||
if (!showUseGlobalSetting || !formContext?.globalValue || !presetField) {
|
||||
return undefined;
|
||||
}
|
||||
return get(
|
||||
formContext.globalValue as Record<string, unknown>,
|
||||
globalFieldPath,
|
||||
);
|
||||
}, [showUseGlobalSetting, formContext?.globalValue, globalFieldPath]);
|
||||
return get(formContext.globalValue as Record<string, unknown>, presetField);
|
||||
}, [showUseGlobalSetting, formContext?.globalValue, presetField]);
|
||||
|
||||
const { data } = useSWR<FfmpegPresetResponse>("ffmpeg/presets");
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getNumericInputMode, getSizedFieldClassName } from "../utils";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
|
||||
export function TextWidget(props: WidgetProps) {
|
||||
const {
|
||||
@@ -28,7 +28,6 @@ export function TextWidget(props: WidgetProps) {
|
||||
id={id}
|
||||
className={cn(fieldClassName)}
|
||||
type="text"
|
||||
inputMode={getNumericInputMode(schema, options)}
|
||||
value={value ?? ""}
|
||||
disabled={disabled || readonly}
|
||||
placeholder={placeholder || (options.placeholder as string) || ""}
|
||||
|
||||
@@ -127,12 +127,8 @@ export default function ObjectTrackOverlay({
|
||||
},
|
||||
);
|
||||
|
||||
const getZonesFriendlyNames = (
|
||||
zones: string[],
|
||||
config: FrigateConfig,
|
||||
cameraId?: string,
|
||||
) => {
|
||||
return zones?.map((zone) => resolveZoneName(config, zone, cameraId)) ?? [];
|
||||
const getZonesFriendlyNames = (zones: string[], config: FrigateConfig) => {
|
||||
return zones?.map((zone) => resolveZoneName(config, zone)) ?? [];
|
||||
};
|
||||
|
||||
const timelineResults = useMemo(() => {
|
||||
@@ -155,7 +151,7 @@ export default function ObjectTrackOverlay({
|
||||
data: {
|
||||
...event.data,
|
||||
zones_friendly_names: config
|
||||
? getZonesFriendlyNames(event.data?.zones, config, event.camera)
|
||||
? getZonesFriendlyNames(event.data?.zones, config)
|
||||
: [],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -61,11 +61,7 @@ export function ObjectPath({
|
||||
...pos.lifecycle_item?.data,
|
||||
zones_friendly_names: pos.lifecycle_item?.data.zones.map(
|
||||
(zone) => {
|
||||
return resolveZoneName(
|
||||
config,
|
||||
zone,
|
||||
pos.lifecycle_item?.camera,
|
||||
);
|
||||
return resolveZoneName(config, zone);
|
||||
},
|
||||
),
|
||||
},
|
||||
|
||||
@@ -301,19 +301,11 @@ export function TrackingDetails({
|
||||
[recordings, actualVideoStart],
|
||||
);
|
||||
|
||||
const sequence = useMemo(
|
||||
() =>
|
||||
eventSequence?.map((item) => ({
|
||||
...item,
|
||||
data: {
|
||||
...item.data,
|
||||
zones_friendly_names: item.data?.zones?.map((zone) =>
|
||||
resolveZoneName(config, zone, item.camera),
|
||||
),
|
||||
},
|
||||
})),
|
||||
[eventSequence, config],
|
||||
);
|
||||
eventSequence?.map((event) => {
|
||||
event.data.zones_friendly_names = event.data?.zones?.map((zone) => {
|
||||
return resolveZoneName(config, zone);
|
||||
});
|
||||
});
|
||||
|
||||
// Use manualOverride (set when seeking in image mode) if present so
|
||||
// lifecycle rows and overlays follow image-mode seeks. Otherwise fall
|
||||
@@ -857,9 +849,9 @@ export function TrackingDetails({
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
{!sequence ? (
|
||||
{!eventSequence ? (
|
||||
<ActivityIndicator className="size-2" size={2} />
|
||||
) : sequence.length === 0 ? (
|
||||
) : eventSequence.length === 0 ? (
|
||||
<div className="py-2 text-muted-foreground">
|
||||
{t("detail.noObjectDetailData", { ns: "views/events" })}
|
||||
</div>
|
||||
@@ -879,7 +871,7 @@ export function TrackingDetails({
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{sequence.map((item, idx) => {
|
||||
{eventSequence.map((item, idx) => {
|
||||
return (
|
||||
<div
|
||||
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
|
||||
|
||||
@@ -1032,7 +1032,7 @@ function ObjectTimeline({
|
||||
data: {
|
||||
...event.data,
|
||||
zones_friendly_names: event.data?.zones?.map((zone) =>
|
||||
resolveZoneName(config, zone, event.camera),
|
||||
resolveZoneName(config, zone),
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user