mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-14 04:02:41 +00:00
Compare commits
1 Commits
bo/custom_
...
jamison/lo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b89936de39 |
@@ -1042,8 +1042,6 @@ POD_NAMESPACE = os.environ.get("POD_NAMESPACE")
|
||||
|
||||
DEV_MODE = os.environ.get("DEV_MODE", "").lower() == "true"
|
||||
|
||||
HOOK_ENABLED = os.environ.get("HOOK_ENABLED", "").lower() == "true"
|
||||
|
||||
INTEGRATION_TESTS_MODE = os.environ.get("INTEGRATION_TESTS_MODE", "").lower() == "true"
|
||||
|
||||
#####
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
from onyx.configs.app_configs import HOOK_ENABLED
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from shared_configs.configs import MULTI_TENANT
|
||||
|
||||
|
||||
def require_hook_enabled() -> None:
|
||||
"""FastAPI dependency that gates all hook management endpoints.
|
||||
|
||||
Hooks are only available in single-tenant / self-hosted deployments with
|
||||
HOOK_ENABLED=true explicitly set. Two layers of protection:
|
||||
1. MULTI_TENANT check — rejects even if HOOK_ENABLED is accidentally set true
|
||||
2. HOOK_ENABLED flag — explicit opt-in by the operator
|
||||
|
||||
Use as: Depends(require_hook_enabled)
|
||||
"""
|
||||
if MULTI_TENANT:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.NOT_FOUND,
|
||||
"Custom code hooks are not available in multi-tenant deployments",
|
||||
)
|
||||
if not HOOK_ENABLED:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.NOT_FOUND,
|
||||
"Custom code hooks are not enabled. Set HOOK_ENABLED=true to enable.",
|
||||
)
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Unit tests for the hooks feature gate."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.hooks.api_dependencies import require_hook_enabled
|
||||
|
||||
|
||||
class TestRequireHookEnabled:
|
||||
def test_raises_when_multi_tenant(self) -> None:
|
||||
with (
|
||||
patch("onyx.hooks.api_dependencies.MULTI_TENANT", True),
|
||||
patch("onyx.hooks.api_dependencies.HOOK_ENABLED", True),
|
||||
):
|
||||
with pytest.raises(OnyxError) as exc_info:
|
||||
require_hook_enabled()
|
||||
assert exc_info.value.error_code is OnyxErrorCode.NOT_FOUND
|
||||
assert exc_info.value.status_code == 404
|
||||
assert "multi-tenant" in exc_info.value.detail
|
||||
|
||||
def test_raises_when_flag_disabled(self) -> None:
|
||||
with (
|
||||
patch("onyx.hooks.api_dependencies.MULTI_TENANT", False),
|
||||
patch("onyx.hooks.api_dependencies.HOOK_ENABLED", False),
|
||||
):
|
||||
with pytest.raises(OnyxError) as exc_info:
|
||||
require_hook_enabled()
|
||||
assert exc_info.value.error_code is OnyxErrorCode.NOT_FOUND
|
||||
assert exc_info.value.status_code == 404
|
||||
assert "HOOK_ENABLED" in exc_info.value.detail
|
||||
|
||||
def test_passes_when_enabled_single_tenant(self) -> None:
|
||||
with (
|
||||
patch("onyx.hooks.api_dependencies.MULTI_TENANT", False),
|
||||
patch("onyx.hooks.api_dependencies.HOOK_ENABLED", True),
|
||||
):
|
||||
require_hook_enabled() # must not raise
|
||||
@@ -22,6 +22,25 @@ import {
|
||||
} from "react";
|
||||
import type { PreviewHighlightTarget } from "./Preview";
|
||||
import { SvgEdit } from "@opal/icons";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import LogoCropModal from "./LogoCropModal";
|
||||
|
||||
const LOGO_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; // 2 MB
|
||||
const LOGO_MAX_FILE_SIZE_MB = LOGO_MAX_FILE_SIZE_BYTES / (1024 * 1024);
|
||||
const ALLOWED_LOGO_FILE_TYPES = ["image/png", "image/jpeg"];
|
||||
|
||||
function validateLogoFile(file: File): string | null {
|
||||
if (!ALLOWED_LOGO_FILE_TYPES.includes(file.type)) {
|
||||
return `Unsupported file type (${
|
||||
file.type || "unknown"
|
||||
}). Please upload a PNG or JPEG image.`;
|
||||
}
|
||||
if (file.size > LOGO_MAX_FILE_SIZE_BYTES) {
|
||||
const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
|
||||
return `File is too large (${sizeMB} MB). Maximum allowed size is ${LOGO_MAX_FILE_SIZE_MB} MB.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface AppearanceThemeSettingsProps {
|
||||
selectedLogo: File | null;
|
||||
@@ -63,6 +82,7 @@ export const AppearanceThemeSettings = forwardRef<
|
||||
const prevEnableConsentScreenRef = useRef<boolean>(
|
||||
Boolean(values.enable_consent_screen)
|
||||
);
|
||||
const [cropFile, setCropFile] = useState<File | null>(null);
|
||||
const [focusedPreviewTarget, setFocusedPreviewTarget] =
|
||||
useState<PreviewHighlightTarget | null>(null);
|
||||
const [hoveredPreviewTarget, setHoveredPreviewTarget] =
|
||||
@@ -146,10 +166,17 @@ export const AppearanceThemeSettings = forwardRef<
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedLogo(file);
|
||||
setFieldValue("use_custom_logo", true);
|
||||
if (!file) return;
|
||||
|
||||
const error = validateLogoFile(file);
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
event.target.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
setCropFile(file);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
const handleLogoRemove = async () => {
|
||||
@@ -305,8 +332,12 @@ export const AppearanceThemeSettings = forwardRef<
|
||||
src={getLogoSrc()}
|
||||
onEdit={handleLogoEdit}
|
||||
onDrop={(file) => {
|
||||
setSelectedLogo(file);
|
||||
setFieldValue("use_custom_logo", true);
|
||||
const error = validateLogoFile(file);
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
return;
|
||||
}
|
||||
setCropFile(file);
|
||||
}}
|
||||
onRemove={handleLogoRemove}
|
||||
showEditOverlay={false}
|
||||
@@ -582,6 +613,18 @@ export const AppearanceThemeSettings = forwardRef<
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cropFile && (
|
||||
<LogoCropModal
|
||||
file={cropFile}
|
||||
onApply={(croppedFile) => {
|
||||
setSelectedLogo(croppedFile);
|
||||
setFieldValue("use_custom_logo", true);
|
||||
setCropFile(null);
|
||||
}}
|
||||
onCancel={() => setCropFile(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
297
web/src/app/ee/admin/theme/LogoCropModal.tsx
Normal file
297
web/src/app/ee/admin/theme/LogoCropModal.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import Modal, { BasicModalFooter } from "@/refresh-components/Modal";
|
||||
import { Button } from "@opal/components";
|
||||
import { Disabled } from "@opal/core";
|
||||
import { SvgZoomIn, SvgZoomOut } from "@opal/icons";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
|
||||
const CANVAS_SIZE = 448;
|
||||
const OUTPUT_SIZE = 192;
|
||||
const MIN_ZOOM = 1;
|
||||
const MAX_ZOOM = 5;
|
||||
const ZOOM_STEP = 0.25;
|
||||
|
||||
interface LogoCropModalProps {
|
||||
file: File;
|
||||
onApply: (croppedFile: File) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function LogoCropModal({
|
||||
file,
|
||||
onApply,
|
||||
onCancel,
|
||||
}: LogoCropModalProps) {
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||
const [zoom, setZoom] = useState(MIN_ZOOM);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [naturalSize, setNaturalSize] = useState({ w: 0, h: 0 });
|
||||
const maskId = useId();
|
||||
const dragStartRef = useRef({ x: 0, y: 0, ox: 0, oy: 0 });
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
setImageSrc(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
|
||||
const handleImageLoad = useCallback(
|
||||
(e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = e.currentTarget;
|
||||
setNaturalSize({ w: img.naturalWidth, h: img.naturalHeight });
|
||||
imageRef.current = img;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const getBaseDisplayedSize = useCallback(() => {
|
||||
if (naturalSize.w === 0 || naturalSize.h === 0) {
|
||||
return { w: CANVAS_SIZE, h: CANVAS_SIZE };
|
||||
}
|
||||
const shortSide = Math.min(naturalSize.w, naturalSize.h);
|
||||
const scale = CANVAS_SIZE / shortSide;
|
||||
return {
|
||||
w: naturalSize.w * scale,
|
||||
h: naturalSize.h * scale,
|
||||
};
|
||||
}, [naturalSize]);
|
||||
|
||||
const getDisplayedSize = useCallback(() => {
|
||||
const baseSize = getBaseDisplayedSize();
|
||||
return {
|
||||
w: baseSize.w * zoom,
|
||||
h: baseSize.h * zoom,
|
||||
};
|
||||
}, [getBaseDisplayedSize, zoom]);
|
||||
|
||||
const clampOffset = useCallback(
|
||||
(ox: number, oy: number) => {
|
||||
const { w, h } = getDisplayedSize();
|
||||
const maxX = Math.max(0, (w - CANVAS_SIZE) / 2);
|
||||
const maxY = Math.max(0, (h - CANVAS_SIZE) / 2);
|
||||
return {
|
||||
x: Math.max(-maxX, Math.min(maxX, ox)),
|
||||
y: Math.max(-maxY, Math.min(maxY, oy)),
|
||||
};
|
||||
},
|
||||
[getDisplayedSize]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOffset((prev) => clampOffset(prev.x, prev.y));
|
||||
}, [zoom, clampOffset]);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
dragStartRef.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
ox: offset.x,
|
||||
oy: offset.y,
|
||||
};
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[offset]
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!dragging) return;
|
||||
const dx = e.clientX - dragStartRef.current.x;
|
||||
const dy = e.clientY - dragStartRef.current.y;
|
||||
setOffset(
|
||||
clampOffset(dragStartRef.current.ox + dx, dragStartRef.current.oy + dy)
|
||||
);
|
||||
},
|
||||
[dragging, clampOffset]
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
setDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = -e.deltaY * 0.003;
|
||||
setZoom((z) => Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z + delta)));
|
||||
}, []);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
if (!imageRef.current || naturalSize.w === 0) return;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = OUTPUT_SIZE;
|
||||
canvas.height = OUTPUT_SIZE;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
toast.error("Failed to process image. Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
const shortSide = Math.min(naturalSize.w, naturalSize.h);
|
||||
const scale = (CANVAS_SIZE / shortSide) * zoom;
|
||||
|
||||
const srcCenterX = naturalSize.w / 2 - offset.x / scale;
|
||||
const srcCenterY = naturalSize.h / 2 - offset.y / scale;
|
||||
const srcSize = CANVAS_SIZE / scale;
|
||||
|
||||
ctx.drawImage(
|
||||
imageRef.current,
|
||||
srcCenterX - srcSize / 2,
|
||||
srcCenterY - srcSize / 2,
|
||||
srcSize,
|
||||
srcSize,
|
||||
0,
|
||||
0,
|
||||
OUTPUT_SIZE,
|
||||
OUTPUT_SIZE
|
||||
);
|
||||
|
||||
const isPng = file.type === "image/png";
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
toast.error("Failed to process image. Please try again.");
|
||||
return;
|
||||
}
|
||||
const cropped = new File(
|
||||
[blob],
|
||||
file.name.replace(/\.\w+$/, isPng ? ".png" : ".jpg"),
|
||||
{ type: isPng ? "image/png" : "image/jpeg" }
|
||||
);
|
||||
onApply(cropped);
|
||||
},
|
||||
isPng ? "image/png" : "image/jpeg",
|
||||
0.92
|
||||
);
|
||||
}, [file, naturalSize, zoom, offset, onApply]);
|
||||
|
||||
const baseDisplayed = getBaseDisplayedSize();
|
||||
const isLandscapeOrSquare = naturalSize.w >= naturalSize.h;
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={(open) => !open && onCancel()}>
|
||||
<Modal.Content width="sm" height="fit" preventAccidentalClose={false}>
|
||||
<Modal.Header title="Position Logo" onClose={onCancel} />
|
||||
<Modal.Body twoTone>
|
||||
<div className="flex flex-col items-center w-full">
|
||||
<div
|
||||
className="relative overflow-hidden select-none"
|
||||
style={{
|
||||
width: CANVAS_SIZE,
|
||||
height: CANVAS_SIZE,
|
||||
cursor: dragging ? "grabbing" : "grab",
|
||||
touchAction: "none",
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
{imageSrc && (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt="Logo preview"
|
||||
draggable={false}
|
||||
onLoad={handleImageLoad}
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
width: isLandscapeOrSquare ? baseDisplayed.w : "auto",
|
||||
height: isLandscapeOrSquare ? "auto" : baseDisplayed.h,
|
||||
maxWidth: "none",
|
||||
left: CANVAS_SIZE / 2 - baseDisplayed.w / 2,
|
||||
top: CANVAS_SIZE / 2 - baseDisplayed.h / 2,
|
||||
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<svg
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
width={CANVAS_SIZE}
|
||||
height={CANVAS_SIZE}
|
||||
>
|
||||
<defs>
|
||||
<mask id={maskId}>
|
||||
<rect width="100%" height="100%" fill="white" />
|
||||
<circle
|
||||
cx={CANVAS_SIZE / 2}
|
||||
cy={CANVAS_SIZE / 2}
|
||||
r={CANVAS_SIZE / 2 - 1}
|
||||
fill="black"
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="rgba(0,0,0,0.55)"
|
||||
mask={`url(#${maskId})`}
|
||||
/>
|
||||
<circle
|
||||
cx={CANVAS_SIZE / 2}
|
||||
cy={CANVAS_SIZE / 2}
|
||||
r={CANVAS_SIZE / 2 - 1}
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<BasicModalFooter
|
||||
left={
|
||||
<div className="flex items-center gap-1">
|
||||
<Disabled disabled={zoom <= MIN_ZOOM}>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="md"
|
||||
icon={SvgZoomOut}
|
||||
onClick={() =>
|
||||
setZoom((z) => Math.max(MIN_ZOOM, z - ZOOM_STEP))
|
||||
}
|
||||
/>
|
||||
</Disabled>
|
||||
<Text
|
||||
text03
|
||||
mainUiAction
|
||||
className={cn("w-10 text-center select-none")}
|
||||
>
|
||||
{Math.round(zoom * 100)}%
|
||||
</Text>
|
||||
<Disabled disabled={zoom >= MAX_ZOOM}>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="md"
|
||||
icon={SvgZoomIn}
|
||||
onClick={() =>
|
||||
setZoom((z) => Math.min(MAX_ZOOM, z + ZOOM_STEP))
|
||||
}
|
||||
/>
|
||||
</Disabled>
|
||||
</div>
|
||||
}
|
||||
cancel={
|
||||
<Button prominence="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
submit={<Button onClick={handleApply}>Apply</Button>}
|
||||
/>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user