From f8e3815c62c3ba765e85230ab804a37d1ee76736 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 21:38:05 +0000 Subject: [PATCH 01/22] Add OC_URL annotations: clickable hyperlinks anchored to highlighted text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annotations carrying the new OC_URL label render as hyperlinks (underline + external-link icon, pointer cursor) and open their link_url on click in both the PDF viewer and the text/markdown viewer. Backend: new Annotation.link_url URLField (max 2048, nullable) with migration 0072. New add_url_annotation GraphQL mutation auto-ensures the OC_URL label per-corpus via Corpus.ensure_label_and_labelset (mirrors the OC_SECTION pattern). Both add_annotation and update_annotation now accept link_url. Validation lives in a shared validate_link_url() helper invoked from Annotation.save() unconditionally and from the GraphQL mutation / DRF serializer layers — only http(s) and site-relative paths are allowed, so javascript: / data: schemes are rejected before persistence. Frontend: linkUrl propagates through RawServerAnnotationType, the ServerSpanAnnotation / ServerTokenAnnotation classes, the primary annotation fetch queries (GET_DOCUMENT_KNOWLEDGE_AND_ANNOTATIONS, GET_ANNOTATIONS, GET_ANNOTATIONS_FOR_ANALYSIS, GET_ANNOTATIONS_FOR_CARDS, and similar), and the create/update mutations. A new useCreateUrlAnnotation hook fronts the addUrlAnnotation mutation; create/url-create handlers are threaded as props (DocumentKnowledgeBase → DocumentViewer → PDF → PDFPage → SelectionLayer; TxtAnnotatorWrapper → TxtAnnotator) so the renderers stay decoupled from Apollo and remain unit-testable. The selection action menu gains an "Add link…" entry in both PDF and text viewers, which opens a shared CreateUrlAnnotationModal that prompts for a URL. The pencil icon on an existing OC_URL annotation opens the URL-edit modal instead of the label-edit modal. Holding Shift / Ctrl / Cmd while clicking a link annotation falls back to the normal toggle-selection behaviour. External URLs open in a new tab with noopener,noreferrer; site-relative paths navigate in the current tab so the SPA router can resolve them. A defensive isSafeUrl mirror of the backend allow-list lives in the renderer so a stale cached unsafe URL still won't reach window.open. --- CHANGELOG.md | 4 + config/graphql/annotation_mutations.py | 127 ++++++++++++++ config/graphql/mutations.py | 2 + config/graphql/serializers.py | 16 ++ .../src/assets/configurations/constants.ts | 4 + .../modals/CreateUrlAnnotationModal.tsx | 143 ++++++++++++++++ .../wrappers/TxtAnnotatorWrapper.tsx | 3 + .../display/components/Selection.tsx | 58 ++++++- .../display/components/SelectionBoundary.tsx | 24 ++- .../annotator/hooks/AnnotationHooks.tsx | 127 +++++++++++++- .../hooks/__tests__/AnnotationHooks.test.tsx | 4 + .../annotator/renderers/pdf/PDF.tsx | 11 ++ .../annotator/renderers/pdf/PDFPage.tsx | 7 + .../renderers/pdf/SelectionLayer.tsx | 139 +++++++++++++++- .../annotator/renderers/txt/TxtAnnotator.tsx | 157 +++++++++++++++++- .../components/annotator/types/annotations.ts | 18 +- .../annotator/utils/urlAnnotation.ts | 69 ++++++++ .../document/DocumentKnowledgeBase.tsx | 18 +- .../document/document_kb/DocumentViewer.tsx | 7 + frontend/src/graphql/mutations.ts | 83 +++++++++ frontend/src/graphql/queries.ts | 7 + frontend/src/types/graphql-api.ts | 5 + frontend/src/utils/transform.tsx | 6 +- .../migrations/0072_annotation_link_url.py | 24 +++ opencontractserver/annotations/models.py | 60 +++++++ opencontractserver/constants/annotations.py | 4 + 26 files changed, 1099 insertions(+), 28 deletions(-) create mode 100644 frontend/src/components/annotator/components/modals/CreateUrlAnnotationModal.tsx create mode 100644 frontend/src/components/annotator/utils/urlAnnotation.ts create mode 100644 opencontractserver/annotations/migrations/0072_annotation_link_url.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b69cc84b0..1d73d5d18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **OC_URL link annotations — clickable hyperlinks anchored to highlighted text** (`opencontractserver/annotations/models.py`, `opencontractserver/annotations/migrations/0072_annotation_link_url.py`, `opencontractserver/constants/annotations.py`, `config/graphql/{annotation_mutations,serializers,mutations}.py`, `frontend/src/assets/configurations/constants.ts`, `frontend/src/types/graphql-api.ts`, `frontend/src/components/annotator/types/annotations.ts`, `frontend/src/components/annotator/utils/urlAnnotation.ts` (new), `frontend/src/graphql/{queries,mutations}.ts`, `frontend/src/components/annotator/hooks/AnnotationHooks.tsx`, `frontend/src/components/annotator/display/components/{Selection,SelectionBoundary}.tsx`, `frontend/src/components/annotator/renderers/pdf/{PDF,PDFPage,SelectionLayer}.tsx`, `frontend/src/components/annotator/renderers/txt/TxtAnnotator.tsx`, `frontend/src/components/annotator/components/wrappers/TxtAnnotatorWrapper.tsx`, `frontend/src/components/annotator/components/modals/CreateUrlAnnotationModal.tsx` (new), `frontend/src/components/knowledge_base/document/DocumentKnowledgeBase.tsx`, `frontend/src/components/knowledge_base/document/document_kb/DocumentViewer.tsx`, `frontend/src/utils/transform.tsx`). Annotations carrying the new `OC_URL` label render as hyperlinks (underline + external-link icon, pointer cursor) and open their `link_url` on click in both the PDF viewer and the text/markdown viewer. The `Annotation.link_url` URL field (max 2048, nullable) carries the target; an "Add link…" item in the selection action menu prompts for a URL and calls a new `add_url_annotation` GraphQL mutation that auto-creates the `OC_URL` label per-corpus via `Corpus.ensure_label_and_labelset` (mirroring `OC_SECTION`). The pencil icon on an existing `OC_URL` annotation opens a URL-edit modal instead of the label modal. Held `Shift`/`Ctrl`/`Cmd` while clicking a link annotation falls back to the normal "toggle selection" behaviour so authors can still pick a link to delete or re-edit it. **Security**: `link_url` is validated both in `Annotation.save()` (always runs, regardless of `VALIDATE_ANNOTATION_JSON`) and in the GraphQL mutation/serializer layer via a shared `validate_link_url(...)` helper — only `http://`, `https://`, and site-relative `/...` paths are accepted, so `javascript:`, `data:`, and other dangerous schemes are rejected before persistence and never reach `window.open`. External targets open in a new tab with `noopener,noreferrer`; site-relative paths navigate in the current tab so the SPA router can resolve them. + ### Changed - **Backend dependency audit — aggressive trim of unused / vestigial packages** (`requirements/base.txt`, `requirements/local.txt`, `requirements/production.txt`, `.pre-commit-config.yaml`, `compose/production/django/Dockerfile`, deleted `requirements/filetypes/docx.txt`, `requirements/ingestors/nlm_ingest.txt`, `requirements/processors/gliner.txt`, `.pylintrc`). Grep-audited every direct dependency across `opencontractserver/`, `config/`, scripts, compose, Dockerfiles, settings, and CI workflows. diff --git a/config/graphql/annotation_mutations.py b/config/graphql/annotation_mutations.py index 9a1ba8405..a5c2ec199 100644 --- a/config/graphql/annotation_mutations.py +++ b/config/graphql/annotation_mutations.py @@ -25,7 +25,9 @@ Annotation, Note, Relationship, + validate_link_url, ) +from opencontractserver.constants.annotations import OC_URL_LABEL from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document, DocumentPath from opencontractserver.feedback.models import UserFeedback @@ -278,6 +280,13 @@ class Arguments: required=False, description="Optional markdown description for this annotation.", ) + link_url = graphene.String( + required=False, + description=( + "Optional URL opened on click. Restricted to http(s):// or " + "site-relative paths; intended for OC_URL annotations." + ), + ) ok = graphene.Boolean() message = graphene.String() @@ -296,6 +305,7 @@ def mutate( annotation_label_id, annotation_type, long_description=None, + link_url=None, ) -> "AddAnnotation": corpus_pk = from_global_id(corpus_id)[1] document_pk = from_global_id(document_id)[1] @@ -303,6 +313,12 @@ def mutate( user = info.context.user + if link_url: + try: + validate_link_url(link_url) + except Exception as exc: + return AddAnnotation(ok=False, annotation=None, message=str(exc)) + parents = _resolve_annotation_parents(user, corpus_pk, document_pk) if parents is None: return AddAnnotation( @@ -322,6 +338,7 @@ def mutate( creator=user, json=json, annotation_type=annotation_type.value, + link_url=link_url or None, ) annotation.save() set_permissions_for_obj_to_user(user, annotation, [PermissionTypes.CRUD]) @@ -331,6 +348,108 @@ def mutate( ) +class AddUrlAnnotation(graphene.Mutation): + """Create an annotation labelled ``OC_URL`` with a click-through URL. + + Convenience wrapper over ``AddAnnotation``: ensures the corpus has an + ``OC_URL`` label (creating it if absent) and stamps ``link_url`` on the + resulting annotation so the frontend renders the highlighted text as a + clickable hyperlink. + """ + + class Arguments: + json = GenericScalar( + required=True, description="New-style JSON for multipage annotations." + ) + page = graphene.Int( + required=True, description="What page is this annotation on (0-indexed)." + ) + raw_text = graphene.String( + required=True, description="The raw text being linked." + ) + corpus_id = graphene.String( + required=True, description="ID of the corpus this annotation is for." + ) + document_id = graphene.String( + required=True, description="ID of the document this annotation is on." + ) + annotation_type = graphene.Argument( + graphene.Enum.from_enum(LabelType), + required=True, + description="Annotation type: TOKEN_LABEL for PDFs, SPAN_LABEL for text.", + ) + link_url = graphene.String( + required=True, + description="The target URL to open on click.", + ) + + ok = graphene.Boolean() + message = graphene.String() + annotation = graphene.Field(AnnotationType) + + @login_required + @graphql_ratelimit_dynamic(get_rate=get_user_tier_rate("WRITE_LIGHT")) + def mutate( + root, + info, + json, + page, + raw_text, + corpus_id, + document_id, + annotation_type, + link_url, + ) -> "AddUrlAnnotation": + corpus_pk = from_global_id(corpus_id)[1] + document_pk = from_global_id(document_id)[1] + + user = info.context.user + + try: + validate_link_url(link_url) + except Exception as exc: + return AddUrlAnnotation(ok=False, annotation=None, message=str(exc)) + + parents = _resolve_annotation_parents(user, corpus_pk, document_pk) + if parents is None: + return AddUrlAnnotation( + ok=False, + annotation=None, + message=_ANNOTATION_PARENT_NOT_FOUND_MSG, + ) + document, corpus = parents + + with transaction.atomic(): + # ``ensure_label_and_labelset`` is idempotent; creates the + # OC_URL label on first use, returns the existing one thereafter. + label = corpus.ensure_label_and_labelset( + label_text=OC_URL_LABEL, + creator_id=user.pk, + label_type=annotation_type.value, + color="#2563EB", + icon="link", + description="Click-through hyperlink annotation", + ) + + annotation = Annotation( + page=page, + raw_text=raw_text, + corpus_id=corpus.pk, + document_id=document.pk, + annotation_label_id=label.pk, + creator=user, + json=json, + annotation_type=annotation_type.value, + link_url=link_url, + ) + annotation.save() + set_permissions_for_obj_to_user(user, annotation, [PermissionTypes.CRUD]) + + return AddUrlAnnotation( + ok=True, message="URL annotation created", annotation=annotation + ) + + class AddDocTypeAnnotation(graphene.Mutation): class Arguments: corpus_id = graphene.String( @@ -727,6 +846,14 @@ class Arguments: long_description = graphene.String() json = GenericScalar() annotation_label = graphene.String() + link_url = graphene.String( + required=False, + description=( + "Optional click-through URL for OC_URL annotations. Pass an " + "empty string to clear an existing URL. Restricted to " + "http(s):// or site-relative paths." + ), + ) class UpdateRelations(graphene.Mutation): diff --git a/config/graphql/mutations.py b/config/graphql/mutations.py index 15a6716d5..3b433e570 100644 --- a/config/graphql/mutations.py +++ b/config/graphql/mutations.py @@ -28,6 +28,7 @@ AddAnnotation, AddDocTypeAnnotation, AddRelationship, + AddUrlAnnotation, ApproveAnnotation, CreateNote, DeleteNote, @@ -236,6 +237,7 @@ class Mutation(graphene.ObjectType): # ANNOTATION MUTATIONS ###################################################### add_annotation = AddAnnotation.Field() + add_url_annotation = AddUrlAnnotation.Field() remove_annotation = RemoveAnnotation.Field() update_annotation = UpdateAnnotation.Field() add_doc_type_annotation = AddDocTypeAnnotation.Field() diff --git a/config/graphql/serializers.py b/config/graphql/serializers.py index 152ba1407..cffb215d6 100644 --- a/config/graphql/serializers.py +++ b/config/graphql/serializers.py @@ -210,9 +210,25 @@ class Meta: "creator_id", "parent", "parent_id", + "link_url", ] read_only_fields = ["id", "creator", "parent"] + def validate_link_url(self, value: str | None) -> str | None: + """Normalise empty strings to None and reject unsafe schemes. + + The frontend sends `link_url=""` to clear an existing URL; convert + that to None so the column ends up NULL. All non-empty values flow + through ``Annotation.validate_link_url`` to block ``javascript:`` + and other dangerous schemes before reaching persistence. + """ + from opencontractserver.annotations.models import validate_link_url as _validate + + if not value: + return None + _validate(value) + return value + def create(self, validated_data: dict) -> Annotation: """ Create a new `Annotation` instance, mapping `creator_id` and `parent_id` to their respective diff --git a/frontend/src/assets/configurations/constants.ts b/frontend/src/assets/configurations/constants.ts index 5386fd353..e4f881565 100644 --- a/frontend/src/assets/configurations/constants.ts +++ b/frontend/src/assets/configurations/constants.ts @@ -262,6 +262,10 @@ export const DOCUMENT_ANNOTATION_INDEX_MAX_DEPTH = 6; // Keep in sync with opencontractserver/constants/annotations.py export const STRUCTURAL_LABEL_PREFIX = "OC_"; export const OC_SECTION_LABEL = "OC_SECTION"; +// Annotations carrying the OC_URL label render as clickable hyperlinks; their +// ``linkUrl`` field is opened on click. Keep in sync with +// opencontractserver/constants/annotations.py. +export const OC_URL_LABEL = "OC_URL"; // Document search/picker limits export const DOCUMENT_PICKER_SEARCH_LIMIT = 20; diff --git a/frontend/src/components/annotator/components/modals/CreateUrlAnnotationModal.tsx b/frontend/src/components/annotator/components/modals/CreateUrlAnnotationModal.tsx new file mode 100644 index 000000000..102039927 --- /dev/null +++ b/frontend/src/components/annotator/components/modals/CreateUrlAnnotationModal.tsx @@ -0,0 +1,143 @@ +import { SyntheticEvent, useEffect, useState } from "react"; + +import { + Modal, + ModalHeader, + ModalBody, + ModalFooter, + Button, +} from "@os-legal/ui"; + +interface CreateUrlAnnotationModalProps { + visible: boolean; + /** Text the user selected; shown read-only for context. */ + selectedText: string; + onCancel: () => void; + /** Called with the trimmed URL when the user confirms. */ + onConfirm: (url: string) => void; + /** Initial value, used by the edit-URL flow. */ + initialUrl?: string; +} + +const URL_PATTERN = /^(https?:\/\/.+|\/.+)$/i; + +/** + * Small modal that prompts the user for a target URL when turning a + * selection into an OC_URL link annotation. Validation mirrors the backend + * allow-list: http(s) absolute URLs or site-relative paths starting with + * "/". Anything else is rejected client-side; the server will also reject + * it as a defence-in-depth measure. + */ +export const CreateUrlAnnotationModal = ({ + visible, + selectedText, + onCancel, + onConfirm, + initialUrl = "", +}: CreateUrlAnnotationModalProps) => { + const [url, setUrl] = useState(initialUrl); + const [error, setError] = useState(null); + + useEffect(() => { + if (visible) { + setUrl(initialUrl); + setError(null); + } + }, [visible, initialUrl]); + + const handleConfirm = (event: SyntheticEvent) => { + event.preventDefault(); + event.stopPropagation(); + const trimmed = url.trim(); + if (!trimmed) { + setError("URL is required."); + return; + } + if (!URL_PATTERN.test(trimmed)) { + setError( + "URL must start with http://, https://, or '/' (site-relative path)." + ); + return; + } + onConfirm(trimmed); + }; + + return ( + + {initialUrl ? "Edit link target" : "Add link"} + +
e.stopPropagation()} + style={{ display: "flex", flexDirection: "column", gap: 12 }} + > + {selectedText && ( +
+ Selected text:{" "} + {selectedText} +
+ )} + + { + setUrl(e.target.value); + if (error) setError(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleConfirm(e); + } + }} + placeholder="https://example.com or /relative/path" + autoFocus + style={{ + padding: "8px 10px", + border: error ? "1px solid #dc2626" : "1px solid #d1d5db", + borderRadius: 4, + fontSize: 14, + }} + /> + {error && ( +
{error}
+ )} +
+
+ + + + +
+ ); +}; diff --git a/frontend/src/components/annotator/components/wrappers/TxtAnnotatorWrapper.tsx b/frontend/src/components/annotator/components/wrappers/TxtAnnotatorWrapper.tsx index 517333959..74a23992d 100644 --- a/frontend/src/components/annotator/components/wrappers/TxtAnnotatorWrapper.tsx +++ b/frontend/src/components/annotator/components/wrappers/TxtAnnotatorWrapper.tsx @@ -10,6 +10,7 @@ import { useSetAtom } from "jotai"; import { useApproveAnnotation, useCreateAnnotation, + useCreateUrlAnnotation, useDeleteAnnotation, usePdfAnnotations, useRejectAnnotation, @@ -65,6 +66,7 @@ export const TxtAnnotatorWrapper: React.FC = ({ const { showStructural } = useAnnotationDisplay(); const handleCreateAnnotation = useCreateAnnotation(); + const handleCreateUrlAnnotation = useCreateUrlAnnotation(); const handleDeleteAnnotation = useDeleteAnnotation(); const handleUpdateAnnotation = useUpdateAnnotation(); const handleApproveAnnotation = useApproveAnnotation(); @@ -201,6 +203,7 @@ export const TxtAnnotatorWrapper: React.FC = ({ read_only={readOnly} allowInput={allowInput} createAnnotation={handleCreateAnnotation} + createUrlAnnotation={handleCreateUrlAnnotation} updateAnnotation={handleUpdateAnnotation} approveAnnotation={handleApproveAnnotation} rejectAnnotation={handleRejectAnnotation} diff --git a/frontend/src/components/annotator/display/components/Selection.tsx b/frontend/src/components/annotator/display/components/Selection.tsx index a12599c08..825c3cd62 100644 --- a/frontend/src/components/annotator/display/components/Selection.tsx +++ b/frontend/src/components/annotator/display/components/Selection.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from "react"; import _ from "lodash"; import styled from "styled-components"; -import { Pencil, Trash2 } from "lucide-react"; +import { ExternalLink, Pencil, Trash2 } from "lucide-react"; import { VerticallyJustifiedEndDiv } from "../../sidebar/common"; @@ -21,6 +21,10 @@ import RadialButtonCloud, { } from "../../../widgets/buttons/RadialButtonCloud"; import { SelectionTokenGroup } from "./SelectionTokenGroup"; import { EditLabelModal } from "../../components/modals/EditLabelModal"; +import { CreateUrlAnnotationModal } from "../../components/modals/CreateUrlAnnotationModal"; +import { isUrlAnnotation, openAnnotationUrl } from "../../utils/urlAnnotation"; +import { useUpdateAnnotation } from "../../hooks/AnnotationHooks"; +import { OC_URL_LABEL } from "../../../../assets/configurations/constants"; import { useReactiveVar } from "@apollo/client"; import { authToken } from "../../../../graphql/cache"; import { PDFPageInfo } from "../../types/pdf"; @@ -120,9 +124,11 @@ export const Selection: React.FC = ({ const auth_token = useReactiveVar(authToken); const [hovered, setHovered] = useState(false); const [isEditLabelModalVisible, setIsEditLabelModalVisible] = useState(false); + const [isEditUrlModalVisible, setIsEditUrlModalVisible] = useState(false); const [cloudVisible, setCloudVisible] = useState(false); const [hidden, setHidden] = useState(false); const cloudRef = useRef(null); + const updateAnnotation = useUpdateAnnotation(); const { showBoundingBoxes, showSelectedOnly, showLabels } = useAnnotationDisplay(); @@ -214,7 +220,22 @@ export const Selection: React.FC = ({ deleteAnnotation(annotation.id); }; - const onShiftClick = () => { + const isLinkAnnotation = isUrlAnnotation(annotation); + + const onShiftClick = (event?: React.MouseEvent) => { + // OC_URL annotations act as hyperlinks on plain click. Holding Shift or + // a modifier key (Ctrl/Cmd) falls back to the normal "toggle selection" + // behaviour so authors can still pick a link annotation to edit or delete + // it from the radial menu. + if ( + isLinkAnnotation && + !event?.shiftKey && + !event?.metaKey && + !event?.ctrlKey + ) { + if (openAnnotationUrl(annotation)) return; + } + const current = selectedAnnotations.slice(0); if (current.some((other) => other === annotation.id)) { const next = current.filter((other) => other !== annotation.id); @@ -276,6 +297,7 @@ export const Selection: React.FC = ({ bounds={bounds} onHover={setHovered} onClick={onShiftClick} + clickThroughOnPlainClick={isLinkAnnotation} approved={approved} rejected={rejected} selected={selected} @@ -323,8 +345,17 @@ export const Selection: React.FC = ({ whiteSpace: "nowrap", overflowX: "visible", marginLeft: "8px", + display: "flex", + alignItems: "center", + gap: "4px", }} > + {isLinkAnnotation && ( + + )} {label.text} {annotation.myPermissions.includes( @@ -340,7 +371,16 @@ export const Selection: React.FC = ({ }} onClick={(e: React.MouseEvent) => { e.stopPropagation(); - setIsEditLabelModalVisible(true); + // OC_URL annotations get the link-target editor; + // every other annotation gets the standard label + // editor. + if ( + annotation.annotationLabel?.text === OC_URL_LABEL + ) { + setIsEditUrlModalVisible(true); + } else { + setIsEditLabelModalVisible(true); + } }} onMouseDown={(e: React.MouseEvent) => { e.stopPropagation(); @@ -412,6 +452,18 @@ export const Selection: React.FC = ({ onHide={() => setIsEditLabelModalVisible(false)} /> )} + {isEditUrlModalVisible && ( + setIsEditUrlModalVisible(false)} + onConfirm={(url) => { + updateAnnotation(annotation.update({ linkUrl: url })); + setIsEditUrlModalVisible(false); + }} + /> + )} ); }; diff --git a/frontend/src/components/annotator/display/components/SelectionBoundary.tsx b/frontend/src/components/annotator/display/components/SelectionBoundary.tsx index d20cef154..49268de17 100644 --- a/frontend/src/components/annotator/display/components/SelectionBoundary.tsx +++ b/frontend/src/components/annotator/display/components/SelectionBoundary.tsx @@ -26,7 +26,15 @@ interface SelectionBoundaryProps { children?: React.ReactNode; annotationId?: string; onHover?: (hovered: boolean) => void; - onClick?: () => void; + onClick?: (event?: React.MouseEvent) => void; + /** + * When true, plain (non-shift) clicks also invoke ``onClick``. Used for + * hyperlink-style annotations (OC_URL) where a single click should open + * the link. When false (default) the shift-click-to-select semantic is + * preserved, so plain clicks fall through to canvas handlers and don't + * interfere with creating a new annotation underneath. + */ + clickThroughOnPlainClick?: boolean; approved?: boolean; rejected?: boolean; } @@ -94,6 +102,7 @@ export const SelectionBoundary: React.FC = ({ children, onHover, onClick, + clickThroughOnPlainClick = false, selected, approved, rejected, @@ -140,15 +149,17 @@ export const SelectionBoundary: React.FC = ({ const handleClick = (e: React.MouseEvent) => { if (isCreatingAnnotation) return; - if (e.shiftKey && onClick) { + if (!onClick) return; + if (e.shiftKey || clickThroughOnPlainClick) { e.stopPropagation(); - onClick(); + onClick(e); } }; const handleMouseDown = (e: React.MouseEvent) => { if (isCreatingAnnotation) return; - if (e.shiftKey && onClick) { + if (!onClick) return; + if (e.shiftKey || clickThroughOnPlainClick) { e.stopPropagation(); } }; @@ -181,7 +192,10 @@ export const SelectionBoundary: React.FC = ({ $bounds={bounds} $approved={approved} $rejected={rejected} - style={{ pointerEvents: isCreatingAnnotation ? "none" : "auto" }} + style={{ + pointerEvents: isCreatingAnnotation ? "none" : "auto", + cursor: clickThroughOnPlainClick ? "pointer" : undefined, + }} > {children || null} diff --git a/frontend/src/components/annotator/hooks/AnnotationHooks.tsx b/frontend/src/components/annotator/hooks/AnnotationHooks.tsx index fdea9a22a..3e8f641ff 100644 --- a/frontend/src/components/annotator/hooks/AnnotationHooks.tsx +++ b/frontend/src/components/annotator/hooks/AnnotationHooks.tsx @@ -4,6 +4,7 @@ import { useMutation } from "@apollo/client"; import { toast } from "react-toastify"; import { REQUEST_ADD_ANNOTATION, + REQUEST_ADD_URL_ANNOTATION, REQUEST_DELETE_ANNOTATION, REQUEST_UPDATE_ANNOTATION, REQUEST_ADD_DOC_TYPE_ANNOTATION, @@ -14,6 +15,8 @@ import { REJECT_ANNOTATION, NewAnnotationInputType, NewAnnotationOutputType, + NewUrlAnnotationInputType, + NewUrlAnnotationOutputType, RemoveAnnotationInputType, RemoveAnnotationOutputType, UpdateAnnotationInputType, @@ -281,7 +284,9 @@ export function useCreateAnnotation() { false, false, false, - createdAnnotationData.id + createdAnnotationData.id, + undefined, + createdAnnotationData.linkUrl ?? null ); } else { newAnnotation = new ServerTokenAnnotation( @@ -294,7 +299,9 @@ export function useCreateAnnotation() { false, false, false, - createdAnnotationData.id + createdAnnotationData.id, + undefined, + createdAnnotationData.linkUrl ?? null ); } @@ -322,6 +329,111 @@ export function useCreateAnnotation() { ]); } +/** + * Hook to create an OC_URL annotation: a clickable hyperlink anchored to + * the supplied selection. Internally calls the ``addUrlAnnotation`` mutation, + * which auto-ensures the OC_URL label exists in the corpus. + */ +export function useCreateUrlAnnotation() { + const { addMultipleAnnotations } = usePdfAnnotations(); + const selectedDocument = useAtomValue(selectedDocumentAtom); + const { selectedCorpus } = useCorpusState(); + + const [createUrlAnnotation] = useMutation< + NewUrlAnnotationOutputType, + NewUrlAnnotationInputType + >(REQUEST_ADD_URL_ANNOTATION); + + const handleCreateUrlAnnotation = useCallback( + async ( + annotation: ServerTokenAnnotation | ServerSpanAnnotation, + linkUrl: string + ) => { + if (!selectedCorpus || !selectedDocument) { + toast.warning("No corpus or document selected"); + return; + } + const trimmedUrl = linkUrl.trim(); + if (!trimmedUrl) { + toast.warning("URL is required for link annotation"); + return; + } + + try { + const result = await createUrlAnnotation({ + variables: { + json: annotation.json, + page: annotation.page, + rawText: annotation.rawText, + corpusId: selectedCorpus.id, + documentId: selectedDocument.id, + annotationType: + annotation instanceof ServerSpanAnnotation + ? LabelType.SpanLabel + : LabelType.TokenLabel, + linkUrl: trimmedUrl, + }, + }); + + const payload = result?.data?.addUrlAnnotation; + if (!payload?.ok || !payload.annotation) { + toast.error( + `Unable to create link annotation: ${ + payload?.message ?? "unknown error" + }` + ); + return; + } + + const created = payload.annotation; + const newAnnotation: ServerTokenAnnotation | ServerSpanAnnotation = + isSpanBasedFileType(selectedDocument.fileType) + ? new ServerSpanAnnotation( + created.page, + created.annotationLabel, + created.rawText, + false, + created.json as SpanAnnotationJson, + getPermissions(created.myPermissions || []), + false, + false, + false, + created.id, + undefined, + created.linkUrl + ) + : new ServerTokenAnnotation( + created.page, + created.annotationLabel, + created.rawText, + false, + created.json ?? {}, + getPermissions(created.myPermissions || []), + false, + false, + false, + created.id, + undefined, + created.linkUrl + ); + + addMultipleAnnotations([newAnnotation]); + toast.success("Link annotation created"); + } catch (error: unknown) { + toast.error(`Unable to create link annotation: ${error}`); + } + }, + [ + selectedDocument, + selectedCorpus, + createUrlAnnotation, + addMultipleAnnotations, + ] + ); + + return handleCreateUrlAnnotation; +} + /** * Hook to update an existing annotation. */ @@ -349,6 +461,9 @@ export function useUpdateAnnotation() { rawText: annotation.rawText, page: annotation.page, annotationLabel: annotation.annotationLabel.id, + // ``null`` is sent as-is and clears the column server-side; + // ``undefined`` is dropped by Apollo before serialising the request. + linkUrl: annotation.linkUrl ?? null, }, }); @@ -366,7 +481,9 @@ export function useUpdateAnnotation() { false, false, false, - annotation.id + annotation.id, + undefined, + annotation.linkUrl ?? null ); } else { updatedAnnotation = new ServerTokenAnnotation( @@ -379,7 +496,9 @@ export function useUpdateAnnotation() { false, false, false, - annotation.id + annotation.id, + undefined, + annotation.linkUrl ?? null ); } diff --git a/frontend/src/components/annotator/hooks/__tests__/AnnotationHooks.test.tsx b/frontend/src/components/annotator/hooks/__tests__/AnnotationHooks.test.tsx index e1d044ee6..96a66cccc 100644 --- a/frontend/src/components/annotator/hooks/__tests__/AnnotationHooks.test.tsx +++ b/frontend/src/components/annotator/hooks/__tests__/AnnotationHooks.test.tsx @@ -489,6 +489,10 @@ describe("AnnotationHooks", () => { rawText: existing.rawText, page: existing.page, annotationLabel: mockLabel.id, + // useUpdateAnnotation always sends linkUrl (null clears the + // column server-side; non-null sets it). The mock must match + // these variables exactly or Apollo never resolves. + linkUrl: null, }, }, result: { diff --git a/frontend/src/components/annotator/renderers/pdf/PDF.tsx b/frontend/src/components/annotator/renderers/pdf/PDF.tsx index 708b55a14..a16ac892a 100644 --- a/frontend/src/components/annotator/renderers/pdf/PDF.tsx +++ b/frontend/src/components/annotator/renderers/pdf/PDF.tsx @@ -107,6 +107,15 @@ interface PDFProps { read_only: boolean; containerWidth?: number | null; createAnnotationHandler: (annotation: ServerTokenAnnotation) => Promise; + /** + * Optional handler that creates an OC_URL link annotation. Forwarded + * down to ``SelectionLayer`` so it can present an "Add link…" action in + * the selection menu. Omitted by callers that don't support links. + */ + createUrlAnnotationHandler?: ( + annotation: ServerTokenAnnotation, + url: string + ) => Promise; } // Shared render coordination state @@ -122,6 +131,7 @@ export const PDF: React.FC = ({ read_only, containerWidth, createAnnotationHandler, + createUrlAnnotationHandler, }) => { const { pages } = usePages(); const setViewStateError = useSetViewStateError(); @@ -563,6 +573,7 @@ export const PDF: React.FC = ({ onError={setViewStateError} containerWidth={containerWidth} createAnnotationHandler={createAnnotationHandler} + createUrlAnnotationHandler={createUrlAnnotationHandler} onZoomRenderRequest={requestPageRender} /> )} diff --git a/frontend/src/components/annotator/renderers/pdf/PDFPage.tsx b/frontend/src/components/annotator/renderers/pdf/PDFPage.tsx index 00e923e71..7bf892903 100644 --- a/frontend/src/components/annotator/renderers/pdf/PDFPage.tsx +++ b/frontend/src/components/annotator/renderers/pdf/PDFPage.tsx @@ -51,6 +51,11 @@ const CanvasWrapper = styled.div` interface PDFPageProps extends PageProps { containerWidth?: number | null; createAnnotationHandler: (annotation: ServerTokenAnnotation) => Promise; + /** Optional OC_URL link-annotation creator forwarded to SelectionLayer. */ + createUrlAnnotationHandler?: ( + annotation: ServerTokenAnnotation, + url: string + ) => Promise; onZoomRenderRequest?: ( pageNumber: number, renderer: any, @@ -73,6 +78,7 @@ export const PDFPage = ({ onError, containerWidth, createAnnotationHandler, + createUrlAnnotationHandler, onZoomRenderRequest, }: PDFPageProps) => { const canvasRef = useRef(null); @@ -568,6 +574,7 @@ export const PDFPage = ({ read_only={read_only} activeSpanLabel={activeSpanLabel ?? null} createAnnotation={createAnnotationHandler} + createUrlAnnotation={createUrlAnnotationHandler} pageNumber={pageInfo.page.pageNumber - 1} /> {pageAnnotationComponents} diff --git a/frontend/src/components/annotator/renderers/pdf/SelectionLayer.tsx b/frontend/src/components/annotator/renderers/pdf/SelectionLayer.tsx index 5536b5455..a4a2bad12 100644 --- a/frontend/src/components/annotator/renderers/pdf/SelectionLayer.tsx +++ b/frontend/src/components/annotator/renderers/pdf/SelectionLayer.tsx @@ -17,7 +17,9 @@ import { useCorpusState } from "../../context/CorpusAtom"; import { useAnnotationSelection } from "../../context/UISettingsAtom"; import { useAtom, useAtomValue } from "jotai"; import { isCreatingAnnotationAtom } from "../../context/UISettingsAtom"; -import { Copy, Tag, X, AlertCircle, Settings, Link } from "lucide-react"; +import { Copy, Tag, X, AlertCircle, Settings, Link, Link2 } from "lucide-react"; +import { CreateUrlAnnotationModal } from "../../components/modals/CreateUrlAnnotationModal"; +import { LabelType } from "../../types/enums"; import { SelectionActionMenu, ActionMenuItem, @@ -43,6 +45,16 @@ interface SelectionLayerProps { read_only: boolean; activeSpanLabel: AnnotationLabelType | null; createAnnotation: (annotation: ServerTokenAnnotation) => void; + /** + * Optional handler that creates an OC_URL link annotation. When provided + * the action menu renders an "Add link…" item; otherwise the link UI is + * hidden. Lifted as a prop (rather than calling the hook directly) so + * tests can render SelectionLayer without an Apollo provider. + */ + createUrlAnnotation?: ( + annotation: ServerTokenAnnotation, + url: string + ) => Promise; pageNumber: number; } @@ -51,6 +63,7 @@ const SelectionLayer = ({ read_only, activeSpanLabel, createAnnotation, + createUrlAnnotation, pageNumber, }: SelectionLayerProps) => { const location = useLocation(); @@ -66,6 +79,10 @@ const SelectionLayer = ({ const { setSelectedAnnotations } = useAnnotationSelection(); const [, setIsCreatingAnnotation] = useAtom(isCreatingAnnotationAtom); + const [urlModalOpen, setUrlModalOpen] = useState(false); + const [urlPendingSelections, setUrlPendingSelections] = useState<{ + [key: number]: BoundingBox[]; + } | null>(null); const [localPageSelection, setLocalPageSelection] = useState< { pageNumber: number; bounds: BoundingBox } | undefined >(); @@ -254,6 +271,87 @@ const SelectionLayer = ({ setPendingSelections({}); }, [activeSpanLabel, pendingSelections, handleCreateMultiPageAnnotation]); + /** + * Opens the URL prompt modal, capturing the current pending selection so + * the user can finish typing a URL without losing their bounds while the + * action menu closes. + */ + const handleStartCreateLink = useCallback(() => { + setUrlPendingSelections(pendingSelections); + lastMenuInteractionTime.current = Date.now(); + setShowActionMenu(false); + setUrlModalOpen(true); + }, [pendingSelections]); + + /** + * Builds a ServerTokenAnnotation from the captured selections and calls + * ``addUrlAnnotation``. Mirrors ``handleCreateMultiPageAnnotation`` for + * the non-URL flow. + */ + const handleConfirmCreateLink = useCallback( + async (url: string) => { + const selections = urlPendingSelections; + if ( + !createUrlAnnotation || + !selections || + Object.keys(selections).length === 0 + ) { + setUrlModalOpen(false); + return; + } + + const pages = Object.keys(selections).map(Number); + const annotations: Record = {}; + let combinedRawText = ""; + for (const pageNum of pages) { + const pageAnnotation = pageInfo.getPageAnnotationJson( + selections[pageNum] + ); + if (pageAnnotation) { + annotations[pageNum] = pageAnnotation; + combinedRawText += " " + pageAnnotation.rawText; + } + } + + // ``createUrlAnnotation`` only consumes ``page``, ``json``, ``rawText`` + // and the label-type marker, so a synthetic OC_URL label placeholder + // is sufficient — the backend resolves/creates the real label. + const placeholder: AnnotationLabelType = { + id: "__pending_oc_url__", + text: "OC_URL", + color: "#2563EB", + labelType: LabelType.TokenLabel, + } as AnnotationLabelType; + + const annotation = new ServerTokenAnnotation( + pages[0], + placeholder, + combinedRawText.trim(), + false, + annotations, + [], + false, + false, + false + ); + + await createUrlAnnotation(annotation, url); + + setUrlModalOpen(false); + setUrlPendingSelections(null); + setMultiSelections({}); + setPendingSelections({}); + }, + [urlPendingSelections, pageInfo, createUrlAnnotation] + ); + + const handleCancelCreateLink = useCallback(() => { + setUrlModalOpen(false); + setUrlPendingSelections(null); + setMultiSelections({}); + setPendingSelections({}); + }, []); + /** * Handles canceling the selection without any action. */ @@ -891,6 +989,22 @@ const SelectionLayer = ({ {!read_only && canUpdateCorpus && ( <> + {createUrlAnnotation && ( + { + e.stopPropagation(); + handleStartCreateLink(); + }} + onTouchStart={(e) => { + e.stopPropagation(); + lastMenuInteractionTime.current = Date.now(); + }} + data-testid="create-link-button" + > + + Add link… + + )} {activeSpanLabel ? ( { @@ -978,6 +1092,29 @@ const SelectionLayer = ({ , document.body )} + + {urlModalOpen && + createPortal( + + pageInfo.getPageAnnotationJson(urlPendingSelections[p]) + ?.rawText ?? "" + ) + .join(" ") + .trim() + : "" + } + onCancel={handleCancelCreateLink} + onConfirm={handleConfirmCreateLink} + />, + document.body + )} ); }; diff --git a/frontend/src/components/annotator/renderers/txt/TxtAnnotator.tsx b/frontend/src/components/annotator/renderers/txt/TxtAnnotator.tsx index cc3d60e43..3927bff06 100644 --- a/frontend/src/components/annotator/renderers/txt/TxtAnnotator.tsx +++ b/frontend/src/components/annotator/renderers/txt/TxtAnnotator.tsx @@ -34,7 +34,10 @@ import { Label, LabelContainer, PaperContainer } from "./StyledComponents"; import RadialButtonCloud, { CloudButtonItem } from "./RadialButtonCloud"; import { hexToRgba } from "./utils"; import { useLocation } from "react-router-dom"; -import { Copy, Link, Tag, X, AlertCircle } from "lucide-react"; +import { Copy, ExternalLink, Link, Tag, X, AlertCircle } from "lucide-react"; +import { isUrlAnnotation, openAnnotationUrl } from "../../utils/urlAnnotation"; +import { CreateUrlAnnotationModal } from "../../components/modals/CreateUrlAnnotationModal"; +import { Link2 } from "lucide-react"; import { encodeTextBlock, textBlockFromSpan, @@ -110,6 +113,16 @@ interface TxtAnnotatorProps { allowInput: boolean; /** Creates a new annotation in upstream data. */ createAnnotation: (added_annotation_obj: ServerSpanAnnotation) => void; + /** + * Optional handler that creates an OC_URL link annotation. When provided, + * an "Add link…" item appears in the selection menu; otherwise the link + * UI is hidden. Passed in (rather than via a hook) so the renderer + * stays decoupled from Apollo for unit tests. + */ + createUrlAnnotation?: ( + annotation: ServerSpanAnnotation, + url: string + ) => Promise; /** Updates an existing annotation in upstream data. */ updateAnnotation: (updated_annotation: ServerSpanAnnotation) => void; /** Approves an annotation if permitted. */ @@ -351,6 +364,7 @@ const TxtAnnotator: React.FC = ({ read_only, allowInput, createAnnotation, + createUrlAnnotation, updateAnnotation, approveAnnotation, rejectAnnotation, @@ -495,9 +509,21 @@ const TxtAnnotator: React.FC = ({ /** * Handle clicking on a label - toggle selected annotation or clear it. + * + * For OC_URL link annotations, a plain click opens the target URL instead + * of toggling selection. Holding Shift / Ctrl / Cmd falls back to the + * normal toggle behaviour so authors can still pick the link to edit it. */ const handleLabelClick = useCallback( - (annotation: ServerSpanAnnotation) => { + (annotation: ServerSpanAnnotation, event?: React.MouseEvent) => { + if ( + isUrlAnnotation(annotation) && + !event?.shiftKey && + !event?.metaKey && + !event?.ctrlKey + ) { + if (openAnnotationUrl(annotation)) return; + } if (selectedAnnotations.includes(annotation.id)) { setSelectedAnnotations([]); } else { @@ -874,6 +900,56 @@ const TxtAnnotator: React.FC = ({ dismissMenu(); }, [pendingSelection, getSpan, createAnnotation, dismissMenu]); + // URL-link creation flow: capture the active selection, hide the action + // menu, and pop the URL prompt modal. On confirm we hand off to the + // injected ``createUrlAnnotation`` prop (wired to the corpus-scoped + // ``useCreateUrlAnnotation`` hook by the wrapper). + const [urlModalOpen, setUrlModalOpen] = useState(false); + const [urlPendingSelection, setUrlPendingSelection] = useState<{ + start: number; + end: number; + text: string; + } | null>(null); + + const handleTxtStartCreateLink = useCallback(() => { + if (pendingSelection) { + setUrlPendingSelection(pendingSelection); + dismissMenu(); + setUrlModalOpen(true); + } + }, [pendingSelection, dismissMenu]); + + const handleTxtConfirmCreateLink = useCallback( + async (url: string) => { + if (urlPendingSelection && createUrlAnnotation) { + const newAnnotation = getSpan(urlPendingSelection); + await createUrlAnnotation(newAnnotation, url); + } + setUrlModalOpen(false); + setUrlPendingSelection(null); + }, + [urlPendingSelection, getSpan, createUrlAnnotation] + ); + + const handleTxtCancelCreateLink = useCallback(() => { + setUrlModalOpen(false); + setUrlPendingSelection(null); + }, []); + + // Editing the URL on an existing OC_URL annotation. + const [urlEditAnnotation, setUrlEditAnnotation] = + useState(null); + + const handleTxtConfirmEditLink = useCallback( + (url: string) => { + if (urlEditAnnotation) { + updateAnnotation(urlEditAnnotation.update({ linkUrl: url })); + } + setUrlEditAnnotation(null); + }, + [urlEditAnnotation, updateAnnotation] + ); + // Handle clicks outside the action menu and keyboard shortcuts useEffect(() => { if (!showActionMenu) return; @@ -1024,6 +1100,19 @@ const TxtAnnotator: React.FC = ({ ? finalAnnotations[0].annotationLabel.color || "#cccccc" : undefined; + // First OC_URL annotation overlapping this span — used to make + // the span behave (and look) like a hyperlink. + const linkAnnotation = finalAnnotations.find(isUrlAnnotation); + + const handleSpanClick = linkAnnotation + ? (event: React.MouseEvent) => { + if (event.shiftKey || event.metaKey || event.ctrlKey) return; + if (openAnnotationUrl(linkAnnotation)) { + event.stopPropagation(); + } + } + : undefined; + return ( = ({ } onMouseEnter={() => handleMouseEnter(index)} onMouseLeave={handleMouseLeave} + onClick={handleSpanClick} approved={approved} rejected={rejected} hasBorder={hasBorder} @@ -1045,6 +1135,9 @@ const TxtAnnotator: React.FC = ({ ...backgroundStyle, position: "relative", paddingLeft: isChatSource ? "4px" : undefined, + cursor: linkAnnotation ? "pointer" : undefined, + textDecoration: linkAnnotation ? "underline" : undefined, + textUnderlineOffset: linkAnnotation ? "2px" : undefined, }} > {isChatSource && ( @@ -1071,10 +1164,16 @@ const TxtAnnotator: React.FC = ({ actions.push({ name: "edit", color: "#a3a3a3", - tooltip: "Edit Annotation", + tooltip: isUrlAnnotation(annotation) + ? "Edit Link URL" + : "Edit Annotation", onClick: () => { - setAnnotationToEdit(annotation); - setEditModalOpen(true); + if (isUrlAnnotation(annotation)) { + setUrlEditAnnotation(annotation); + } else { + setAnnotationToEdit(annotation); + setEditModalOpen(true); + } }, }); } @@ -1139,9 +1238,20 @@ const TxtAnnotator: React.FC = ({ $index={labelIndex} onClick={(e: React.MouseEvent) => { e.stopPropagation(); - handleLabelClick(annotation); + handleLabelClick(annotation, e); + }} + style={{ + display: "inline-flex", + alignItems: "center", + gap: "4px", }} > + {isUrlAnnotation(annotation) && ( + + )} {annotation.annotationLabel.text} = ({ {allowInput && !read_only && ( <> + {createUrlAnnotation && ( + { + e.stopPropagation(); + handleTxtStartCreateLink(); + }} + data-testid="txt-create-link-button" + > + + Add link… + + )} { e.stopPropagation(); @@ -1245,6 +1367,29 @@ const TxtAnnotator: React.FC = ({ document.body )} + {urlModalOpen && + createPortal( + , + document.body + )} + + {urlEditAnnotation && + createPortal( + setUrlEditAnnotation(null)} + onConfirm={handleTxtConfirmEditLink} + />, + document.body + )} + 0 + ); +} + +/** + * Allow-list mirrored from the backend (``Annotation.validate_link_url``) + * so the renderer refuses to open dangerous schemes even if the database + * was bypassed (e.g. via a stale cached annotation). + */ +function isSafeUrl(url: string): boolean { + const normalized = url.trim(); + return ( + normalized.toLowerCase().startsWith("http://") || + normalized.toLowerCase().startsWith("https://") || + normalized.startsWith("/") + ); +} + +/** + * Open the annotation's ``linkUrl`` in a new tab. External http(s) + * targets use ``window.open`` with ``noopener,noreferrer`` so the opened + * page cannot reach back into the OpenContracts session. Site-relative + * paths navigate within the current tab so the SPA router can resolve + * them. + * + * Returns ``true`` when navigation was attempted, ``false`` when the URL + * was missing or unsafe. + */ +export function openAnnotationUrl( + annotation: ServerTokenAnnotation | ServerSpanAnnotation +): boolean { + const url = annotation.linkUrl; + if (!url || !isSafeUrl(url)) return false; + const normalized = url.trim(); + if (normalized.startsWith("/")) { + window.location.assign(normalized); + } else { + window.open(normalized, "_blank", "noopener,noreferrer"); + } + return true; +} diff --git a/frontend/src/components/knowledge_base/document/DocumentKnowledgeBase.tsx b/frontend/src/components/knowledge_base/document/DocumentKnowledgeBase.tsx index b204d229e..417fbc6a4 100644 --- a/frontend/src/components/knowledge_base/document/DocumentKnowledgeBase.tsx +++ b/frontend/src/components/knowledge_base/document/DocumentKnowledgeBase.tsx @@ -24,7 +24,10 @@ import { useDocumentPermissions } from "../../annotator/context/DocumentAtom"; import { useAtom, useSetAtom } from "jotai"; import { useAnnotationSelection } from "../../annotator/context/UISettingsAtom"; import { useChatSourceState } from "../../annotator/context/ChatSourceAtom"; -import { useCreateAnnotation } from "../../annotator/hooks/AnnotationHooks"; +import { + useCreateAnnotation, + useCreateUrlAnnotation, +} from "../../annotator/hooks/AnnotationHooks"; import { ServerTokenAnnotation } from "../../annotator/types/annotations"; import { selectedRelationsAtom, @@ -403,6 +406,7 @@ const DocumentKnowledgeBase: React.FC = ({ // Call the hook ONCE here const originalCreateAnnotationHandler = useCreateAnnotation(); + const originalCreateUrlAnnotationHandler = useCreateUrlAnnotation(); // Conditional annotation handlers based on corpus availability const createAnnotationHandler = React.useCallback( @@ -416,6 +420,17 @@ const DocumentKnowledgeBase: React.FC = ({ [corpusId, originalCreateAnnotationHandler] ); + const createUrlAnnotationHandler = React.useCallback( + async (annotation: ServerTokenAnnotation, url: string): Promise => { + if (!corpusId) { + toast.info("Add document to corpus to create annotations"); + return; + } + await originalCreateUrlAnnotationHandler(annotation, url); + }, + [corpusId, originalCreateUrlAnnotationHandler] + ); + const { selectedAnalysis, selectedExtract } = useAnalysisSelection(); const { selectedAnnotations, setSelectedAnnotations } = useAnnotationSelection(); @@ -668,6 +683,7 @@ const DocumentKnowledgeBase: React.FC = ({ containerWidth={containerWidth} containerRefCallback={containerRefCallback} createAnnotationHandler={createAnnotationHandler} + createUrlAnnotationHandler={createUrlAnnotationHandler} /> ); diff --git a/frontend/src/components/knowledge_base/document/document_kb/DocumentViewer.tsx b/frontend/src/components/knowledge_base/document/document_kb/DocumentViewer.tsx index 163ad12ee..404d844c0 100644 --- a/frontend/src/components/knowledge_base/document/document_kb/DocumentViewer.tsx +++ b/frontend/src/components/knowledge_base/document/document_kb/DocumentViewer.tsx @@ -82,6 +82,11 @@ export interface DocumentViewerProps { containerRefCallback: React.RefCallback; /** Annotation creation handler (PDF only) */ createAnnotationHandler: (annotation: ServerTokenAnnotation) => Promise; + /** OC_URL link-annotation creator (PDF only); enables the "Add link" action. */ + createUrlAnnotationHandler?: ( + annotation: ServerTokenAnnotation, + url: string + ) => Promise; } /** @@ -97,6 +102,7 @@ export const DocumentViewer: React.FC = ({ containerWidth, containerRefCallback, createAnnotationHandler, + createUrlAnnotationHandler, }) => { if (isPdfFileType(fileType)) { return ( @@ -111,6 +117,7 @@ export const DocumentViewer: React.FC = ({ read_only={!canEdit} containerWidth={containerWidth} createAnnotationHandler={createAnnotationHandler} + createUrlAnnotationHandler={createUrlAnnotationHandler} /> diff --git a/frontend/src/graphql/mutations.ts b/frontend/src/graphql/mutations.ts index 87a60318f..c94816e60 100644 --- a/frontend/src/graphql/mutations.ts +++ b/frontend/src/graphql/mutations.ts @@ -942,6 +942,7 @@ export interface NewAnnotationOutputType { page: number; rawText: string; json: MultipageAnnotationJson; + linkUrl: string | null; annotationType: LabelType; annotationLabel: AnnotationLabelType; myPermissions: string[]; @@ -967,6 +968,7 @@ export interface NewAnnotationInputType { documentId: string; annotationLabelId: string; annotationType: LabelType; + linkUrl?: string | null; } export const REQUEST_ADD_ANNOTATION = gql` @@ -978,6 +980,7 @@ export const REQUEST_ADD_ANNOTATION = gql` $documentId: String! $annotationLabelId: String! $annotationType: LabelType! + $linkUrl: String ) { addAnnotation( json: $json @@ -987,6 +990,7 @@ export const REQUEST_ADD_ANNOTATION = gql` documentId: $documentId annotationLabelId: $annotationLabelId annotationType: $annotationType + linkUrl: $linkUrl ) { ok annotation { @@ -994,6 +998,7 @@ export const REQUEST_ADD_ANNOTATION = gql` page rawText json + linkUrl isPublic myPermissions annotationType @@ -1017,6 +1022,77 @@ export const REQUEST_ADD_ANNOTATION = gql` } `; +export interface NewUrlAnnotationOutputType { + addUrlAnnotation: { + ok: boolean; + message?: string; + annotation: { + id: string; + page: number; + rawText: string; + json: MultipageAnnotationJson; + linkUrl: string; + annotationType: LabelType; + annotationLabel: AnnotationLabelType; + myPermissions: string[]; + isPublic: boolean; + } | null; + }; +} + +export interface NewUrlAnnotationInputType { + page: number; + json: MultipageAnnotationJson; + rawText: string; + corpusId: string; + documentId: string; + annotationType: LabelType; + linkUrl: string; +} + +export const REQUEST_ADD_URL_ANNOTATION = gql` + mutation ( + $json: GenericScalar! + $page: Int! + $rawText: String! + $corpusId: String! + $documentId: String! + $annotationType: LabelType! + $linkUrl: String! + ) { + addUrlAnnotation( + json: $json + page: $page + rawText: $rawText + corpusId: $corpusId + documentId: $documentId + annotationType: $annotationType + linkUrl: $linkUrl + ) { + ok + message + annotation { + id + page + rawText + json + linkUrl + isPublic + myPermissions + annotationType + annotationLabel { + id + icon + description + color + text + labelType + } + } + } + } +`; + export interface NewDocTypeAnnotationOutputType { addDocTypeAnnotation: { ok: boolean; @@ -1317,6 +1393,11 @@ export interface UpdateAnnotationInputType { json?: Record; page?: number; rawText?: string; + /** + * URL to open on click for OC_URL annotations. Empty string clears it. + * Restricted server-side to http(s):// or site-relative paths. + */ + linkUrl?: string | null; } export const REQUEST_UPDATE_ANNOTATION = gql` @@ -1326,6 +1407,7 @@ export const REQUEST_UPDATE_ANNOTATION = gql` $json: GenericScalar $page: Int $rawText: String + $linkUrl: String ) { updateAnnotation( id: $id @@ -1333,6 +1415,7 @@ export const REQUEST_UPDATE_ANNOTATION = gql` json: $json page: $page rawText: $rawText + linkUrl: $linkUrl ) { ok message diff --git a/frontend/src/graphql/queries.ts b/frontend/src/graphql/queries.ts index e95ce4e5c..82baf518f 100644 --- a/frontend/src/graphql/queries.ts +++ b/frontend/src/graphql/queries.ts @@ -1075,6 +1075,7 @@ export const GET_ANNOTATIONS = gql` annotationType structural rawText + linkUrl isPublic myPermissions contentModalities @@ -1192,6 +1193,7 @@ export const GET_ANNOTATIONS_FOR_CARDS = gql` annotationType structural rawText + linkUrl isPublic contentModalities __typename @@ -1326,6 +1328,7 @@ export const SEMANTIC_SEARCH_ANNOTATIONS = gql` annotationType structural rawText + linkUrl isPublic myPermissions contentModalities @@ -2500,6 +2503,7 @@ export const GET_ANNOTATIONS_FOR_ANALYSIS = gql` page rawText + linkUrl json userFeedback { @@ -3333,6 +3337,7 @@ export const GET_DOCUMENT_KNOWLEDGE_AND_ANNOTATIONS = gql` annotationType rawText json + linkUrl myPermissions structural contentModalities @@ -3445,6 +3450,7 @@ export const GET_DOCUMENT_ANNOTATIONS_ONLY = gql` annotationType rawText json + linkUrl myPermissions structural contentModalities @@ -3537,6 +3543,7 @@ export const GET_DOCUMENT_STRUCTURAL_ANNOTATIONS = gql` annotationType rawText json + linkUrl myPermissions structural contentModalities diff --git a/frontend/src/types/graphql-api.ts b/frontend/src/types/graphql-api.ts index 31ed5f329..9d90a2667 100644 --- a/frontend/src/types/graphql-api.ts +++ b/frontend/src/types/graphql-api.ts @@ -140,6 +140,11 @@ export type RawServerAnnotationType = Node & { annotationLabel: AnnotationLabelType; document?: DocumentType; structural?: boolean; + /** + * Target URL for clickable-link annotations (OC_URL label). + * Null/absent for all other annotations. + */ + linkUrl?: Maybe; corpus?: Maybe; creator?: UserType; created?: Scalars["DateTime"]; diff --git a/frontend/src/utils/transform.tsx b/frontend/src/utils/transform.tsx index 325dc9dc4..ab8607b38 100644 --- a/frontend/src/utils/transform.tsx +++ b/frontend/src/utils/transform.tsx @@ -144,7 +144,8 @@ export function convertToServerAnnotation( rejected, allowComments ?? false, annotation.id, - annotation.contentModalities + annotation.contentModalities, + annotation.linkUrl ?? null ); } @@ -161,7 +162,8 @@ export function convertToServerAnnotation( rejected, allowComments ?? false, annotation.id, - annotation.contentModalities + annotation.contentModalities, + annotation.linkUrl ?? null ); } diff --git a/opencontractserver/annotations/migrations/0072_annotation_link_url.py b/opencontractserver/annotations/migrations/0072_annotation_link_url.py new file mode 100644 index 000000000..c039b5464 --- /dev/null +++ b/opencontractserver/annotations/migrations/0072_annotation_link_url.py @@ -0,0 +1,24 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("annotations", "0071_grounding_annotation_unique_constraints"), + ] + + operations = [ + migrations.AddField( + model_name="annotation", + name="link_url", + field=models.URLField( + blank=True, + help_text=( + "Target URL opened when the annotation is clicked. " + "Only meaningful for annotations labelled OC_URL." + ), + max_length=2048, + null=True, + ), + ), + ] diff --git a/opencontractserver/annotations/models.py b/opencontractserver/annotations/models.py index cdafce866..550801aff 100644 --- a/opencontractserver/annotations/models.py +++ b/opencontractserver/annotations/models.py @@ -91,6 +91,41 @@ ] +# Allowed URL schemes for ``Annotation.link_url`` and OC_URL authoring +# flows. Anything not in this allow-list (notably ``javascript:`` and +# ``data:``) is rejected to keep the click-to-open flow XSS-safe. +LINK_URL_ALLOWED_SCHEMES: tuple[str, ...] = ("http://", "https://") + + +def validate_link_url(url: str) -> None: + """Raise ``ValidationError`` if *url* is not a safe link target. + + Accepts: + * ``http://`` / ``https://`` absolute URLs + * site-relative paths beginning with ``/`` + + Rejects every other scheme — particularly ``javascript:`` and ``data:``, + both of which would execute attacker-controlled code if reflected into + ``window.open`` on click. + """ + + if not url: + return + normalized = url.strip() + is_safe = normalized.lower().startswith( + LINK_URL_ALLOWED_SCHEMES + ) or normalized.startswith("/") + if not is_safe: + raise ValidationError( + { + "link_url": ( + "link_url must be an http(s):// URL or a site-relative " + "path starting with '/'." + ) + } + ) + + class AnnotationLabel(BaseOCModel): label_type = django.db.models.CharField( @@ -966,6 +1001,20 @@ class Annotation(BaseOCModel, HasEmbeddingMixin): # Mark structural / layout annotations explicitly. structural = django.db.models.BooleanField(default=False) + # Target URL for clickable-link annotations (used with the OC_URL label). + # Frontend opens this URL when the annotation is clicked. Restricted to + # http(s) and protocol-relative schemes in ``clean()`` to block + # ``javascript:`` and other dangerous schemes from reaching the renderer. + link_url = django.db.models.URLField( + max_length=2048, + null=True, + blank=True, + help_text=( + "Target URL opened when the annotation is clicked. " + "Only meaningful for annotations labelled OC_URL." + ), + ) + # True only for annotations created by the extraction-grounding pipeline # (``opencontractserver/utils/extraction_grounding.py``). Backs the # partial UniqueConstraints below — the constraints scope to this flag @@ -1121,6 +1170,12 @@ def clean(self) -> None: # noqa: C901 (complexity – kept minimal) } ) + # Validate link_url scheme (always runs, even when JSON validation + # is disabled — link_url is reflected in clickable UI, so unsafe + # schemes like ``javascript:`` must be rejected before persistence). + if self.link_url: + validate_link_url(self.link_url) + # Validate mutual exclusivity of document vs structural_set has_document = self.document_id is not None has_structural_set = getattr(self, "structural_set_id", None) is not None @@ -1159,6 +1214,11 @@ def save(self, *args: Any, **kwargs: Any) -> None: # Ensure that `clean()` is executed even if external callers forget. self.clean() + # link_url validation always runs, regardless of the JSON-validation + # flag, because the URL is reflected directly into a click handler. + if self.link_url: + validate_link_url(self.link_url) + # Auto-compact annotation JSON to v2 format on save (lazy migration). if ( self.annotation_type == TOKEN_LABEL diff --git a/opencontractserver/constants/annotations.py b/opencontractserver/constants/annotations.py index 4f3bac17c..6e9492a12 100644 --- a/opencontractserver/constants/annotations.py +++ b/opencontractserver/constants/annotations.py @@ -13,6 +13,10 @@ # They drive built-in features such as the document index. OC_SECTION_LABEL = "OC_SECTION" OC_EXTRACT_SOURCE_LABEL = "OC_EXTRACT_SOURCE" +# OC_URL annotations carry a target URL in ``Annotation.link_url`` that the +# frontend opens when the annotation is clicked, turning highlighted text into +# a navigable hyperlink. +OC_URL_LABEL = "OC_URL" # Maximum number of entries allowed in a single create_document_index call. DOCUMENT_ANNOTATION_INDEX_LIMIT = 500 From 198830e0558f4287e70cc0ce776aeac8e2d0a5c4 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Wed, 13 May 2026 20:53:31 -0500 Subject: [PATCH 02/22] Fix mypy: broaden requests headers types to dict[str, str | bytes] types-requests upgrade made requests.post/get expect MutableMapping[str, str | bytes], which is invariant in values, so dict[str, str] no longer matches. Broaden header annotations and maybe_add_cloud_run_auth() signature accordingly. --- .../pipeline/embedders/multimodal_microservice.py | 4 ++-- .../pipeline/embedders/sent_transformer_microservice.py | 2 +- opencontractserver/pipeline/parsers/docling_parser_rest.py | 2 +- opencontractserver/pipeline/parsers/docxodus_parser.py | 2 +- opencontractserver/pipeline/rerankers/cohere_reranker.py | 2 +- .../pipeline/rerankers/microservice_reranker.py | 2 +- opencontractserver/users/tasks.py | 4 ++-- opencontractserver/utils/cloud.py | 4 ++-- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/opencontractserver/pipeline/embedders/multimodal_microservice.py b/opencontractserver/pipeline/embedders/multimodal_microservice.py index 8a0b873bc..f7f95c73f 100644 --- a/opencontractserver/pipeline/embedders/multimodal_microservice.py +++ b/opencontractserver/pipeline/embedders/multimodal_microservice.py @@ -553,7 +553,7 @@ def _get_service_config(self, all_kwargs: dict) -> tuple[str, str, dict]: ) # Build headers - headers: dict[str, str] = {"Content-Type": "application/json"} + headers: dict[str, str | bytes] = {"Content-Type": "application/json"} if api_key: headers["X-API-Key"] = api_key @@ -648,7 +648,7 @@ def _get_service_config(self, all_kwargs: dict) -> tuple[str, str, dict]: ) # Build headers - headers: dict[str, str] = {"Content-Type": "application/json"} + headers: dict[str, str | bytes] = {"Content-Type": "application/json"} if api_key: headers["X-API-Key"] = api_key diff --git a/opencontractserver/pipeline/embedders/sent_transformer_microservice.py b/opencontractserver/pipeline/embedders/sent_transformer_microservice.py index 2e56d1748..326b023e7 100644 --- a/opencontractserver/pipeline/embedders/sent_transformer_microservice.py +++ b/opencontractserver/pipeline/embedders/sent_transformer_microservice.py @@ -198,7 +198,7 @@ def _get_service_config(self, all_kwargs: dict) -> tuple[str, dict]: all_kwargs.get("use_cloud_run_iam_auth", s.use_cloud_run_iam_auth) ) - headers: dict[str, str] = {"Content-Type": "application/json"} + headers: dict[str, str | bytes] = {"Content-Type": "application/json"} if api_key: headers["X-API-Key"] = api_key diff --git a/opencontractserver/pipeline/parsers/docling_parser_rest.py b/opencontractserver/pipeline/parsers/docling_parser_rest.py index d5db0713f..6cfddeaa8 100644 --- a/opencontractserver/pipeline/parsers/docling_parser_rest.py +++ b/opencontractserver/pipeline/parsers/docling_parser_rest.py @@ -332,7 +332,7 @@ def _parse_single_chunk_impl( f"{self.service_url}" ) try: - headers: dict[str, str] = {"Content-Type": "application/json"} + headers: dict[str, str | bytes] = {"Content-Type": "application/json"} # Attach Cloud Run IAM id_token if applicable/forced headers = maybe_add_cloud_run_auth( self.service_url, headers, force=self.use_cloud_run_iam_auth diff --git a/opencontractserver/pipeline/parsers/docxodus_parser.py b/opencontractserver/pipeline/parsers/docxodus_parser.py index 1810dcc98..4a0d83245 100644 --- a/opencontractserver/pipeline/parsers/docxodus_parser.py +++ b/opencontractserver/pipeline/parsers/docxodus_parser.py @@ -145,7 +145,7 @@ def _parse_document_impl( } try: - headers: dict[str, str] = {"Content-Type": "application/json"} + headers: dict[str, str | bytes] = {"Content-Type": "application/json"} headers = maybe_add_cloud_run_auth( self.service_url, headers, force=self.use_cloud_run_iam_auth ) diff --git a/opencontractserver/pipeline/rerankers/cohere_reranker.py b/opencontractserver/pipeline/rerankers/cohere_reranker.py index 611fdb445..20d5535c7 100644 --- a/opencontractserver/pipeline/rerankers/cohere_reranker.py +++ b/opencontractserver/pipeline/rerankers/cohere_reranker.py @@ -122,7 +122,7 @@ def _rerank_impl( # Cohere calls this ``top_n``. payload["top_n"] = int(top_k) - headers = { + headers: dict[str, str | bytes] = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", } diff --git a/opencontractserver/pipeline/rerankers/microservice_reranker.py b/opencontractserver/pipeline/rerankers/microservice_reranker.py index 3fab6006f..6834a0054 100644 --- a/opencontractserver/pipeline/rerankers/microservice_reranker.py +++ b/opencontractserver/pipeline/rerankers/microservice_reranker.py @@ -131,7 +131,7 @@ def _get_service_config(self, all_kwargs: dict) -> tuple[str, dict, int]: ) timeout = int(all_kwargs.get("timeout_seconds", s.timeout_seconds)) - headers: dict[str, str] = {"Content-Type": "application/json"} + headers: dict[str, str | bytes] = {"Content-Type": "application/json"} if api_key: headers["X-API-Key"] = api_key headers = maybe_add_cloud_run_auth( diff --git a/opencontractserver/users/tasks.py b/opencontractserver/users/tasks.py index c165613cf..d1f052619 100644 --- a/opencontractserver/users/tasks.py +++ b/opencontractserver/users/tasks.py @@ -33,7 +33,7 @@ def get_new_auth0_token() -> Optional[str]: url = f"https://{auth0_settings.AUTH0_DOMAIN}/oauth/token" - headers: dict[str, str] = {"content-type": "application/json"} + headers: dict[str, str | bytes] = {"content-type": "application/json"} request_data: dict[str, Any] = { "grant_type": auth0_settings.AUTH0_M2M_MANAGEMENT_GRANT_TYPE, @@ -158,7 +158,7 @@ def ensure_valid_auth0_token() -> Optional[str]: @celery_app.task def get_user_details_async(token: str, auth0_Id: str) -> dict[str, Any]: - headers: dict[str, str] = { + headers: dict[str, str | bytes] = { "Authorization": f"Bearer {token}", } url = f"https://{auth0_settings.AUTH0_DOMAIN}/api/v2/users/{auth0_Id}" diff --git a/opencontractserver/utils/cloud.py b/opencontractserver/utils/cloud.py index 13b472fe7..93cbde9d1 100644 --- a/opencontractserver/utils/cloud.py +++ b/opencontractserver/utils/cloud.py @@ -7,8 +7,8 @@ def maybe_add_cloud_run_auth( - url: str, headers: dict[str, str], force: bool = False -) -> dict[str, str]: + url: str, headers: dict[str, str | bytes], force: bool = False +) -> dict[str, str | bytes]: """ Attach an Authorization bearer with a Google Cloud Run identity token when applicable. Args: From 83719609df03b08c0b79522989ac4b8521603ae7 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Wed, 13 May 2026 20:59:46 -0500 Subject: [PATCH 03/22] Add backend tests for OC_URL annotation mutations and validation Covers validate_link_url (model + GraphQL), addUrlAnnotation (happy path, label idempotency, IDOR, unsafe schemes), addAnnotation linkUrl argument, and updateAnnotation linkUrl clearing/validation. --- .../tests/test_url_annotation.py | 503 ++++++++++++++++++ 1 file changed, 503 insertions(+) create mode 100644 opencontractserver/tests/test_url_annotation.py diff --git a/opencontractserver/tests/test_url_annotation.py b/opencontractserver/tests/test_url_annotation.py new file mode 100644 index 000000000..2438a2e27 --- /dev/null +++ b/opencontractserver/tests/test_url_annotation.py @@ -0,0 +1,503 @@ +""" +Tests for OC_URL clickable hyperlink annotations. + +Covers: +* ``Annotation.link_url`` validation (model-level): blocks ``javascript:``, + ``data:`` and other unsafe schemes; accepts http(s):// and site-relative + paths; empty/None is a no-op. +* GraphQL ``addUrlAnnotation`` mutation: creates an OC_URL label on first + use, anchors highlighted text, persists ``link_url``, enforces visibility + on parent corpus/document, and rejects unsafe URLs with a structured error. +* GraphQL ``addAnnotation`` mutation: optional ``linkUrl`` argument validates + the scheme and is persisted on the resulting annotation. +* GraphQL ``updateAnnotation`` mutation: allows clearing ``link_url`` with an + empty string and rejects unsafe schemes. +""" + +from __future__ import annotations + +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.test import TestCase +from graphene.test import Client +from graphql_relay import to_global_id + +from config.graphql.schema import schema +from opencontractserver.annotations.models import ( + TOKEN_LABEL, + Annotation, + AnnotationLabel, + validate_link_url, +) +from opencontractserver.constants.annotations import OC_URL_LABEL +from opencontractserver.corpuses.models import Corpus +from opencontractserver.documents.models import Document +from opencontractserver.types.enums import PermissionTypes +from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user + +User = get_user_model() + + +ADD_URL_ANNOTATION_MUTATION = """ + mutation AddUrlAnnotation( + $corpusId: String! + $documentId: String! + $page: Int! + $rawText: String! + $json: GenericScalar! + $annotationType: LabelType! + $linkUrl: String! + ) { + addUrlAnnotation( + corpusId: $corpusId + documentId: $documentId + page: $page + rawText: $rawText + json: $json + annotationType: $annotationType + linkUrl: $linkUrl + ) { + ok + message + annotation { + id + rawText + linkUrl + annotationLabel { + text + } + } + } + } +""" + + +ADD_ANNOTATION_WITH_LINK_URL_MUTATION = """ + mutation AddAnnotation( + $corpusId: String! + $documentId: String! + $annotationLabelId: String! + $page: Int! + $rawText: String! + $json: GenericScalar! + $annotationType: LabelType! + $linkUrl: String + ) { + addAnnotation( + corpusId: $corpusId + documentId: $documentId + annotationLabelId: $annotationLabelId + page: $page + rawText: $rawText + json: $json + annotationType: $annotationType + linkUrl: $linkUrl + ) { + ok + message + annotation { + id + linkUrl + } + } + } +""" + + +UPDATE_ANNOTATION_MUTATION = """ + mutation UpdateAnnotation( + $id: String! + $linkUrl: String + ) { + updateAnnotation( + id: $id + linkUrl: $linkUrl + ) { + ok + message + } + } +""" + + +class _MutationContext: + """Minimal info.context stand-in for graphene.test.Client.""" + + def __init__(self, user): + self.user = user + + +class ValidateLinkUrlTests(TestCase): + """Direct coverage of ``validate_link_url`` and ``Annotation.clean()``.""" + + def test_empty_string_is_noop(self): + # Empty / None must return cleanly so the column can stay NULL. + self.assertIsNone(validate_link_url("")) + + def test_none_is_noop(self): + # None defends against callers that forget the empty-string normalisation. + # ``validate_link_url`` is typed for str but treats falsy as a no-op. + self.assertIsNone(validate_link_url("")) + + def test_http_url_is_allowed(self): + # Sanity: plain http URL must be accepted. + self.assertIsNone(validate_link_url("http://example.com")) + + def test_https_url_is_allowed(self): + # Sanity: plain https URL must be accepted. + self.assertIsNone(validate_link_url("https://example.com/path?x=1")) + + def test_site_relative_path_is_allowed(self): + # Site-relative URLs allow internal SPA navigation (e.g. /corpus/foo). + self.assertIsNone(validate_link_url("/corpus/foo")) + + def test_javascript_scheme_is_rejected(self): + with self.assertRaises(ValidationError) as cm: + validate_link_url("javascript:alert(1)") + # Error must mention the offending field for clean GraphQL surfacing. + self.assertIn("link_url", cm.exception.message_dict) + + def test_data_scheme_is_rejected(self): + with self.assertRaises(ValidationError): + validate_link_url("data:text/html,") + + def test_file_scheme_is_rejected(self): + # file:// references would let an attacker probe local resources. + with self.assertRaises(ValidationError): + validate_link_url("file:///etc/passwd") + + def test_ftp_scheme_is_rejected(self): + # Only http(s) + site-relative are in the allow-list — ftp is out. + with self.assertRaises(ValidationError): + validate_link_url("ftp://example.com/file") + + def test_case_insensitive_scheme(self): + # Schemes are compared lowercased, so casing must not bypass the check. + self.assertIsNone(validate_link_url("HTTPS://example.com")) + with self.assertRaises(ValidationError): + validate_link_url("JavaScript:alert(1)") + + def test_whitespace_prefix_does_not_bypass(self): + # ``" javascript:..."`` could trick a naive startswith check if we + # did not strip; the regex must still reject after normalisation. + with self.assertRaises(ValidationError): + validate_link_url(" javascript:alert(1)") + + def test_annotation_clean_rejects_unsafe_link_url(self): + # The model's ``clean()`` must invoke ``validate_link_url`` so + # callers that go through full_clean() are protected. + user = User.objects.create_user(username="u1", password="x") + doc = Document.objects.create( + title="doc", creator=user, is_public=False, backend_lock=False + ) + label = AnnotationLabel.objects.create( + text="L", label_type=TOKEN_LABEL, creator=user + ) + ann = Annotation( + page=0, + raw_text="hello", + document=doc, + annotation_label=label, + creator=user, + annotation_type=TOKEN_LABEL, + link_url="javascript:alert(1)", + json={"0": {"bounds": {}, "rawText": "hello", "tokensJsons": []}}, + ) + with self.assertRaises(ValidationError): + ann.clean() + + def test_annotation_save_rejects_unsafe_link_url(self): + # The override on ``save()`` runs even when the JSON-validation flag + # is disabled — this is the last line of defence before persistence. + user = User.objects.create_user(username="u2", password="x") + doc = Document.objects.create( + title="doc", creator=user, is_public=False, backend_lock=False + ) + label = AnnotationLabel.objects.create( + text="L", label_type=TOKEN_LABEL, creator=user + ) + ann = Annotation( + page=0, + raw_text="hello", + document=doc, + annotation_label=label, + creator=user, + annotation_type=TOKEN_LABEL, + link_url="javascript:alert(1)", + json={"0": {"bounds": {}, "rawText": "hello", "tokensJsons": []}}, + ) + with self.assertRaises(ValidationError): + ann.save() + + +class AddUrlAnnotationMutationTests(TestCase): + """Coverage of the ``addUrlAnnotation`` GraphQL mutation.""" + + def setUp(self): + self.owner = User.objects.create_user(username="owner", password="x") + self.outsider = User.objects.create_user(username="outsider", password="x") + + original_doc = Document.objects.create( + title="Owner Doc", + creator=self.owner, + is_public=False, + backend_lock=False, + ) + self.corpus = Corpus.objects.create( + title="Owner Corpus", creator=self.owner, is_public=False + ) + # add_document returns the corpus-scoped copy that the frontend + # actually annotates against. + self.document, _, _ = self.corpus.add_document( + document=original_doc, user=self.owner + ) + + set_permissions_for_obj_to_user( + self.owner, self.document, [PermissionTypes.CRUD] + ) + set_permissions_for_obj_to_user( + self.owner, self.corpus, [PermissionTypes.CRUD] + ) + + self.client = Client(schema) + + def _execute(self, *, user, link_url, raw_text="link text"): + return self.client.execute( + ADD_URL_ANNOTATION_MUTATION, + variables={ + "corpusId": to_global_id("CorpusType", self.corpus.pk), + "documentId": to_global_id("DocumentType", self.document.pk), + "page": 0, + "rawText": raw_text, + "json": { + "0": { + "bounds": {}, + "rawText": raw_text, + "tokensJsons": [], + } + }, + "annotationType": "TOKEN_LABEL", + "linkUrl": link_url, + }, + context_value=_MutationContext(user), + ) + + def test_owner_creates_url_annotation_and_label(self): + # Happy path: owner creates a URL annotation. The OC_URL label is + # created on first use and the resulting annotation carries the + # supplied link_url. + before_labels = AnnotationLabel.objects.filter(text=OC_URL_LABEL).count() + result = self._execute(user=self.owner, link_url="https://example.com/a") + self.assertNotIn("errors", result, msg=result.get("errors")) + + payload = result["data"]["addUrlAnnotation"] + self.assertTrue(payload["ok"], msg=payload.get("message")) + self.assertIsNotNone(payload["annotation"]) + self.assertEqual(payload["annotation"]["linkUrl"], "https://example.com/a") + self.assertEqual(payload["annotation"]["annotationLabel"]["text"], OC_URL_LABEL) + + # The OC_URL label exists exactly once — the mutation is idempotent + # at the label level so repeated calls reuse the same label row. + self.assertEqual( + AnnotationLabel.objects.filter(text=OC_URL_LABEL).count(), + before_labels + 1, + ) + + def test_second_url_annotation_reuses_oc_url_label(self): + # Idempotency: creating a second URL annotation must NOT create a + # second OC_URL label — ensure_label_and_labelset is idempotent. + self._execute(user=self.owner, link_url="https://example.com/a") + self._execute(user=self.owner, link_url="https://example.com/b") + self.assertEqual( + AnnotationLabel.objects.filter(text=OC_URL_LABEL).count(), 1 + ) + + def test_rejects_javascript_scheme(self): + # Defence in depth: the GraphQL layer must refuse unsafe schemes + # before persistence (the model layer is the last line of defence). + before = Annotation.objects.count() + result = self._execute(user=self.owner, link_url="javascript:alert(1)") + payload = result["data"]["addUrlAnnotation"] + self.assertFalse(payload["ok"]) + self.assertIsNone(payload["annotation"]) + # No row written. + self.assertEqual(Annotation.objects.count(), before) + + def test_rejects_data_scheme(self): + result = self._execute( + user=self.owner, link_url="data:text/html," + ) + self.assertFalse(result["data"]["addUrlAnnotation"]["ok"]) + + def test_outsider_cannot_create_url_annotation(self): + # IDOR coverage: an authenticated user with no permissions on + # the parent corpus/document gets the uniform permission error + # and no annotation is written. + before = Annotation.objects.count() + result = self._execute(user=self.outsider, link_url="https://example.com") + payload = result["data"]["addUrlAnnotation"] + self.assertFalse(payload["ok"]) + self.assertIsNone(payload["annotation"]) + self.assertEqual(Annotation.objects.count(), before) + + def test_site_relative_url_accepted(self): + # Confirms the allow-list lets through internal SPA links. + result = self._execute(user=self.owner, link_url="/corpus/foo/doc/bar") + payload = result["data"]["addUrlAnnotation"] + self.assertTrue(payload["ok"]) + self.assertEqual(payload["annotation"]["linkUrl"], "/corpus/foo/doc/bar") + + +class AddAnnotationLinkUrlTests(TestCase): + """Coverage of the optional ``link_url`` argument on ``addAnnotation``.""" + + def setUp(self): + self.owner = User.objects.create_user(username="owner", password="x") + original_doc = Document.objects.create( + title="Owner Doc", + creator=self.owner, + is_public=False, + backend_lock=False, + ) + self.corpus = Corpus.objects.create( + title="Owner Corpus", creator=self.owner, is_public=False + ) + self.document, _, _ = self.corpus.add_document( + document=original_doc, user=self.owner + ) + self.label = AnnotationLabel.objects.create( + text="Custom", label_type=TOKEN_LABEL, creator=self.owner + ) + set_permissions_for_obj_to_user( + self.owner, self.document, [PermissionTypes.CRUD] + ) + set_permissions_for_obj_to_user( + self.owner, self.corpus, [PermissionTypes.CRUD] + ) + self.client = Client(schema) + + def _execute(self, *, link_url, user=None): + return self.client.execute( + ADD_ANNOTATION_WITH_LINK_URL_MUTATION, + variables={ + "corpusId": to_global_id("CorpusType", self.corpus.pk), + "documentId": to_global_id("DocumentType", self.document.pk), + "annotationLabelId": to_global_id( + "AnnotationLabelType", self.label.pk + ), + "page": 0, + "rawText": "anchor", + "json": { + "0": {"bounds": {}, "rawText": "anchor", "tokensJsons": []} + }, + "annotationType": "TOKEN_LABEL", + "linkUrl": link_url, + }, + context_value=_MutationContext(user or self.owner), + ) + + def test_add_annotation_persists_link_url(self): + result = self._execute(link_url="https://example.com") + payload = result["data"]["addAnnotation"] + self.assertTrue(payload["ok"], msg=payload.get("message")) + self.assertEqual(payload["annotation"]["linkUrl"], "https://example.com") + + def test_add_annotation_rejects_unsafe_link_url(self): + # Validation happens BEFORE the parents are resolved; no DB write. + before = Annotation.objects.count() + result = self._execute(link_url="javascript:alert(1)") + payload = result["data"]["addAnnotation"] + self.assertFalse(payload["ok"]) + self.assertIsNone(payload["annotation"]) + self.assertEqual(Annotation.objects.count(), before) + + def test_add_annotation_without_link_url_is_ok(self): + # Backward compatibility: omitting link_url must still create an + # annotation with link_url=NULL. + result = self._execute(link_url=None) + payload = result["data"]["addAnnotation"] + self.assertTrue(payload["ok"], msg=payload.get("message")) + self.assertIsNone(payload["annotation"]["linkUrl"]) + + +class UpdateAnnotationLinkUrlTests(TestCase): + """Coverage of ``link_url`` handling in ``updateAnnotation``.""" + + def setUp(self): + self.owner = User.objects.create_user(username="owner", password="x") + original_doc = Document.objects.create( + title="Owner Doc", + creator=self.owner, + is_public=False, + backend_lock=False, + ) + self.corpus = Corpus.objects.create( + title="Owner Corpus", creator=self.owner, is_public=False + ) + self.document, _, _ = self.corpus.add_document( + document=original_doc, user=self.owner + ) + self.label = AnnotationLabel.objects.create( + text="Custom", label_type=TOKEN_LABEL, creator=self.owner + ) + set_permissions_for_obj_to_user( + self.owner, self.document, [PermissionTypes.CRUD] + ) + set_permissions_for_obj_to_user( + self.owner, self.corpus, [PermissionTypes.CRUD] + ) + + self.annotation = Annotation.objects.create( + page=0, + raw_text="anchor", + document=self.document, + corpus=self.corpus, + annotation_label=self.label, + creator=self.owner, + annotation_type=TOKEN_LABEL, + link_url="https://example.com/old", + json={"0": {"bounds": {}, "rawText": "anchor", "tokensJsons": []}}, + ) + set_permissions_for_obj_to_user( + self.owner, self.annotation, [PermissionTypes.CRUD] + ) + + self.client = Client(schema) + + def _execute(self, *, link_url): + return self.client.execute( + UPDATE_ANNOTATION_MUTATION, + variables={ + "id": to_global_id("AnnotationType", self.annotation.pk), + "linkUrl": link_url, + }, + context_value=_MutationContext(self.owner), + ) + + def test_update_sets_new_link_url(self): + result = self._execute(link_url="https://example.com/new") + self.assertNotIn("errors", result, msg=result.get("errors")) + self.annotation.refresh_from_db() + self.assertEqual(self.annotation.link_url, "https://example.com/new") + + def test_update_with_empty_string_clears_link_url(self): + # The serializer normalises "" → None so the column ends up NULL. + result = self._execute(link_url="") + self.assertNotIn("errors", result, msg=result.get("errors")) + self.annotation.refresh_from_db() + self.assertIsNone(self.annotation.link_url) + + def test_update_rejects_unsafe_link_url(self): + # serializer.validate_link_url calls validate_link_url which raises + # ValidationError; the original value must remain. + before = self.annotation.link_url + result = self._execute(link_url="javascript:alert(1)") + # GraphQL surface: DRFMutation returns ok=False on validation error. + # The exact key path is mutation-specific; what matters is the row + # was NOT updated. + self.annotation.refresh_from_db() + self.assertEqual(self.annotation.link_url, before) + # The mutation should NOT have set ok=True + if "data" in result and result["data"]: + payload = result["data"].get("updateAnnotation") or {} + self.assertFalse(payload.get("ok", False)) From 82163b337cfefbf3ff33f1e28c41502154180d2f Mon Sep 17 00:00:00 2001 From: JSv4 Date: Wed, 13 May 2026 21:04:32 -0500 Subject: [PATCH 04/22] Add frontend tests for OC_URL annotation utilities and modal Unit tests: - urlAnnotation.ts: isUrlAnnotation matrix + openAnnotationUrl allow-list (http(s)/relative), refusal of javascript: and data: URLs - useCreateUrlAnnotation hook: corpus-missing short-circuit, blank URL short-circuit, success path persistence with server linkUrl, server ok=false path Component tests (Playwright): - CreateUrlAnnotationModal: visibility, create/edit headers, empty + unsafe scheme validation, onConfirm/onCancel callbacks, Enter to submit, trimmed URL, site-relative paths. --- .../hooks/__tests__/AnnotationHooks.test.tsx | 160 ++++++++++++ .../utils/__tests__/urlAnnotation.test.ts | 209 ++++++++++++++++ .../tests/CreateUrlAnnotationModal.ct.tsx | 233 ++++++++++++++++++ 3 files changed, 602 insertions(+) create mode 100644 frontend/src/components/annotator/utils/__tests__/urlAnnotation.test.ts create mode 100644 frontend/tests/CreateUrlAnnotationModal.ct.tsx diff --git a/frontend/src/components/annotator/hooks/__tests__/AnnotationHooks.test.tsx b/frontend/src/components/annotator/hooks/__tests__/AnnotationHooks.test.tsx index 96a66cccc..611857189 100644 --- a/frontend/src/components/annotator/hooks/__tests__/AnnotationHooks.test.tsx +++ b/frontend/src/components/annotator/hooks/__tests__/AnnotationHooks.test.tsx @@ -29,6 +29,7 @@ import { useStructuralAnnotations, useInitialAnnotations, useCreateAnnotation, + useCreateUrlAnnotation, useUpdateAnnotation, useDeleteAnnotation, useApproveAnnotation, @@ -50,6 +51,7 @@ import { selectedDocumentAtom } from "../../context/DocumentAtom"; import { corpusStateAtom } from "../../context/CorpusAtom"; import { REQUEST_ADD_ANNOTATION, + REQUEST_ADD_URL_ANNOTATION, REQUEST_DELETE_ANNOTATION, REQUEST_UPDATE_ANNOTATION, REQUEST_ADD_DOC_TYPE_ANNOTATION, @@ -59,6 +61,7 @@ import { APPROVE_ANNOTATION, REJECT_ANNOTATION, } from "../../../../graphql/mutations"; +import { OC_URL_LABEL } from "../../../../assets/configurations/constants"; import { LabelType } from "../../types/enums"; import { PermissionTypes } from "../../../types"; import type { AnnotationLabelType } from "../../../../types/graphql-api"; @@ -476,6 +479,163 @@ describe("AnnotationHooks", () => { }); }); + describe("useCreateUrlAnnotation", () => { + const ocUrlLabel: AnnotationLabelType = { + id: "label-url", + text: OC_URL_LABEL, + color: "#2563EB", + icon: "link", + description: "url", + labelType: LabelType.SpanLabel, + }; + + it("short-circuits (no mutation) when corpus is missing", async () => { + // The hook must guard against missing parents — otherwise Apollo + // would throw "No more mocked responses". + const ann = makeSpan("local"); + const { result } = renderHook( + () => ({ + create: useCreateUrlAnnotation(), + state: usePdfAnnotations(), + }), + { wrapper: buildWrapper({ withCorpus: false }) } + ); + + await act(async () => { + await result.current.create(ann, "https://example.com"); + }); + + expect(result.current.state.pdfAnnotations.annotations).toHaveLength(0); + }); + + it("short-circuits when linkUrl is blank/whitespace", async () => { + // A trimmed-empty URL is treated identically to an omitted URL: the + // mutation never fires, and toast.warning is shown to the user. + const ann = makeSpan("local"); + const { result } = renderHook( + () => ({ + create: useCreateUrlAnnotation(), + state: usePdfAnnotations(), + }), + { wrapper: buildWrapper() } + ); + + await act(async () => { + await result.current.create(ann, " "); + }); + + expect(result.current.state.pdfAnnotations.annotations).toHaveLength(0); + }); + + it("adds the server-returned URL annotation on success", async () => { + const localAnn = makeSpan("local-tmp-url", 0, 5, "hello"); + const serverId = "server-url-1"; + const linkUrl = "https://example.com/a"; + + const mocks: MockedResponse[] = [ + { + request: { + query: REQUEST_ADD_URL_ANNOTATION, + variables: { + json: localAnn.json, + documentId: mockDocument.id, + corpusId: mockCorpus.id, + rawText: localAnn.rawText, + page: localAnn.page, + annotationType: LabelType.SpanLabel, + linkUrl, + }, + }, + result: { + data: { + addUrlAnnotation: { + ok: true, + message: "URL annotation created", + annotation: { + id: serverId, + page: 0, + rawText: "hello", + json: { start: 0, end: 5 }, + linkUrl, + annotationType: LabelType.SpanLabel, + annotationLabel: ocUrlLabel, + myPermissions: ["CAN_READ", "CAN_UPDATE", "CAN_REMOVE"], + isPublic: false, + }, + }, + }, + }, + }, + ]; + + const { result } = renderHook( + () => ({ + create: useCreateUrlAnnotation(), + state: usePdfAnnotations(), + }), + { wrapper: buildWrapper({ mocks }) } + ); + + await act(async () => { + await result.current.create(localAnn, linkUrl); + }); + + // Key contract: the annotation stored in state carries the server + // id and the linkUrl returned by the server. + const stored = result.current.state.pdfAnnotations.annotations; + expect(stored).toHaveLength(1); + expect(stored[0].id).toBe(serverId); + expect(stored[0].linkUrl).toBe(linkUrl); + expect(stored[0].annotationLabel.text).toBe(OC_URL_LABEL); + }); + + it("does not add to state when the server returns ok=false", async () => { + // Defence-in-depth: an unsafe URL slipping past client-side validation + // would be rejected by the server; the hook must NOT push anything + // into local state in that case. + const localAnn = makeSpan("local-tmp-url", 0, 5, "hello"); + const mocks: MockedResponse[] = [ + { + request: { + query: REQUEST_ADD_URL_ANNOTATION, + variables: { + json: localAnn.json, + documentId: mockDocument.id, + corpusId: mockCorpus.id, + rawText: localAnn.rawText, + page: localAnn.page, + annotationType: LabelType.SpanLabel, + linkUrl: "https://example.com", + }, + }, + result: { + data: { + addUrlAnnotation: { + ok: false, + message: "link_url must be an http(s):// URL", + annotation: null, + }, + }, + }, + }, + ]; + + const { result } = renderHook( + () => ({ + create: useCreateUrlAnnotation(), + state: usePdfAnnotations(), + }), + { wrapper: buildWrapper({ mocks }) } + ); + + await act(async () => { + await result.current.create(localAnn, "https://example.com"); + }); + + expect(result.current.state.pdfAnnotations.annotations).toHaveLength(0); + }); + }); + describe("useUpdateAnnotation", () => { it("replaces the annotation in state on a successful mutation", async () => { const existing = makeSpan("ann-1", 0, 5, "hello"); diff --git a/frontend/src/components/annotator/utils/__tests__/urlAnnotation.test.ts b/frontend/src/components/annotator/utils/__tests__/urlAnnotation.test.ts new file mode 100644 index 000000000..ebaad6b93 --- /dev/null +++ b/frontend/src/components/annotator/utils/__tests__/urlAnnotation.test.ts @@ -0,0 +1,209 @@ +/** + * Unit tests for ``urlAnnotation.ts``: + * + * - ``isUrlAnnotation`` returns true only when the annotation carries the + * ``OC_URL`` label AND a non-empty ``linkUrl``. + * - ``openAnnotationUrl`` opens http(s) URLs via ``window.open`` with + * noopener/noreferrer, navigates site-relative paths in the current tab, + * and refuses dangerous schemes (``javascript:``, ``data:``) even when + * the model layer would normally have stripped them. + * + * These tests pin the click-time defence: the renderer must never invoke + * ``window.open`` with attacker-controlled schemes, even if a stale cached + * annotation slipped through the model-level allow-list. + */ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; + +import { OC_URL_LABEL } from "../../../../assets/configurations/constants"; +import { LabelType } from "../../types/enums"; +import { PermissionTypes } from "../../../types"; +import { + ServerSpanAnnotation, + ServerTokenAnnotation, +} from "../../types/annotations"; +import { isUrlAnnotation, openAnnotationUrl } from "../urlAnnotation"; +import type { AnnotationLabelType } from "../../../../types/graphql-api"; + +const ocUrlLabel: AnnotationLabelType = { + id: "label-url", + text: OC_URL_LABEL, + color: "#2563EB", + description: "url", + labelType: LabelType.SpanLabel, + icon: "link" as any, +}; + +const otherLabel: AnnotationLabelType = { + id: "label-other", + text: "Other", + color: "#333333", + description: "", + labelType: LabelType.SpanLabel, + icon: "tag" as any, +}; + +function makeSpan( + label: AnnotationLabelType, + linkUrl: string | null | undefined +): ServerSpanAnnotation { + return new ServerSpanAnnotation( + 0, + label, + "hello", + false, + { start: 0, end: 5 }, + [PermissionTypes.CAN_READ], + false, + false, + false, + "ann-1", + undefined, + linkUrl + ); +} + +function makeToken( + label: AnnotationLabelType, + linkUrl: string | null | undefined +): ServerTokenAnnotation { + return new ServerTokenAnnotation( + 0, + label, + "hello", + false, + { 0: { bounds: {}, rawText: "hello", tokensJsons: [] } } as any, + [PermissionTypes.CAN_READ], + false, + false, + false, + "ann-1", + undefined, + linkUrl + ); +} + +describe("isUrlAnnotation", () => { + it("returns true when label is OC_URL and linkUrl is non-empty", () => { + expect(isUrlAnnotation(makeSpan(ocUrlLabel, "https://example.com"))).toBe( + true + ); + expect(isUrlAnnotation(makeToken(ocUrlLabel, "https://example.com"))).toBe( + true + ); + }); + + it("returns false when label is OC_URL but linkUrl is missing", () => { + // Common while the author is editing — the annotation is not yet + // clickable so click handlers must keep selection behaviour. + expect(isUrlAnnotation(makeSpan(ocUrlLabel, null))).toBe(false); + expect(isUrlAnnotation(makeSpan(ocUrlLabel, undefined))).toBe(false); + expect(isUrlAnnotation(makeSpan(ocUrlLabel, ""))).toBe(false); + expect(isUrlAnnotation(makeSpan(ocUrlLabel, " "))).toBe(false); + }); + + it("returns false when linkUrl is present but label is not OC_URL", () => { + // Defence in depth: the existence of a linkUrl alone does NOT make an + // annotation clickable; the label must opt-in. + expect(isUrlAnnotation(makeSpan(otherLabel, "https://example.com"))).toBe( + false + ); + }); +}); + +describe("openAnnotationUrl", () => { + let originalOpen: typeof window.open; + let originalLocation: Location; + let openSpy: ReturnType; + let assignSpy: ReturnType; + + beforeEach(() => { + originalOpen = window.open; + openSpy = vi.fn(); + window.open = openSpy as unknown as typeof window.open; + + // jsdom's ``window.location`` is non-configurable per-property. Replace + // the whole object via Object.defineProperty on window — that descriptor + // IS configurable — so we can inject a recording stub for ``assign``. + originalLocation = window.location; + assignSpy = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + writable: true, + value: { + ...originalLocation, + assign: assignSpy, + }, + }); + }); + + afterEach(() => { + window.open = originalOpen; + Object.defineProperty(window, "location", { + configurable: true, + writable: true, + value: originalLocation, + }); + }); + + it("opens https URLs in a new tab with noopener,noreferrer", () => { + const ok = openAnnotationUrl(makeSpan(ocUrlLabel, "https://example.com")); + expect(ok).toBe(true); + expect(openSpy).toHaveBeenCalledWith( + "https://example.com", + "_blank", + "noopener,noreferrer" + ); + expect(assignSpy).not.toHaveBeenCalled(); + }); + + it("opens http URLs in a new tab with noopener,noreferrer", () => { + const ok = openAnnotationUrl(makeSpan(ocUrlLabel, "http://example.com")); + expect(ok).toBe(true); + expect(openSpy).toHaveBeenCalledTimes(1); + }); + + it("navigates site-relative paths in the current tab", () => { + // SPA-internal links must stay in the current tab so the router can + // resolve them; opening in a new tab would lose Apollo cache state. + const ok = openAnnotationUrl(makeSpan(ocUrlLabel, "/corpus/foo")); + expect(ok).toBe(true); + expect(assignSpy).toHaveBeenCalledWith("/corpus/foo"); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it("refuses to open javascript: URLs", () => { + // The model layer would already strip these, but the renderer is the + // last line of defence — never reflect attacker-controlled schemes. + const ok = openAnnotationUrl(makeSpan(ocUrlLabel, "javascript:alert(1)")); + expect(ok).toBe(false); + expect(openSpy).not.toHaveBeenCalled(); + expect(assignSpy).not.toHaveBeenCalled(); + }); + + it("refuses to open data: URLs", () => { + const ok = openAnnotationUrl( + makeSpan(ocUrlLabel, "data:text/html,") + ); + expect(ok).toBe(false); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it("refuses to open empty/missing URLs", () => { + expect(openAnnotationUrl(makeSpan(ocUrlLabel, ""))).toBe(false); + expect(openAnnotationUrl(makeSpan(ocUrlLabel, undefined))).toBe(false); + expect(openAnnotationUrl(makeSpan(ocUrlLabel, null))).toBe(false); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it("trims whitespace from valid URLs before opening", () => { + const ok = openAnnotationUrl( + makeSpan(ocUrlLabel, " https://example.com ") + ); + expect(ok).toBe(true); + expect(openSpy).toHaveBeenCalledWith( + "https://example.com", + "_blank", + "noopener,noreferrer" + ); + }); +}); diff --git a/frontend/tests/CreateUrlAnnotationModal.ct.tsx b/frontend/tests/CreateUrlAnnotationModal.ct.tsx new file mode 100644 index 000000000..40b448a87 --- /dev/null +++ b/frontend/tests/CreateUrlAnnotationModal.ct.tsx @@ -0,0 +1,233 @@ +import React from "react"; +import { test, expect } from "./utils/coverage"; +import { CreateUrlAnnotationModal } from "../src/components/annotator/components/modals/CreateUrlAnnotationModal"; +import { docScreenshot } from "./utils/docScreenshot"; + +/** + * Component coverage for CreateUrlAnnotationModal: + * - renders only when visible + * - shows the selected text as a read-only chip + * - rejects empty / unsafe URLs and surfaces an inline error + * - calls onConfirm with the trimmed URL on Create / Enter / Save + * - calls onCancel on Cancel + * - prefills the input on edit and toggles the header label + * + * The modal is the user's last client-side checkpoint before the URL is + * sent to the server; the validation cases below pin the allow-list + * (http(s):// + site-relative) that mirrors the backend. + */ + +test.describe("CreateUrlAnnotationModal", () => { + test("does not render when visible is false", async ({ mount, page }) => { + const component = await mount( + {}} + onConfirm={() => {}} + /> + ); + + // The modal title for the create path. If the modal is closed, the + // text must not be present in the DOM. + await expect(page.getByText("Add link")).toHaveCount(0); + + await component.unmount(); + }); + + test("renders the create header and selected-text chip", async ({ + mount, + page, + }) => { + const component = await mount( + {}} + onConfirm={() => {}} + /> + ); + + await expect(page.getByText("Add link")).toBeVisible({ timeout: 5000 }); + // The selected text is shown as context above the URL input. + await expect(page.getByText("hello world")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Create link" }) + ).toBeVisible(); + + await docScreenshot(page, "annotations--url-annotation--create-empty"); + + await component.unmount(); + }); + + test("renders the edit header and prefilled URL", async ({ mount, page }) => { + const component = await mount( + {}} + onConfirm={() => {}} + /> + ); + + await expect(page.getByText("Edit link target")).toBeVisible({ + timeout: 5000, + }); + // The save button label differs from the create variant. + await expect(page.getByRole("button", { name: "Save link" })).toBeVisible(); + // The input is prefilled with the initialUrl. + await expect(page.locator("#oc-url-input")).toHaveValue( + "https://example.com/existing" + ); + + await docScreenshot(page, "annotations--url-annotation--edit-prefilled"); + + await component.unmount(); + }); + + test("rejects empty URL with inline error", async ({ mount, page }) => { + let confirmed: string | null = null; + const component = await mount( + {}} + onConfirm={(url) => { + confirmed = url; + }} + /> + ); + + await expect( + page.getByRole("button", { name: "Create link" }) + ).toBeVisible(); + await page.getByRole("button", { name: "Create link" }).click(); + + await expect(page.getByText("URL is required.")).toBeVisible(); + // The callback must NOT have fired for an empty URL. + expect(confirmed).toBeNull(); + + await docScreenshot(page, "annotations--url-annotation--error-empty"); + + await component.unmount(); + }); + + test("rejects unsafe scheme with inline error", async ({ mount, page }) => { + let confirmed: string | null = null; + const component = await mount( + {}} + onConfirm={(url) => { + confirmed = url; + }} + /> + ); + + await page.locator("#oc-url-input").fill("javascript:alert(1)"); + await page.getByRole("button", { name: "Create link" }).click(); + + // Inline guidance mentions the allow-list. + await expect( + page.getByText( + "URL must start with http://, https://, or '/' (site-relative path)." + ) + ).toBeVisible(); + expect(confirmed).toBeNull(); + + await component.unmount(); + }); + + test("calls onConfirm with the trimmed URL on Create", async ({ + mount, + page, + }) => { + let confirmed: string | null = null; + const component = await mount( + {}} + onConfirm={(url) => { + confirmed = url; + }} + /> + ); + + // Surrounding whitespace is stripped before validation/onConfirm. + await page.locator("#oc-url-input").fill(" https://example.com/path "); + await page.getByRole("button", { name: "Create link" }).click(); + + await expect.poll(() => confirmed).toBe("https://example.com/path"); + + await component.unmount(); + }); + + test("calls onConfirm when Enter is pressed in the input", async ({ + mount, + page, + }) => { + let confirmed: string | null = null; + const component = await mount( + {}} + onConfirm={(url) => { + confirmed = url; + }} + /> + ); + + await page.locator("#oc-url-input").fill("https://example.com"); + await page.locator("#oc-url-input").press("Enter"); + + await expect.poll(() => confirmed).toBe("https://example.com"); + + await component.unmount(); + }); + + test("calls onCancel when Cancel is clicked", async ({ mount, page }) => { + let cancelled = false; + const component = await mount( + { + cancelled = true; + }} + onConfirm={() => {}} + /> + ); + + await page.getByRole("button", { name: "Cancel" }).click(); + await expect.poll(() => cancelled).toBe(true); + + await component.unmount(); + }); + + test("accepts site-relative paths", async ({ mount, page }) => { + // The allow-list mirrors the backend: site-relative paths starting + // with "/" are valid in addition to http(s) URLs. + let confirmed: string | null = null; + const component = await mount( + {}} + onConfirm={(url) => { + confirmed = url; + }} + /> + ); + + await page.locator("#oc-url-input").fill("/corpus/foo/doc/bar"); + await page.getByRole("button", { name: "Create link" }).click(); + + await expect.poll(() => confirmed).toBe("/corpus/foo/doc/bar"); + + await component.unmount(); + }); +}); From eb2521ff3eb3665634cd6b9bebbac2abd719d02e Mon Sep 17 00:00:00 2001 From: JSv4 Date: Wed, 13 May 2026 21:08:47 -0500 Subject: [PATCH 05/22] Exclude test_url_annotation from mypy baseline (matches sibling test files) --- mypy.ini | 3 ++ .../tests/test_url_annotation.py | 45 +++++++------------ 2 files changed, 18 insertions(+), 30 deletions(-) diff --git a/mypy.ini b/mypy.ini index 754f0cc82..6ab828416 100644 --- a/mypy.ini +++ b/mypy.ini @@ -167,6 +167,9 @@ ignore_errors = True [mypy-opencontractserver.tests.test_add_annotation_idor] ignore_errors = True +[mypy-opencontractserver.tests.test_url_annotation] +ignore_errors = True + [mypy-opencontractserver.tests.test_admin] ignore_errors = True diff --git a/opencontractserver/tests/test_url_annotation.py b/opencontractserver/tests/test_url_annotation.py index 2438a2e27..d6c3c2f55 100644 --- a/opencontractserver/tests/test_url_annotation.py +++ b/opencontractserver/tests/test_url_annotation.py @@ -132,24 +132,21 @@ class ValidateLinkUrlTests(TestCase): def test_empty_string_is_noop(self): # Empty / None must return cleanly so the column can stay NULL. - self.assertIsNone(validate_link_url("")) - - def test_none_is_noop(self): - # None defends against callers that forget the empty-string normalisation. - # ``validate_link_url`` is typed for str but treats falsy as a no-op. - self.assertIsNone(validate_link_url("")) + # ``validate_link_url`` returns None on accept; the assertion below + # exists purely to fail loudly if a future change makes it raise. + validate_link_url("") def test_http_url_is_allowed(self): - # Sanity: plain http URL must be accepted. - self.assertIsNone(validate_link_url("http://example.com")) + # Sanity: plain http URL must be accepted (no exception raised). + validate_link_url("http://example.com") def test_https_url_is_allowed(self): - # Sanity: plain https URL must be accepted. - self.assertIsNone(validate_link_url("https://example.com/path?x=1")) + # Sanity: plain https URL must be accepted (no exception raised). + validate_link_url("https://example.com/path?x=1") def test_site_relative_path_is_allowed(self): # Site-relative URLs allow internal SPA navigation (e.g. /corpus/foo). - self.assertIsNone(validate_link_url("/corpus/foo")) + validate_link_url("/corpus/foo") def test_javascript_scheme_is_rejected(self): with self.assertRaises(ValidationError) as cm: @@ -173,7 +170,7 @@ def test_ftp_scheme_is_rejected(self): def test_case_insensitive_scheme(self): # Schemes are compared lowercased, so casing must not bypass the check. - self.assertIsNone(validate_link_url("HTTPS://example.com")) + validate_link_url("HTTPS://example.com") with self.assertRaises(ValidationError): validate_link_url("JavaScript:alert(1)") @@ -255,9 +252,7 @@ def setUp(self): set_permissions_for_obj_to_user( self.owner, self.document, [PermissionTypes.CRUD] ) - set_permissions_for_obj_to_user( - self.owner, self.corpus, [PermissionTypes.CRUD] - ) + set_permissions_for_obj_to_user(self.owner, self.corpus, [PermissionTypes.CRUD]) self.client = Client(schema) @@ -308,9 +303,7 @@ def test_second_url_annotation_reuses_oc_url_label(self): # second OC_URL label — ensure_label_and_labelset is idempotent. self._execute(user=self.owner, link_url="https://example.com/a") self._execute(user=self.owner, link_url="https://example.com/b") - self.assertEqual( - AnnotationLabel.objects.filter(text=OC_URL_LABEL).count(), 1 - ) + self.assertEqual(AnnotationLabel.objects.filter(text=OC_URL_LABEL).count(), 1) def test_rejects_javascript_scheme(self): # Defence in depth: the GraphQL layer must refuse unsafe schemes @@ -371,9 +364,7 @@ def setUp(self): set_permissions_for_obj_to_user( self.owner, self.document, [PermissionTypes.CRUD] ) - set_permissions_for_obj_to_user( - self.owner, self.corpus, [PermissionTypes.CRUD] - ) + set_permissions_for_obj_to_user(self.owner, self.corpus, [PermissionTypes.CRUD]) self.client = Client(schema) def _execute(self, *, link_url, user=None): @@ -382,14 +373,10 @@ def _execute(self, *, link_url, user=None): variables={ "corpusId": to_global_id("CorpusType", self.corpus.pk), "documentId": to_global_id("DocumentType", self.document.pk), - "annotationLabelId": to_global_id( - "AnnotationLabelType", self.label.pk - ), + "annotationLabelId": to_global_id("AnnotationLabelType", self.label.pk), "page": 0, "rawText": "anchor", - "json": { - "0": {"bounds": {}, "rawText": "anchor", "tokensJsons": []} - }, + "json": {"0": {"bounds": {}, "rawText": "anchor", "tokensJsons": []}}, "annotationType": "TOKEN_LABEL", "linkUrl": link_url, }, @@ -443,9 +430,7 @@ def setUp(self): set_permissions_for_obj_to_user( self.owner, self.document, [PermissionTypes.CRUD] ) - set_permissions_for_obj_to_user( - self.owner, self.corpus, [PermissionTypes.CRUD] - ) + set_permissions_for_obj_to_user(self.owner, self.corpus, [PermissionTypes.CRUD]) self.annotation = Annotation.objects.create( page=0, From 53d90c71586ab507dfed29483f95ecc891bff84c Mon Sep 17 00:00:00 2001 From: JSv4 Date: Wed, 13 May 2026 21:12:38 -0500 Subject: [PATCH 06/22] Address review: extract OC_URL label magic values, dedupe link_url validation in clean/save Constants: - Add OC_URL_LABEL_COLOR / OC_URL_LABEL_ICON / OC_URL_LABEL_DESCRIPTION in opencontractserver/constants/annotations.py and use them in AddUrlAnnotation.mutate (no more magic values). Model: - Annotation.clean() now validates link_url BEFORE the JSON-validation early-return so the check is always run regardless of the VALIDATE_ANNOTATION_JSON flag. - Annotation.save() only re-validates link_url when clean() was skipped (VALIDATE_ANNOTATION_JSON=False), removing the redundant double-call. --- config/graphql/annotation_mutations.py | 20 +++++++++---- ...otations--url-annotation--create-empty.png | Bin 0 -> 20928 bytes ...ations--url-annotation--edit-prefilled.png | Bin 0 -> 20926 bytes ...notations--url-annotation--error-empty.png | Bin 0 -> 18474 bytes opencontractserver/annotations/models.py | 27 +++++++++++------- opencontractserver/constants/annotations.py | 7 +++++ 6 files changed, 37 insertions(+), 17 deletions(-) create mode 100644 docs/assets/images/screenshots/auto/annotations--url-annotation--create-empty.png create mode 100644 docs/assets/images/screenshots/auto/annotations--url-annotation--edit-prefilled.png create mode 100644 docs/assets/images/screenshots/auto/annotations--url-annotation--error-empty.png diff --git a/config/graphql/annotation_mutations.py b/config/graphql/annotation_mutations.py index a5c2ec199..3cf4741aa 100644 --- a/config/graphql/annotation_mutations.py +++ b/config/graphql/annotation_mutations.py @@ -27,7 +27,12 @@ Relationship, validate_link_url, ) -from opencontractserver.constants.annotations import OC_URL_LABEL +from opencontractserver.constants.annotations import ( + OC_URL_LABEL, + OC_URL_LABEL_COLOR, + OC_URL_LABEL_DESCRIPTION, + OC_URL_LABEL_ICON, +) from opencontractserver.corpuses.models import Corpus from opencontractserver.documents.models import Document, DocumentPath from opencontractserver.feedback.models import UserFeedback @@ -420,15 +425,18 @@ def mutate( document, corpus = parents with transaction.atomic(): - # ``ensure_label_and_labelset`` is idempotent; creates the - # OC_URL label on first use, returns the existing one thereafter. + # ``ensure_label_and_labelset`` is idempotent per (text, label_type). + # PDF (TOKEN_LABEL) and text (SPAN_LABEL) documents each get their + # own OC_URL row — the lookup filters on both fields, so flipping + # types between calls cannot return a label of the wrong shape to + # the renderer. label = corpus.ensure_label_and_labelset( label_text=OC_URL_LABEL, creator_id=user.pk, label_type=annotation_type.value, - color="#2563EB", - icon="link", - description="Click-through hyperlink annotation", + color=OC_URL_LABEL_COLOR, + icon=OC_URL_LABEL_ICON, + description=OC_URL_LABEL_DESCRIPTION, ) annotation = Annotation( diff --git a/docs/assets/images/screenshots/auto/annotations--url-annotation--create-empty.png b/docs/assets/images/screenshots/auto/annotations--url-annotation--create-empty.png new file mode 100644 index 0000000000000000000000000000000000000000..adcbc78eee9765bd6da8368b8cd99dee25f60503 GIT binary patch literal 20928 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 literal 0 HcmV?d00001 diff --git a/docs/assets/images/screenshots/auto/annotations--url-annotation--edit-prefilled.png b/docs/assets/images/screenshots/auto/annotations--url-annotation--edit-prefilled.png new file mode 100644 index 0000000000000000000000000000000000000000..01772e36619e972da61beb378d4c59aec1745598 GIT binary patch literal 20926 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 T{8J%gpa_GftDnm{r-UW|;ZB_1 literal 0 HcmV?d00001 diff --git a/docs/assets/images/screenshots/auto/annotations--url-annotation--error-empty.png b/docs/assets/images/screenshots/auto/annotations--url-annotation--error-empty.png new file mode 100644 index 0000000000000000000000000000000000000000..62d72f0c6c24cfb8e889c8c8ab29988f2a6f8df5 GIT binary patch literal 18474 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 literal 0 HcmV?d00001 diff --git a/opencontractserver/annotations/models.py b/opencontractserver/annotations/models.py index 550801aff..b9da887a5 100644 --- a/opencontractserver/annotations/models.py +++ b/opencontractserver/annotations/models.py @@ -1092,6 +1092,14 @@ def clean(self) -> None: # noqa: C901 (complexity – kept minimal) super().clean() + # Validate link_url scheme ALWAYS — link_url is reflected in a + # click handler, so unsafe schemes (e.g. ``javascript:``) must be + # rejected before persistence even when ``VALIDATE_ANNOTATION_JSON`` + # is disabled. Run before the early-return below so the check + # cannot be bypassed by toggling that flag in production. + if self.link_url: + validate_link_url(self.link_url) + from django.conf import settings # local to avoid global import cost should_validate: bool = bool( @@ -1170,12 +1178,6 @@ def clean(self) -> None: # noqa: C901 (complexity – kept minimal) } ) - # Validate link_url scheme (always runs, even when JSON validation - # is disabled — link_url is reflected in clickable UI, so unsafe - # schemes like ``javascript:`` must be rejected before persistence). - if self.link_url: - validate_link_url(self.link_url) - # Validate mutual exclusivity of document vs structural_set has_document = self.document_id is not None has_structural_set = getattr(self, "structural_set_id", None) is not None @@ -1211,12 +1213,15 @@ def save(self, *args: Any, **kwargs: Any) -> None: from django.conf import settings if getattr(settings, "VALIDATE_ANNOTATION_JSON", settings.DEBUG): - # Ensure that `clean()` is executed even if external callers forget. + # Ensure that `clean()` is executed even if external callers + # forget. ``clean()`` already validates ``link_url`` ALWAYS + # (before its early-return), so a separate call here would + # be redundant. self.clean() - - # link_url validation always runs, regardless of the JSON-validation - # flag, because the URL is reflected directly into a click handler. - if self.link_url: + elif self.link_url: + # ``clean()`` was skipped above, but link_url must still be + # validated — it is reflected in a click handler so unsafe + # schemes like ``javascript:`` must never reach persistence. validate_link_url(self.link_url) # Auto-compact annotation JSON to v2 format on save (lazy migration). diff --git a/opencontractserver/constants/annotations.py b/opencontractserver/constants/annotations.py index 6e9492a12..99fff76ca 100644 --- a/opencontractserver/constants/annotations.py +++ b/opencontractserver/constants/annotations.py @@ -17,6 +17,13 @@ # frontend opens when the annotation is clicked, turning highlighted text into # a navigable hyperlink. OC_URL_LABEL = "OC_URL" +# Default presentation for the auto-created OC_URL label. Keeping these as +# constants (rather than inline magic values in the mutation) means a future +# theme change updates both backend-seeded labels and frontend renderers +# from the same source of truth. +OC_URL_LABEL_COLOR = "#2563EB" +OC_URL_LABEL_ICON = "link" +OC_URL_LABEL_DESCRIPTION = "Click-through hyperlink annotation" # Maximum number of entries allowed in a single create_document_index call. DOCUMENT_ANNOTATION_INDEX_LIMIT = 500 From 1379ac853d8f2deab47af45b877b058224fa9015 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Wed, 13 May 2026 21:15:35 -0500 Subject: [PATCH 07/22] Replace 'as any' with 'as unknown as ' in urlAnnotation tests to keep any-baseline gate --- .../annotator/utils/__tests__/urlAnnotation.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/annotator/utils/__tests__/urlAnnotation.test.ts b/frontend/src/components/annotator/utils/__tests__/urlAnnotation.test.ts index ebaad6b93..468b4e1f9 100644 --- a/frontend/src/components/annotator/utils/__tests__/urlAnnotation.test.ts +++ b/frontend/src/components/annotator/utils/__tests__/urlAnnotation.test.ts @@ -24,13 +24,15 @@ import { import { isUrlAnnotation, openAnnotationUrl } from "../urlAnnotation"; import type { AnnotationLabelType } from "../../../../types/graphql-api"; +// SemanticICONS unions are unwieldy in tests; cast via ``unknown`` once +// at the constant boundary so the rest of the file stays well-typed. const ocUrlLabel: AnnotationLabelType = { id: "label-url", text: OC_URL_LABEL, color: "#2563EB", description: "url", labelType: LabelType.SpanLabel, - icon: "link" as any, + icon: "link" as unknown as AnnotationLabelType["icon"], }; const otherLabel: AnnotationLabelType = { @@ -39,7 +41,7 @@ const otherLabel: AnnotationLabelType = { color: "#333333", description: "", labelType: LabelType.SpanLabel, - icon: "tag" as any, + icon: "tag" as unknown as AnnotationLabelType["icon"], }; function makeSpan( @@ -71,7 +73,9 @@ function makeToken( label, "hello", false, - { 0: { bounds: {}, rawText: "hello", tokensJsons: [] } } as any, + { + 0: { bounds: {}, rawText: "hello", tokensJsons: [] }, + } as unknown as Record, [PermissionTypes.CAN_READ], false, false, From 1624a2600df4f6f5ee0623a8fe889d35cbbfc443 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Wed, 13 May 2026 21:19:07 -0500 Subject: [PATCH 08/22] =?UTF-8?q?Cover=20useCreateUrlAnnotation=20throw=20?= =?UTF-8?q?path=20(network=20error=20=E2=86=92=20toast,=20no=20state=20cha?= =?UTF-8?q?nge)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/__tests__/AnnotationHooks.test.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/frontend/src/components/annotator/hooks/__tests__/AnnotationHooks.test.tsx b/frontend/src/components/annotator/hooks/__tests__/AnnotationHooks.test.tsx index 611857189..2167f679e 100644 --- a/frontend/src/components/annotator/hooks/__tests__/AnnotationHooks.test.tsx +++ b/frontend/src/components/annotator/hooks/__tests__/AnnotationHooks.test.tsx @@ -589,6 +589,45 @@ describe("AnnotationHooks", () => { expect(stored[0].annotationLabel.text).toBe(OC_URL_LABEL); }); + it("falls back to toast.error when the mutation throws", async () => { + // Defence in depth: a network error or thrown Apollo error must NOT + // leave the user staring at an "unsaved changes" indicator with + // nothing in state. The hook swallows the error after toasting. + const localAnn = makeSpan("local-throws", 0, 5, "hello"); + const mocks: MockedResponse[] = [ + { + request: { + query: REQUEST_ADD_URL_ANNOTATION, + variables: { + json: localAnn.json, + documentId: mockDocument.id, + corpusId: mockCorpus.id, + rawText: localAnn.rawText, + page: localAnn.page, + annotationType: LabelType.SpanLabel, + linkUrl: "https://example.com", + }, + }, + error: new Error("network exploded"), + }, + ]; + + const { result } = renderHook( + () => ({ + create: useCreateUrlAnnotation(), + state: usePdfAnnotations(), + }), + { wrapper: buildWrapper({ mocks }) } + ); + + await act(async () => { + await result.current.create(localAnn, "https://example.com"); + }); + + // No annotation added on the throw path. + expect(result.current.state.pdfAnnotations.annotations).toHaveLength(0); + }); + it("does not add to state when the server returns ok=false", async () => { // Defence-in-depth: an unsafe URL slipping past client-side validation // would be rejected by the server; the hook must NOT push anything From 869a7dfbbb4c6e2a78adfa2b6bde549967de6b8b Mon Sep 17 00:00:00 2001 From: JSv4 Date: Wed, 13 May 2026 21:27:05 -0500 Subject: [PATCH 09/22] Address review: fix URLField validator conflict, narrow exception catch - Annotation.link_url is now CharField (was URLField). URLField attaches Django's URLValidator which runs at clean_fields() time and rejects site-relative paths like /corpus/foo before our own validate_link_url (in clean()) gets a chance to allow them. Migration 0072 updated to the same shape. - AddAnnotation / AddUrlAnnotation catch ValidationError instead of bare Exception, and surface a clean human-readable message via _format_link_url_error (extracts the dict-detail's first message instead of the noisy ["{'link_url': ['...']}"] repr). - Empty-string normalisation comment clarified on AddAnnotation so the link_url or None is no longer dead code on re-read. --- config/graphql/annotation_mutations.py | 32 ++++++++++++++++--- .../migrations/0072_annotation_link_url.py | 2 +- opencontractserver/annotations/models.py | 11 ++++--- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/config/graphql/annotation_mutations.py b/config/graphql/annotation_mutations.py index 3cf4741aa..32b7ed4cc 100644 --- a/config/graphql/annotation_mutations.py +++ b/config/graphql/annotation_mutations.py @@ -5,7 +5,7 @@ import logging import graphene -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db import transaction from graphene.types.generic import GenericScalar from graphql_jwt.decorators import login_required @@ -222,6 +222,21 @@ def mutate(root, info, annotation_id, comment=None) -> "ApproveAnnotation": ) +def _format_link_url_error(exc: ValidationError) -> str: + """Surface a stable, human-readable link_url validation error. + + ``str(ValidationError({"link_url": "..."}))`` returns a Python + ``[" {'link_url': ['...']} "]`` string that leaks internal structure. + Pull the first message off the dict so the user sees a clean sentence. + """ + detail = getattr(exc, "message_dict", None) + if detail: + messages = detail.get("link_url", []) or [] + if messages: + return str(messages[0]) + return "link_url failed validation." + + def _resolve_annotation_parents( user, corpus_pk: int | str, document_pk: int | str ) -> tuple["Document", "Corpus"] | None: @@ -321,8 +336,10 @@ def mutate( if link_url: try: validate_link_url(link_url) - except Exception as exc: - return AddAnnotation(ok=False, annotation=None, message=str(exc)) + except ValidationError as exc: + return AddAnnotation( + ok=False, annotation=None, message=_format_link_url_error(exc) + ) parents = _resolve_annotation_parents(user, corpus_pk, document_pk) if parents is None: @@ -343,6 +360,9 @@ def mutate( creator=user, json=json, annotation_type=annotation_type.value, + # Normalise empty string to None so the column ends up NULL + # (the ``if link_url:`` guard above only protects the validator + # call, not the persisted value). link_url=link_url or None, ) annotation.save() @@ -412,8 +432,10 @@ def mutate( try: validate_link_url(link_url) - except Exception as exc: - return AddUrlAnnotation(ok=False, annotation=None, message=str(exc)) + except ValidationError as exc: + return AddUrlAnnotation( + ok=False, annotation=None, message=_format_link_url_error(exc) + ) parents = _resolve_annotation_parents(user, corpus_pk, document_pk) if parents is None: diff --git a/opencontractserver/annotations/migrations/0072_annotation_link_url.py b/opencontractserver/annotations/migrations/0072_annotation_link_url.py index c039b5464..9fae0e140 100644 --- a/opencontractserver/annotations/migrations/0072_annotation_link_url.py +++ b/opencontractserver/annotations/migrations/0072_annotation_link_url.py @@ -11,7 +11,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name="annotation", name="link_url", - field=models.URLField( + field=models.CharField( blank=True, help_text=( "Target URL opened when the annotation is clicked. " diff --git a/opencontractserver/annotations/models.py b/opencontractserver/annotations/models.py index b9da887a5..961dc87b2 100644 --- a/opencontractserver/annotations/models.py +++ b/opencontractserver/annotations/models.py @@ -1002,10 +1002,13 @@ class Annotation(BaseOCModel, HasEmbeddingMixin): structural = django.db.models.BooleanField(default=False) # Target URL for clickable-link annotations (used with the OC_URL label). - # Frontend opens this URL when the annotation is clicked. Restricted to - # http(s) and protocol-relative schemes in ``clean()`` to block - # ``javascript:`` and other dangerous schemes from reaching the renderer. - link_url = django.db.models.URLField( + # Frontend opens this URL when the annotation is clicked. Uses ``CharField`` + # — NOT ``URLField`` — because we accept site-relative paths (``/corpus/...``) + # in addition to absolute URLs, and ``URLField``'s built-in ``URLValidator`` + # rejects those at ``clean_fields()`` time before ``validate_link_url()`` + # gets a chance to run. The full allow-list (http(s)://, /…) is enforced + # by ``validate_link_url`` in ``clean()`` and ``save()``. + link_url = django.db.models.CharField( max_length=2048, null=True, blank=True, From 5f5c12a666f30f137c75ef009ba780e42c8e1c8a Mon Sep 17 00:00:00 2001 From: JSv4 Date: Wed, 13 May 2026 21:28:20 -0500 Subject: [PATCH 10/22] Merge duplicate lucide-react imports in TxtAnnotator --- .../annotator/renderers/txt/TxtAnnotator.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/annotator/renderers/txt/TxtAnnotator.tsx b/frontend/src/components/annotator/renderers/txt/TxtAnnotator.tsx index 3927bff06..76a532c6c 100644 --- a/frontend/src/components/annotator/renderers/txt/TxtAnnotator.tsx +++ b/frontend/src/components/annotator/renderers/txt/TxtAnnotator.tsx @@ -34,10 +34,17 @@ import { Label, LabelContainer, PaperContainer } from "./StyledComponents"; import RadialButtonCloud, { CloudButtonItem } from "./RadialButtonCloud"; import { hexToRgba } from "./utils"; import { useLocation } from "react-router-dom"; -import { Copy, ExternalLink, Link, Tag, X, AlertCircle } from "lucide-react"; +import { + Copy, + ExternalLink, + Link, + Link2, + Tag, + X, + AlertCircle, +} from "lucide-react"; import { isUrlAnnotation, openAnnotationUrl } from "../../utils/urlAnnotation"; import { CreateUrlAnnotationModal } from "../../components/modals/CreateUrlAnnotationModal"; -import { Link2 } from "lucide-react"; import { encodeTextBlock, textBlockFromSpan, From ef30fe96f6c0213ec94f7c842579d610760de58a Mon Sep 17 00:00:00 2001 From: JSv4 Date: Wed, 13 May 2026 21:35:37 -0500 Subject: [PATCH 11/22] Address round 3 review: DRY URL allow-list, nullable linkUrl type, named sentinel id - Export isSafeUrl from urlAnnotation.ts and use it in CreateUrlAnnotationModal so the http(s)/relative allow-list lives in a single place on the frontend (renderer click handler + author modal share the same predicate). - Tighten NewUrlAnnotationOutputType.annotation.linkUrl to string | null to match the GraphQL schema (server field is nullable). Aligns with RawServerAnnotationType.linkUrl. - Extract the placeholder OC_URL label sentinel id into PENDING_OC_URL_LABEL_ID in constants.ts (was hard-coded as "__pending_oc_url__" in SelectionLayer.tsx). Also use OC_URL_LABEL for the placeholder's text instead of the raw string. --- ...otations--url-annotation--create-empty.png | Bin 20928 -> 20596 bytes ...ations--url-annotation--edit-prefilled.png | Bin 20926 -> 18872 bytes ...notations--url-annotation--error-empty.png | Bin 18474 -> 18470 bytes .../src/assets/configurations/constants.ts | 6 ++++++ .../modals/CreateUrlAnnotationModal.tsx | 14 +++++++------- .../renderers/pdf/SelectionLayer.tsx | 6 ++++-- .../annotator/utils/urlAnnotation.ts | 7 ++++++- frontend/src/graphql/mutations.ts | 5 ++++- 8 files changed, 27 insertions(+), 11 deletions(-) diff --git a/docs/assets/images/screenshots/auto/annotations--url-annotation--create-empty.png b/docs/assets/images/screenshots/auto/annotations--url-annotation--create-empty.png index adcbc78eee9765bd6da8368b8cd99dee25f60503..4e91a75189dc23bf2a3ee3b47d0d95371ad29867 100644 GIT binary patch literal 20596 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 diff --git a/docs/assets/images/screenshots/auto/annotations--url-annotation--edit-prefilled.png b/docs/assets/images/screenshots/auto/annotations--url-annotation--edit-prefilled.png index 01772e36619e972da61beb378d4c59aec1745598..8ffab5b0b0a4a44ee9bb250b3feefa4db67d7b14 100644 GIT binary patch literal 18872 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%0D9=r<3urAbEAzuy7K=khJk7iwYDZBoxAdy# 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 T{8J%gpa_GftDnm{r-UW|;ZB_1 diff --git a/docs/assets/images/screenshots/auto/annotations--url-annotation--error-empty.png b/docs/assets/images/screenshots/auto/annotations--url-annotation--error-empty.png index 62d72f0c6c24cfb8e889c8c8ab29988f2a6f8df5..89f7ab1c0d544e05820553795bbb4c7c225b520b 100644 GIT binary patch literal 18470 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 Fe*?Qc4J!Zu literal 18474 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 diff --git a/frontend/src/assets/configurations/constants.ts b/frontend/src/assets/configurations/constants.ts index e4f881565..fee3df9a9 100644 --- a/frontend/src/assets/configurations/constants.ts +++ b/frontend/src/assets/configurations/constants.ts @@ -266,6 +266,12 @@ export const OC_SECTION_LABEL = "OC_SECTION"; // ``linkUrl`` field is opened on click. Keep in sync with // opencontractserver/constants/annotations.py. export const OC_URL_LABEL = "OC_URL"; +// Sentinel id used when constructing a placeholder OC_URL ``AnnotationLabel`` +// before the server has assigned a real id (e.g. when the user is creating +// a new link annotation from a selection). Surfaced as a named constant so +// downstream code that needs to recognise "this label is still pending" +// has a single source of truth instead of comparing against a raw string. +export const PENDING_OC_URL_LABEL_ID = "__pending_oc_url__"; // Document search/picker limits export const DOCUMENT_PICKER_SEARCH_LIMIT = 20; diff --git a/frontend/src/components/annotator/components/modals/CreateUrlAnnotationModal.tsx b/frontend/src/components/annotator/components/modals/CreateUrlAnnotationModal.tsx index 102039927..f2188b5f6 100644 --- a/frontend/src/components/annotator/components/modals/CreateUrlAnnotationModal.tsx +++ b/frontend/src/components/annotator/components/modals/CreateUrlAnnotationModal.tsx @@ -8,6 +8,8 @@ import { Button, } from "@os-legal/ui"; +import { isSafeUrl } from "../../utils/urlAnnotation"; + interface CreateUrlAnnotationModalProps { visible: boolean; /** Text the user selected; shown read-only for context. */ @@ -19,14 +21,12 @@ interface CreateUrlAnnotationModalProps { initialUrl?: string; } -const URL_PATTERN = /^(https?:\/\/.+|\/.+)$/i; - /** * Small modal that prompts the user for a target URL when turning a - * selection into an OC_URL link annotation. Validation mirrors the backend - * allow-list: http(s) absolute URLs or site-relative paths starting with - * "/". Anything else is rejected client-side; the server will also reject - * it as a defence-in-depth measure. + * selection into an OC_URL link annotation. Validation reuses ``isSafeUrl`` + * from ``urlAnnotation.ts`` — the same allow-list (http(s) absolute or + * site-relative ``/...``) used by the renderer click-handler and the + * backend ``validate_link_url`` helper, so the three checks cannot drift. */ export const CreateUrlAnnotationModal = ({ visible, @@ -53,7 +53,7 @@ export const CreateUrlAnnotationModal = ({ setError("URL is required."); return; } - if (!URL_PATTERN.test(trimmed)) { + if (!isSafeUrl(trimmed)) { setError( "URL must start with http://, https://, or '/' (site-relative path)." ); diff --git a/frontend/src/components/annotator/renderers/pdf/SelectionLayer.tsx b/frontend/src/components/annotator/renderers/pdf/SelectionLayer.tsx index a4a2bad12..250f6031f 100644 --- a/frontend/src/components/annotator/renderers/pdf/SelectionLayer.tsx +++ b/frontend/src/components/annotator/renderers/pdf/SelectionLayer.tsx @@ -36,6 +36,8 @@ import { } from "../../../../utils/textBlockEncoding"; import { clampMenuPosition } from "../../../../utils/layout"; import { + OC_URL_LABEL, + PENDING_OC_URL_LABEL_ID, SELECTION_MENU_COOLDOWN_MS, Z_INDEX, } from "../../../../assets/configurations/constants"; @@ -317,8 +319,8 @@ const SelectionLayer = ({ // and the label-type marker, so a synthetic OC_URL label placeholder // is sufficient — the backend resolves/creates the real label. const placeholder: AnnotationLabelType = { - id: "__pending_oc_url__", - text: "OC_URL", + id: PENDING_OC_URL_LABEL_ID, + text: OC_URL_LABEL, color: "#2563EB", labelType: LabelType.TokenLabel, } as AnnotationLabelType; diff --git a/frontend/src/components/annotator/utils/urlAnnotation.ts b/frontend/src/components/annotator/utils/urlAnnotation.ts index f43484a97..164bc7db8 100644 --- a/frontend/src/components/annotator/utils/urlAnnotation.ts +++ b/frontend/src/components/annotator/utils/urlAnnotation.ts @@ -34,9 +34,14 @@ export function isUrlAnnotation( * Allow-list mirrored from the backend (``Annotation.validate_link_url``) * so the renderer refuses to open dangerous schemes even if the database * was bypassed (e.g. via a stale cached annotation). + * + * Exported so authoring UIs (e.g. ``CreateUrlAnnotationModal``) can validate + * client-side input with the *same* rules — the allow-list lives in exactly + * one place on the frontend and one place on the backend. */ -function isSafeUrl(url: string): boolean { +export function isSafeUrl(url: string): boolean { const normalized = url.trim(); + if (normalized.length === 0) return false; return ( normalized.toLowerCase().startsWith("http://") || normalized.toLowerCase().startsWith("https://") || diff --git a/frontend/src/graphql/mutations.ts b/frontend/src/graphql/mutations.ts index c94816e60..bda7d02b9 100644 --- a/frontend/src/graphql/mutations.ts +++ b/frontend/src/graphql/mutations.ts @@ -1031,7 +1031,10 @@ export interface NewUrlAnnotationOutputType { page: number; rawText: string; json: MultipageAnnotationJson; - linkUrl: string; + // Server schema returns nullable String for ``link_url``. Even though + // ``addUrlAnnotation`` always requires a URL, narrowing this to + // ``string`` could mask a downstream issue if the API ever omits it. + linkUrl: string | null; annotationType: LabelType; annotationLabel: AnnotationLabelType; myPermissions: string[]; From 09671497d63251ed735680a1e7a881d4311eb96c Mon Sep 17 00:00:00 2001 From: JSv4 Date: Wed, 13 May 2026 21:45:46 -0500 Subject: [PATCH 12/22] Fix TxtAnnotator URL editor for OC_URL annotations without a set link_url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pencil-icon handler in TxtAnnotator routed by isUrlAnnotation, which requires both the OC_URL label AND a non-empty linkUrl. That left OC_URL annotations with an unset linkUrl (e.g. created via the generic addAnnotation path) trapped in the label editor with no way to attach a URL. Aligns the TxtAnnotator check with the PDF Selection check — route on the label text directly so the URL editor opens whenever the user is editing an OC_URL annotation, regardless of current linkUrl value. Also drops a redundant .toLowerCase() call in isSafeUrl by caching the lowered string once. --- ...otations--url-annotation--create-empty.png | Bin 20596 -> 20100 bytes ...ations--url-annotation--edit-prefilled.png | Bin 18872 -> 19788 bytes ...notations--url-annotation--error-empty.png | Bin 18470 -> 18103 bytes .../annotator/renderers/txt/TxtAnnotator.tsx | 17 ++++++++++++++--- .../annotator/utils/urlAnnotation.ts | 5 +++-- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/assets/images/screenshots/auto/annotations--url-annotation--create-empty.png b/docs/assets/images/screenshots/auto/annotations--url-annotation--create-empty.png index 4e91a75189dc23bf2a3ee3b47d0d95371ad29867..cc640e62d3910f4ff4d82d04513a70a6ca20b1de 100644 GIT binary patch literal 20100 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 literal 20596 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 Dqhpm6 literal 18872 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%0D9=r<3urAbEAzuy7K=khJk7iwYDZBoxAdy# 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 Fe*?Qc4J!Zu diff --git a/frontend/src/components/annotator/renderers/txt/TxtAnnotator.tsx b/frontend/src/components/annotator/renderers/txt/TxtAnnotator.tsx index 76a532c6c..ff1d3695f 100644 --- a/frontend/src/components/annotator/renderers/txt/TxtAnnotator.tsx +++ b/frontend/src/components/annotator/renderers/txt/TxtAnnotator.tsx @@ -58,7 +58,10 @@ import { HelpText, } from "../../components/SelectionActionMenu"; import { clampMenuPosition } from "../../../../utils/layout"; -import { Z_INDEX } from "../../../../assets/configurations/constants"; +import { + OC_URL_LABEL, + Z_INDEX, +} from "../../../../assets/configurations/constants"; import { OS_LEGAL_COLORS, chatSourceBlueAlpha, @@ -1168,14 +1171,22 @@ const TxtAnnotator: React.FC = ({ !annotation.structural && annotation.myPermissions.includes(PermissionTypes.CAN_UPDATE) ) { + // Use the label text (NOT ``isUrlAnnotation``) so the + // URL editor opens even when ``link_url`` is null/empty. + // ``isUrlAnnotation`` requires both the OC_URL label AND a + // non-empty linkUrl — if an OC_URL annotation has no URL yet + // (e.g. created via the generic ``addAnnotation``), the + // author must still be able to attach one. + const isOcUrlAnnotation = + annotation.annotationLabel?.text === OC_URL_LABEL; actions.push({ name: "edit", color: "#a3a3a3", - tooltip: isUrlAnnotation(annotation) + tooltip: isOcUrlAnnotation ? "Edit Link URL" : "Edit Annotation", onClick: () => { - if (isUrlAnnotation(annotation)) { + if (isOcUrlAnnotation) { setUrlEditAnnotation(annotation); } else { setAnnotationToEdit(annotation); diff --git a/frontend/src/components/annotator/utils/urlAnnotation.ts b/frontend/src/components/annotator/utils/urlAnnotation.ts index 164bc7db8..58508def9 100644 --- a/frontend/src/components/annotator/utils/urlAnnotation.ts +++ b/frontend/src/components/annotator/utils/urlAnnotation.ts @@ -42,9 +42,10 @@ export function isUrlAnnotation( export function isSafeUrl(url: string): boolean { const normalized = url.trim(); if (normalized.length === 0) return false; + const lower = normalized.toLowerCase(); return ( - normalized.toLowerCase().startsWith("http://") || - normalized.toLowerCase().startsWith("https://") || + lower.startsWith("http://") || + lower.startsWith("https://") || normalized.startsWith("/") ); } From 4e59bb841398e2bb573f5407cead29d1ee0b034b Mon Sep 17 00:00:00 2001 From: JSv4 Date: Wed, 13 May 2026 21:48:18 -0500 Subject: [PATCH 13/22] Persist link_url through V2 export/import + legacy ETL round-trip OC_URL annotations carry their target URL in Annotation.link_url. Without round-trip support, forking a corpus or exporting + re-importing silently drops every link target. This commit adds: - OpenContractsAnnotationPythonType.link_url (NotRequired Optional) to the export type contract. - Export side: structural V2 (export_v2.build_corpus_v2_zip) and legacy / non-structural (etl.build_document_export) both emit link_url when it is non-NULL. - Import side: structural V2 (import_v2.import_corpus_v2_from_bytes) and the bulk import path (importing.import_annotations) both restore link_url on the freshly-created Annotation rows. Falsy / missing values fall through as NULL, so existing corpora without link annotations are unaffected. --- opencontractserver/types/dicts.py | 4 ++++ opencontractserver/utils/etl.py | 2 ++ opencontractserver/utils/export_v2.py | 4 ++++ opencontractserver/utils/import_v2.py | 3 +++ opencontractserver/utils/importing.py | 4 ++++ 5 files changed, 17 insertions(+) diff --git a/opencontractserver/types/dicts.py b/opencontractserver/types/dicts.py index 3a8812ca3..243cc9ec8 100644 --- a/opencontractserver/types/dicts.py +++ b/opencontractserver/types/dicts.py @@ -241,6 +241,10 @@ class OpenContractsAnnotationPythonType(TypedDict): list[str] ] # ["TEXT"], ["IMAGE"], or ["TEXT", "IMAGE"] long_description: NotRequired[Optional[str]] + # Target URL for OC_URL clickable hyperlink annotations. Persisted on + # round-trip so a corpus full of link annotations doesn't silently lose + # all its targets through fork / V2-export / V2-import. + link_url: NotRequired[Optional[str]] class SpanAnnotation(TypedDict): diff --git a/opencontractserver/utils/etl.py b/opencontractserver/utils/etl.py index 964308d16..793bd03fb 100644 --- a/opencontractserver/utils/etl.py +++ b/opencontractserver/utils/etl.py @@ -412,6 +412,8 @@ def build_document_export( annot_export["content_modalities"] = annot.content_modalities if annot.long_description is not None: annot_export["long_description"] = annot.long_description + if annot.link_url: + annot_export["link_url"] = annot.link_url labelled_text.append(annot_export) # Span annotations ({start, end}) don't have page-keyed structure diff --git a/opencontractserver/utils/export_v2.py b/opencontractserver/utils/export_v2.py index 92b1479fc..95db431ac 100644 --- a/opencontractserver/utils/export_v2.py +++ b/opencontractserver/utils/export_v2.py @@ -107,6 +107,10 @@ def package_structural_annotation_set( } if annot.long_description is not None: annot_data["long_description"] = annot.long_description + # Carry the OC_URL ``link_url`` through round-trip so forked / + # re-imported corpora keep their clickable targets. + if annot.link_url: + annot_data["link_url"] = annot.link_url structural_annotations.append(annot_data) # Get structural relationships diff --git a/opencontractserver/utils/import_v2.py b/opencontractserver/utils/import_v2.py index a961499ab..6aa2039a4 100644 --- a/opencontractserver/utils/import_v2.py +++ b/opencontractserver/utils/import_v2.py @@ -140,6 +140,9 @@ def import_structural_annotation_set( page=annot_data.get("page", 0), json=annot_data.get("annotation_json", {}), annotation_type=annot_data.get("annotation_type", ""), + # Restore OC_URL ``link_url`` if it was exported. Falsy / + # missing values stay NULL on the column. + link_url=annot_data.get("link_url") or None, structural=True, creator=user_obj, ) diff --git a/opencontractserver/utils/importing.py b/opencontractserver/utils/importing.py index 7293d284d..069d28e7b 100644 --- a/opencontractserver/utils/importing.py +++ b/opencontractserver/utils/importing.py @@ -150,6 +150,10 @@ def import_annotations( annotation_type=final_annotation_type, structural=annotation_data.get("structural", False), content_modalities=annotation_data.get("content_modalities", []), + # OC_URL annotations carry a click-through ``link_url`` that + # must survive round-trip through bulk import. Falsy / + # missing values stay NULL on the column. + link_url=annotation_data.get("link_url") or None, ) ) parallel_old_ids.append(annotation_data.get("id")) From 633d6b51d19a013556f46727d079e61121c4a1f5 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Wed, 13 May 2026 21:58:56 -0500 Subject: [PATCH 14/22] Address round 4 review: feed filter, SPA navigation, constants drift, whitespace trim Bugs: - UnifiedContentFeed previously filtered out *every* annotation whose label started with the OC_ prefix to hide structural / system annotations. OC_URL is user-authored and SHOULD appear, so this silently removed every link annotation from the feed. Add an explicit carve-out for OC_URL_LABEL. - openAnnotationUrl now accepts an optional navigate callback so call sites can route site-relative paths through useNavigate() instead of window.location.assign (a hard reload that discards Apollo cache + router state). PDF (Selection.tsx) and text (TxtAnnotator.tsx) call sites threaded through. The no-arg overload still falls back to window.location.assign for non-router contexts. Hygiene: - Extract OC_URL_LABEL_COLOR constant and use it in SelectionLayer placeholder. Was a hard-coded "#2563EB" literal that could drift from the backend OC_URL_LABEL_COLOR Python constant. - Annotation.clean() and Annotation.save() now .strip() link_url before validating + persisting, so a direct API call that submits leading/trailing whitespace doesn't store a broken URL. --- .../src/assets/configurations/constants.ts | 5 ++++ .../display/components/Selection.tsx | 6 ++++- .../renderers/pdf/SelectionLayer.tsx | 3 ++- .../annotator/renderers/txt/TxtAnnotator.tsx | 9 ++++--- .../utils/__tests__/urlAnnotation.test.ts | 22 ++++++++++++--- .../annotator/utils/urlAnnotation.ts | 27 ++++++++++++++----- .../unified_feed/UnifiedContentFeed.tsx | 17 +++++++++--- opencontractserver/annotations/models.py | 7 +++++ 8 files changed, 78 insertions(+), 18 deletions(-) diff --git a/frontend/src/assets/configurations/constants.ts b/frontend/src/assets/configurations/constants.ts index fee3df9a9..14f46b7dd 100644 --- a/frontend/src/assets/configurations/constants.ts +++ b/frontend/src/assets/configurations/constants.ts @@ -272,6 +272,11 @@ export const OC_URL_LABEL = "OC_URL"; // downstream code that needs to recognise "this label is still pending" // has a single source of truth instead of comparing against a raw string. export const PENDING_OC_URL_LABEL_ID = "__pending_oc_url__"; +// Default presentation for the OC_URL label. Mirrors the backend constants +// (``opencontractserver/constants/annotations.py``) so the placeholder used +// before the server has assigned a real label, the renderer's hyperlink +// styling, and the auto-created server-side label all agree. +export const OC_URL_LABEL_COLOR = "#2563EB"; // Document search/picker limits export const DOCUMENT_PICKER_SEARCH_LIMIT = 20; diff --git a/frontend/src/components/annotator/display/components/Selection.tsx b/frontend/src/components/annotator/display/components/Selection.tsx index 825c3cd62..700bd5579 100644 --- a/frontend/src/components/annotator/display/components/Selection.tsx +++ b/frontend/src/components/annotator/display/components/Selection.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef } from "react"; import _ from "lodash"; import styled from "styled-components"; +import { useNavigate } from "react-router-dom"; import { ExternalLink, Pencil, Trash2 } from "lucide-react"; @@ -122,6 +123,7 @@ export const Selection: React.FC = ({ showInfo = true, }) => { const auth_token = useReactiveVar(authToken); + const navigate = useNavigate(); const [hovered, setHovered] = useState(false); const [isEditLabelModalVisible, setIsEditLabelModalVisible] = useState(false); const [isEditUrlModalVisible, setIsEditUrlModalVisible] = useState(false); @@ -233,7 +235,9 @@ export const Selection: React.FC = ({ !event?.metaKey && !event?.ctrlKey ) { - if (openAnnotationUrl(annotation)) return; + // Pass ``navigate`` so site-relative paths stay in the SPA instead + // of triggering a hard page reload that would blow away Apollo cache. + if (openAnnotationUrl(annotation, navigate)) return; } const current = selectedAnnotations.slice(0); diff --git a/frontend/src/components/annotator/renderers/pdf/SelectionLayer.tsx b/frontend/src/components/annotator/renderers/pdf/SelectionLayer.tsx index 250f6031f..e5c223e51 100644 --- a/frontend/src/components/annotator/renderers/pdf/SelectionLayer.tsx +++ b/frontend/src/components/annotator/renderers/pdf/SelectionLayer.tsx @@ -37,6 +37,7 @@ import { import { clampMenuPosition } from "../../../../utils/layout"; import { OC_URL_LABEL, + OC_URL_LABEL_COLOR, PENDING_OC_URL_LABEL_ID, SELECTION_MENU_COOLDOWN_MS, Z_INDEX, @@ -321,7 +322,7 @@ const SelectionLayer = ({ const placeholder: AnnotationLabelType = { id: PENDING_OC_URL_LABEL_ID, text: OC_URL_LABEL, - color: "#2563EB", + color: OC_URL_LABEL_COLOR, labelType: LabelType.TokenLabel, } as AnnotationLabelType; diff --git a/frontend/src/components/annotator/renderers/txt/TxtAnnotator.tsx b/frontend/src/components/annotator/renderers/txt/TxtAnnotator.tsx index ff1d3695f..a54bc1426 100644 --- a/frontend/src/components/annotator/renderers/txt/TxtAnnotator.tsx +++ b/frontend/src/components/annotator/renderers/txt/TxtAnnotator.tsx @@ -33,7 +33,7 @@ import { PermissionTypes, TextSearchSpanResult } from "../../../types"; import { Label, LabelContainer, PaperContainer } from "./StyledComponents"; import RadialButtonCloud, { CloudButtonItem } from "./RadialButtonCloud"; import { hexToRgba } from "./utils"; -import { useLocation } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import { Copy, ExternalLink, @@ -390,6 +390,7 @@ const TxtAnnotator: React.FC = ({ onAnnotationRefChange, }) => { const location = useLocation(); + const navigate = useNavigate(); const [hoveredSpanIndex, setHoveredSpanIndex] = useState(null); const [editModalOpen, setEditModalOpen] = useState(false); @@ -532,7 +533,8 @@ const TxtAnnotator: React.FC = ({ !event?.metaKey && !event?.ctrlKey ) { - if (openAnnotationUrl(annotation)) return; + // Pass ``navigate`` so site-relative paths stay in the SPA. + if (openAnnotationUrl(annotation, navigate)) return; } if (selectedAnnotations.includes(annotation.id)) { setSelectedAnnotations([]); @@ -1117,7 +1119,8 @@ const TxtAnnotator: React.FC = ({ const handleSpanClick = linkAnnotation ? (event: React.MouseEvent) => { if (event.shiftKey || event.metaKey || event.ctrlKey) return; - if (openAnnotationUrl(linkAnnotation)) { + // Pass ``navigate`` so site-relative paths stay in the SPA. + if (openAnnotationUrl(linkAnnotation, navigate)) { event.stopPropagation(); } } diff --git a/frontend/src/components/annotator/utils/__tests__/urlAnnotation.test.ts b/frontend/src/components/annotator/utils/__tests__/urlAnnotation.test.ts index 468b4e1f9..03e513b88 100644 --- a/frontend/src/components/annotator/utils/__tests__/urlAnnotation.test.ts +++ b/frontend/src/components/annotator/utils/__tests__/urlAnnotation.test.ts @@ -166,15 +166,31 @@ describe("openAnnotationUrl", () => { expect(openSpy).toHaveBeenCalledTimes(1); }); - it("navigates site-relative paths in the current tab", () => { - // SPA-internal links must stay in the current tab so the router can - // resolve them; opening in a new tab would lose Apollo cache state. + it("navigates site-relative paths via window.location.assign as fallback", () => { + // When no ``navigate`` callback is supplied, the helper falls back to + // a hard navigation. This keeps the API safe for non-router contexts. const ok = openAnnotationUrl(makeSpan(ocUrlLabel, "/corpus/foo")); expect(ok).toBe(true); expect(assignSpy).toHaveBeenCalledWith("/corpus/foo"); expect(openSpy).not.toHaveBeenCalled(); }); + it("uses the navigate callback when supplied for site-relative paths", () => { + // Preferred path: pass a ``useNavigate()`` callback from react-router-dom + // so the SPA router resolves the URL in place, preserving Apollo cache + // and component state. The hard ``window.location.assign`` fallback + // must NOT fire. + const navigateSpy = vi.fn(); + const ok = openAnnotationUrl( + makeSpan(ocUrlLabel, "/corpus/foo"), + navigateSpy + ); + expect(ok).toBe(true); + expect(navigateSpy).toHaveBeenCalledWith("/corpus/foo"); + expect(assignSpy).not.toHaveBeenCalled(); + expect(openSpy).not.toHaveBeenCalled(); + }); + it("refuses to open javascript: URLs", () => { // The model layer would already strip these, but the renderer is the // last line of defence — never reflect attacker-controlled schemes. diff --git a/frontend/src/components/annotator/utils/urlAnnotation.ts b/frontend/src/components/annotator/utils/urlAnnotation.ts index 58508def9..eb17dd1e5 100644 --- a/frontend/src/components/annotator/utils/urlAnnotation.ts +++ b/frontend/src/components/annotator/utils/urlAnnotation.ts @@ -51,23 +51,36 @@ export function isSafeUrl(url: string): boolean { } /** - * Open the annotation's ``linkUrl`` in a new tab. External http(s) - * targets use ``window.open`` with ``noopener,noreferrer`` so the opened - * page cannot reach back into the OpenContracts session. Site-relative - * paths navigate within the current tab so the SPA router can resolve - * them. + * Open the annotation's ``linkUrl``. + * + * External http(s) targets use ``window.open`` with + * ``noopener,noreferrer`` so the opened page cannot reach back into the + * OpenContracts session. + * + * Site-relative paths route through the supplied ``navigate`` callback + * (typically ``useNavigate()`` from react-router-dom) so the SPA router + * resolves them in place — preserving the Apollo cache and component + * state. If no ``navigate`` is supplied (e.g. when called from a context + * that lacks the router) the implementation falls back to + * ``window.location.assign`` as a hard navigation. Call sites should + * prefer the ``navigate`` form. * * Returns ``true`` when navigation was attempted, ``false`` when the URL * was missing or unsafe. */ export function openAnnotationUrl( - annotation: ServerTokenAnnotation | ServerSpanAnnotation + annotation: ServerTokenAnnotation | ServerSpanAnnotation, + navigate?: (to: string) => void ): boolean { const url = annotation.linkUrl; if (!url || !isSafeUrl(url)) return false; const normalized = url.trim(); if (normalized.startsWith("/")) { - window.location.assign(normalized); + if (navigate) { + navigate(normalized); + } else { + window.location.assign(normalized); + } } else { window.open(normalized, "_blank", "noopener,noreferrer"); } diff --git a/frontend/src/components/knowledge_base/document/unified_feed/UnifiedContentFeed.tsx b/frontend/src/components/knowledge_base/document/unified_feed/UnifiedContentFeed.tsx index 1e8929d38..df5ca3724 100644 --- a/frontend/src/components/knowledge_base/document/unified_feed/UnifiedContentFeed.tsx +++ b/frontend/src/components/knowledge_base/document/unified_feed/UnifiedContentFeed.tsx @@ -36,7 +36,10 @@ import { FetchMoreOnVisible } from "../../../widgets/infinite_scroll/FetchMoreOn import { ContentItemRenderer } from "./ContentItemRenderer"; import { RelationshipActionModal } from "./RelationshipActionModal"; import { useRelationshipActions } from "../../../annotator/hooks/useRelationshipActions"; -import { STRUCTURAL_LABEL_PREFIX } from "../../../../assets/configurations/constants"; +import { + OC_URL_LABEL, + STRUCTURAL_LABEL_PREFIX, +} from "../../../../assets/configurations/constants"; import { getCreatorDisplay } from "../../../../utils/userDisplay"; interface UnifiedContentFeedProps { @@ -256,8 +259,16 @@ export const UnifiedContentFeed: React.FC = ({ return; } - // Always hide OC_* prefixed annotations (platform-generated structural labels) - if (ann.annotationLabel.text?.startsWith(STRUCTURAL_LABEL_PREFIX)) { + // Always hide OC_* prefixed annotations (platform-generated structural + // labels like OC_SECTION / OC_EXTRACT_SOURCE) — EXCEPT for OC_URL, + // which is user-authored (it carries a click-through ``linkUrl`` + // anchored to highlighted text) and should appear in the feed + // alongside other user annotations. + const labelText = ann.annotationLabel.text; + if ( + labelText?.startsWith(STRUCTURAL_LABEL_PREFIX) && + labelText !== OC_URL_LABEL + ) { return; } diff --git a/opencontractserver/annotations/models.py b/opencontractserver/annotations/models.py index 961dc87b2..384e3cd52 100644 --- a/opencontractserver/annotations/models.py +++ b/opencontractserver/annotations/models.py @@ -1100,7 +1100,11 @@ def clean(self) -> None: # noqa: C901 (complexity – kept minimal) # rejected before persistence even when ``VALIDATE_ANNOTATION_JSON`` # is disabled. Run before the early-return below so the check # cannot be bypassed by toggling that flag in production. + # Strip surrounding whitespace so a value submitted with leading + # spaces (e.g. via a direct API call that didn't pre-trim) is + # stored canonically and the renderer doesn't produce a broken link. if self.link_url: + self.link_url = self.link_url.strip() validate_link_url(self.link_url) from django.conf import settings # local to avoid global import cost @@ -1225,6 +1229,9 @@ def save(self, *args: Any, **kwargs: Any) -> None: # ``clean()`` was skipped above, but link_url must still be # validated — it is reflected in a click handler so unsafe # schemes like ``javascript:`` must never reach persistence. + # Mirror the whitespace-stripping ``clean()`` performs so the + # column stays canonical regardless of which path persisted. + self.link_url = self.link_url.strip() validate_link_url(self.link_url) # Auto-compact annotation JSON to v2 format on save (lazy migration). From 182f558931ecf706773aac58e367b9ccc3239296 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Wed, 13 May 2026 22:06:53 -0500 Subject: [PATCH 15/22] Address round 5 review: tighten ok=true assertion, dedupe trim in openAnnotationUrl Test: - test_update_rejects_unsafe_link_url default-True on .get("ok") so a missing/absent updateAnnotation payload still fails the assertion. Silent absence is no longer treated as success. Drop the outer data-presence guard. Hygiene: - openAnnotationUrl trims the URL once and reuses the normalised form for both the safety check and navigation, instead of letting isSafeUrl trim again internally. --- .../components/annotator/utils/urlAnnotation.ts | 5 ++++- opencontractserver/tests/test_url_annotation.py | 16 +++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/annotator/utils/urlAnnotation.ts b/frontend/src/components/annotator/utils/urlAnnotation.ts index eb17dd1e5..db2cba452 100644 --- a/frontend/src/components/annotator/utils/urlAnnotation.ts +++ b/frontend/src/components/annotator/utils/urlAnnotation.ts @@ -73,8 +73,11 @@ export function openAnnotationUrl( navigate?: (to: string) => void ): boolean { const url = annotation.linkUrl; - if (!url || !isSafeUrl(url)) return false; + if (!url) return false; + // Trim once and reuse for the safety check and the actual navigation; + // ``isSafeUrl`` would otherwise trim again internally. const normalized = url.trim(); + if (!isSafeUrl(normalized)) return false; if (normalized.startsWith("/")) { if (navigate) { navigate(normalized); diff --git a/opencontractserver/tests/test_url_annotation.py b/opencontractserver/tests/test_url_annotation.py index d6c3c2f55..5f72f41fd 100644 --- a/opencontractserver/tests/test_url_annotation.py +++ b/opencontractserver/tests/test_url_annotation.py @@ -477,12 +477,14 @@ def test_update_rejects_unsafe_link_url(self): # ValidationError; the original value must remain. before = self.annotation.link_url result = self._execute(link_url="javascript:alert(1)") - # GraphQL surface: DRFMutation returns ok=False on validation error. - # The exact key path is mutation-specific; what matters is the row - # was NOT updated. + + # The row must NOT have been updated regardless of how the rejection + # surfaced (DRFMutation ok=False vs GraphQL-level error). self.annotation.refresh_from_db() self.assertEqual(self.annotation.link_url, before) - # The mutation should NOT have set ok=True - if "data" in result and result["data"]: - payload = result["data"].get("updateAnnotation") or {} - self.assertFalse(payload.get("ok", False)) + + # And the mutation must NOT report ok=True. Default to True in the + # ``.get`` so a missing/absent ``updateAnnotation`` payload still + # fails the assertion — silent absence is not success. + payload = (result.get("data") or {}).get("updateAnnotation") or {} + self.assertFalse(payload.get("ok", True)) From 6a3ca0774f08ff0d1d924c9f503ea07d7d626693 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Wed, 13 May 2026 22:19:08 -0500 Subject: [PATCH 16/22] Address round 6 review: protocol-relative open-redirect + whitespace-only normalisation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical security fix: - //evil.com is a protocol-relative URL — browsers resolve it as https://evil.com. It starts with / so the previous site-relative branch in validate_link_url (backend) and isSafeUrl (frontend) would accept it as safe, then navigate/window.location.assign would happily redirect to evil.com. Both now explicitly reject any string starting with // before falling through to the allow-list. Test gap: - Whitespace-only link_url (e.g. " ") was truthy as a Python string. Annotation.clean()/save() would strip it to "" and then validate_link_url("") returns clean — so the column ended up with an empty string instead of NULL. Both now collapse whitespace-only to None via self.link_url.strip() or None. Tests: - test_url_annotation.py: new tests for protocol-relative rejection and whitespace-only normalisation. - urlAnnotation.test.ts: new test for protocol-relative rejection. --- .../utils/__tests__/urlAnnotation.test.ts | 10 +++++ .../annotator/utils/urlAnnotation.ts | 4 ++ opencontractserver/annotations/models.py | 30 +++++++++----- .../tests/test_url_annotation.py | 39 +++++++++++++++++++ 4 files changed, 73 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/annotator/utils/__tests__/urlAnnotation.test.ts b/frontend/src/components/annotator/utils/__tests__/urlAnnotation.test.ts index 03e513b88..683332650 100644 --- a/frontend/src/components/annotator/utils/__tests__/urlAnnotation.test.ts +++ b/frontend/src/components/annotator/utils/__tests__/urlAnnotation.test.ts @@ -208,6 +208,16 @@ describe("openAnnotationUrl", () => { expect(openSpy).not.toHaveBeenCalled(); }); + it("refuses to open protocol-relative URLs (open-redirect guard)", () => { + // ``//evil.com`` starts with ``/`` but the browser would resolve it + // as ``https://evil.com`` — the site-relative branch must reject it + // so this open-redirect vector closes here at the renderer layer. + const ok = openAnnotationUrl(makeSpan(ocUrlLabel, "//evil.com")); + expect(ok).toBe(false); + expect(openSpy).not.toHaveBeenCalled(); + expect(assignSpy).not.toHaveBeenCalled(); + }); + it("refuses to open empty/missing URLs", () => { expect(openAnnotationUrl(makeSpan(ocUrlLabel, ""))).toBe(false); expect(openAnnotationUrl(makeSpan(ocUrlLabel, undefined))).toBe(false); diff --git a/frontend/src/components/annotator/utils/urlAnnotation.ts b/frontend/src/components/annotator/utils/urlAnnotation.ts index db2cba452..c0f3f7c32 100644 --- a/frontend/src/components/annotator/utils/urlAnnotation.ts +++ b/frontend/src/components/annotator/utils/urlAnnotation.ts @@ -42,6 +42,10 @@ export function isUrlAnnotation( export function isSafeUrl(url: string): boolean { const normalized = url.trim(); if (normalized.length === 0) return false; + // Reject protocol-relative URLs (``//evil.com``). They start with ``/`` + // but browsers resolve them as ``https://evil.com``, which would turn + // the site-relative branch into an open redirect. + if (normalized.startsWith("//")) return false; const lower = normalized.toLowerCase(); return ( lower.startsWith("http://") || diff --git a/opencontractserver/annotations/models.py b/opencontractserver/annotations/models.py index 384e3cd52..10ee949a1 100644 --- a/opencontractserver/annotations/models.py +++ b/opencontractserver/annotations/models.py @@ -112,9 +112,15 @@ def validate_link_url(url: str) -> None: if not url: return normalized = url.strip() - is_safe = normalized.lower().startswith( - LINK_URL_ALLOWED_SCHEMES - ) or normalized.startswith("/") + # ``//evil.com`` is a *protocol-relative* URL: browsers navigate to + # ``https://evil.com``. It starts with ``/`` but is NOT a site-relative + # path. Reject it before the allow-list check so the open-redirect + # vector closes here rather than in the renderer. + is_protocol_relative = normalized.startswith("//") + is_site_relative = normalized.startswith("/") and not is_protocol_relative + is_safe = ( + normalized.lower().startswith(LINK_URL_ALLOWED_SCHEMES) or is_site_relative + ) if not is_safe: raise ValidationError( { @@ -1102,10 +1108,12 @@ def clean(self) -> None: # noqa: C901 (complexity – kept minimal) # cannot be bypassed by toggling that flag in production. # Strip surrounding whitespace so a value submitted with leading # spaces (e.g. via a direct API call that didn't pre-trim) is - # stored canonically and the renderer doesn't produce a broken link. - if self.link_url: - self.link_url = self.link_url.strip() - validate_link_url(self.link_url) + # stored canonically. Whitespace-only input collapses to None so + # the column stays NULL rather than holding an empty string. + if self.link_url is not None: + self.link_url = self.link_url.strip() or None + if self.link_url: + validate_link_url(self.link_url) from django.conf import settings # local to avoid global import cost @@ -1225,14 +1233,16 @@ def save(self, *args: Any, **kwargs: Any) -> None: # (before its early-return), so a separate call here would # be redundant. self.clean() - elif self.link_url: + elif self.link_url is not None: # ``clean()`` was skipped above, but link_url must still be # validated — it is reflected in a click handler so unsafe # schemes like ``javascript:`` must never reach persistence. # Mirror the whitespace-stripping ``clean()`` performs so the # column stays canonical regardless of which path persisted. - self.link_url = self.link_url.strip() - validate_link_url(self.link_url) + # Whitespace-only collapses to None. + self.link_url = self.link_url.strip() or None + if self.link_url: + validate_link_url(self.link_url) # Auto-compact annotation JSON to v2 format on save (lazy migration). if ( diff --git a/opencontractserver/tests/test_url_annotation.py b/opencontractserver/tests/test_url_annotation.py index 5f72f41fd..58fe536be 100644 --- a/opencontractserver/tests/test_url_annotation.py +++ b/opencontractserver/tests/test_url_annotation.py @@ -180,6 +180,19 @@ def test_whitespace_prefix_does_not_bypass(self): with self.assertRaises(ValidationError): validate_link_url(" javascript:alert(1)") + def test_protocol_relative_url_is_rejected(self): + # ``//evil.com`` starts with ``/`` but browsers resolve it as + # ``https://evil.com``. The site-relative branch of the allow-list + # must not let it through — otherwise we ship an open redirect. + with self.assertRaises(ValidationError): + validate_link_url("//evil.com") + with self.assertRaises(ValidationError): + validate_link_url("//evil.com/path?x=1") + # Whitespace-prefixed protocol-relative also rejected (post-strip + # the leading ``//`` is preserved, so the rejection still fires). + with self.assertRaises(ValidationError): + validate_link_url(" //evil.com") + def test_annotation_clean_rejects_unsafe_link_url(self): # The model's ``clean()`` must invoke ``validate_link_url`` so # callers that go through full_clean() are protected. @@ -226,6 +239,32 @@ def test_annotation_save_rejects_unsafe_link_url(self): with self.assertRaises(ValidationError): ann.save() + def test_whitespace_only_link_url_collapses_to_none(self): + # ``" "`` is truthy as a string. Without explicit normalisation + # ``save()`` would persist the whitespace verbatim, leaving the + # column with garbage. Both ``clean()`` and ``save()`` collapse + # whitespace-only to None so the column stays NULL. + user = User.objects.create_user(username="u3", password="x") + doc = Document.objects.create( + title="doc", creator=user, is_public=False, backend_lock=False + ) + label = AnnotationLabel.objects.create( + text="L", label_type=TOKEN_LABEL, creator=user + ) + ann = Annotation( + page=0, + raw_text="hello", + document=doc, + annotation_label=label, + creator=user, + annotation_type=TOKEN_LABEL, + link_url=" ", + json={"0": {"bounds": {}, "rawText": "hello", "tokensJsons": []}}, + ) + ann.save() + ann.refresh_from_db() + self.assertIsNone(ann.link_url) + class AddUrlAnnotationMutationTests(TestCase): """Coverage of the ``addUrlAnnotation`` GraphQL mutation.""" From 22b24e4116d7a0ff273c87fe66f5022af13347ed Mon Sep 17 00:00:00 2001 From: JSv4 Date: Thu, 14 May 2026 08:22:02 -0500 Subject: [PATCH 17/22] Lift codecov patch coverage: add targeted URL-annotation tests Backend - ValidateLinkUrlTests: cover the save() elif branch that fires when VALIDATE_ANNOTATION_JSON=False so unsafe link_url schemes can't slip past in production where DEBUG is off. - LinkUrlExporterTests: assert build_document_export propagates link_url through the V1 corpus export / fork pipeline. - test_package_structural_annotation_set: verify the V2 structural-set export round-trips link_url on OC_URL annotations and omits the key on plain annotations. - test_users_tasks_auth0: exercise get_user_details_async so the bearer-token headers dict in the Auth0 user-details task is covered. Frontend - AnnotationHooks.test.tsx: drive useCreateUrlAnnotation's Token branch for PDFs and useUpdateAnnotation's Token-branch linkUrl passthrough. - SelectionBoundary.test.tsx: pin clickThroughOnPlainClick semantics (plain-click fires onClick when on; pointer cursor; mousedown stopPropagation; guard branches when onClick missing). - CreateUrlAnnotationModal.test.tsx: unit-level coverage for body mousedown stopPropagation, error-clearing on input edit, and whitespace trimming on confirm. - SelectionLayer.urlFlow.test.tsx: drive handleStartCreateLink, handleConfirmCreateLink and handleCancelCreateLink end-to-end inside jsdom; verify the create-link button is gated on createUrlAnnotation. - TxtAnnotator.urlFlow.test.tsx: companion tests for the Span (text/md) URL-creation flow plus hyperlink rendering and click-to-open. - urlAnnotation.test.ts: direct coverage of isSafeUrl, including the empty-string / whitespace-only branch. --- .../CreateUrlAnnotationModal.test.tsx | 86 ++++ .../__tests__/SelectionBoundary.test.tsx | 154 ++++++++ .../hooks/__tests__/AnnotationHooks.test.tsx | 195 ++++++++- .../__tests__/SelectionLayer.urlFlow.test.tsx | 243 ++++++++++++ .../__tests__/TxtAnnotator.urlFlow.test.tsx | 371 ++++++++++++++++++ .../utils/__tests__/urlAnnotation.test.ts | 42 +- .../tests/test_corpus_export_import_v2.py | 26 +- .../tests/test_url_annotation.py | 163 ++++++++ .../tests/test_users_tasks_auth0.py | 22 ++ 9 files changed, 1299 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/annotator/components/modals/__tests__/CreateUrlAnnotationModal.test.tsx create mode 100644 frontend/src/components/annotator/display/components/__tests__/SelectionBoundary.test.tsx create mode 100644 frontend/src/components/annotator/renderers/pdf/__tests__/SelectionLayer.urlFlow.test.tsx create mode 100644 frontend/src/components/annotator/renderers/txt/__tests__/TxtAnnotator.urlFlow.test.tsx diff --git a/frontend/src/components/annotator/components/modals/__tests__/CreateUrlAnnotationModal.test.tsx b/frontend/src/components/annotator/components/modals/__tests__/CreateUrlAnnotationModal.test.tsx new file mode 100644 index 000000000..e7c0b39cc --- /dev/null +++ b/frontend/src/components/annotator/components/modals/__tests__/CreateUrlAnnotationModal.test.tsx @@ -0,0 +1,86 @@ +/** + * Unit-level coverage for ``CreateUrlAnnotationModal``. + * + * The Playwright component tests in ``frontend/tests/`` cover the modal + * end-to-end, but unit coverage drives the modal-body interaction + * handlers (``onMouseDown`` stopPropagation, error-clearing on input) + * which Playwright's snapshot tests don't reliably wire into the + * Istanbul lcov bundle picked up by codecov's ``frontend-unit`` flag. + */ +import React from "react"; +import { render, fireEvent, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; + +import { CreateUrlAnnotationModal } from "../CreateUrlAnnotationModal"; + +describe("CreateUrlAnnotationModal", () => { + it("stopPropagation on body mousedown does not bubble to wrapping handler", () => { + // The modal body wraps its children in a div that swallows mousedown + // so a click inside the modal cannot start a selection-drag on the + // underlying SelectionLayer / TxtAnnotator. + const parentHandler = vi.fn(); + render( +
+ +
+ ); + const input = screen.getByPlaceholderText(/https:\/\//); + fireEvent.mouseDown(input); + // The body wrapper's stopPropagation must prevent the bubble. + expect(parentHandler).not.toHaveBeenCalled(); + }); + + it("clears an existing error as soon as the user edits the input", () => { + // The change handler is ``setUrl + if (error) setError(null)``. The + // ``if (error)`` partial branch (covered when error is non-null) is + // exercised by first triggering a validation failure, then typing. + const onConfirm = vi.fn(); + render( + + ); + + const input = screen.getByPlaceholderText(/https:\/\//) as HTMLInputElement; + + // 1. Type an unsafe URL and submit so ``setError`` fires. + fireEvent.change(input, { target: { value: "javascript:alert(1)" } }); + const createBtn = screen.getByRole("button", { name: /create link/i }); + fireEvent.click(createBtn); + // The component renders an inline error message when validation fails. + expect(screen.getByText(/must start with/i)).toBeInTheDocument(); + + // 2. Edit the input; the partial-branch (error truthy → clear) must + // fire and the error message must disappear from the DOM. + fireEvent.change(input, { target: { value: "https://safe.example.com" } }); + expect(screen.queryByText(/must start with/i)).not.toBeInTheDocument(); + }); + + it("trims leading/trailing whitespace before passing the URL upstream", () => { + // Mirror of the model layer's whitespace-stripping contract — the + // modal must hand the parent component a canonical URL string. + const onConfirm = vi.fn(); + render( + + ); + const input = screen.getByPlaceholderText(/https:\/\//) as HTMLInputElement; + fireEvent.change(input, { + target: { value: " https://example.com/with-spaces " }, + }); + fireEvent.click(screen.getByRole("button", { name: /create link/i })); + expect(onConfirm).toHaveBeenCalledWith("https://example.com/with-spaces"); + }); +}); diff --git a/frontend/src/components/annotator/display/components/__tests__/SelectionBoundary.test.tsx b/frontend/src/components/annotator/display/components/__tests__/SelectionBoundary.test.tsx new file mode 100644 index 000000000..3857db90b --- /dev/null +++ b/frontend/src/components/annotator/display/components/__tests__/SelectionBoundary.test.tsx @@ -0,0 +1,154 @@ +/** + * Unit tests for SelectionBoundary click semantics. + * + * Covers the ``clickThroughOnPlainClick`` prop introduced for OC_URL + * hyperlink annotations: plain (non-shift) clicks must fire ``onClick`` + * when the prop is on, but stay inert otherwise. Exercising both branches + * pins the behaviour that lets clickable-link annotations open without + * accidentally invading the normal selection-only semantics. + */ +import React from "react"; +import { render, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; + +import { SelectionBoundary } from "../SelectionBoundary"; + +vi.mock("../../../hooks/useAnnotationRefs", () => ({ + useAnnotationRefs: () => ({ + registerRef: vi.fn(), + unregisterRef: vi.fn(), + }), +})); + +vi.mock("jotai", async () => { + const actual = await vi.importActual("jotai"); + return { + ...actual, + // ``isCreatingAnnotation`` is read via ``useAtomValue`` — return + // ``false`` so the click path is not short-circuited by the + // "drawing a new selection" branch. + useAtomValue: vi.fn(() => false), + }; +}); + +const bounds = { left: 0, top: 0, right: 100, bottom: 50 }; + +// Required props that every test needs — extracted so the per-test JSX +// stays focused on the prop under test (clickThroughOnPlainClick, etc.). +const baseProps = { + hidden: false, + selected: false, +} as const; + +describe("SelectionBoundary click semantics", () => { + it("does NOT call onClick on a plain click by default", () => { + const onClick = vi.fn(); + const { container } = render( + + ); + const span = container.querySelector("span") as HTMLElement; + fireEvent.click(span); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("calls onClick on a shift-click (default behaviour)", () => { + const onClick = vi.fn(); + const { container } = render( + + ); + const span = container.querySelector("span") as HTMLElement; + fireEvent.click(span, { shiftKey: true }); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("calls onClick on a plain click when clickThroughOnPlainClick is true", () => { + // OC_URL hyperlink path: the surrounding renderer wires + // ``clickThroughOnPlainClick`` so a plain click opens the URL. + const onClick = vi.fn(); + const { container } = render( + + ); + const span = container.querySelector("span") as HTMLElement; + fireEvent.click(span); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("shows pointer cursor when clickThroughOnPlainClick is true", () => { + // Visible affordance: hyperlink-style annotations must use a pointer + // cursor so the user knows the span is clickable. + const { container } = render( + + ); + const span = container.querySelector("span") as HTMLElement; + expect(span.style.cursor).toBe("pointer"); + }); + + it("does NOT set pointer cursor when clickThroughOnPlainClick is false", () => { + const { container } = render( + + ); + const span = container.querySelector("span") as HTMLElement; + expect(span.style.cursor).toBe(""); + }); + + it("noop on mouseDown without onClick prop", () => { + // ``handleMouseDown``'s early-return when ``!onClick`` is the + // codecov-flagged guard branch. Just rendering without onClick and + // firing the event must not throw. + const { container } = render( + + ); + const span = container.querySelector("span") as HTMLElement; + expect(() => + fireEvent.mouseDown(span, { shiftKey: true }) + ).not.toThrow(); + }); + + it("stops mouseDown propagation on shift+mousedown", () => { + // The boundary swallows shift+mousedown so the underlying selection + // layer doesn't restart a drag-selection over an existing annotation. + const onClick = vi.fn(); + const parentClick = vi.fn(); + const { container } = render( +
+ +
+ ); + const span = container.querySelector("span") as HTMLElement; + fireEvent.mouseDown(span, { shiftKey: true }); + // The parent handler must not see the event due to stopPropagation. + expect(parentClick).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/annotator/hooks/__tests__/AnnotationHooks.test.tsx b/frontend/src/components/annotator/hooks/__tests__/AnnotationHooks.test.tsx index 2167f679e..925462024 100644 --- a/frontend/src/components/annotator/hooks/__tests__/AnnotationHooks.test.tsx +++ b/frontend/src/components/annotator/hooks/__tests__/AnnotationHooks.test.tsx @@ -147,6 +147,13 @@ interface WrapperOptions { mocks?: MockedResponse[]; withCorpus?: boolean; withDocument?: boolean; + /** + * Optional override for the mock document's ``fileType``. Defaults to + * ``application/txt`` (span-based). Use ``application/pdf`` to drive + * the Token-annotation branch in hooks that switch on file type + * (e.g. ``useCreateUrlAnnotation`` for PDFs). + */ + fileType?: string; initialAnnotations?: (ServerSpanAnnotation | ServerTokenAnnotation)[]; initialRelations?: RelationGroup[]; initialDocTypes?: DocTypeAnnotation[]; @@ -157,14 +164,21 @@ const buildWrapper = (options: WrapperOptions = {}) => { mocks = [], withCorpus = true, withDocument = true, + fileType, initialAnnotations = [], initialRelations = [], initialDocTypes = [], } = options; + const documentForAtom = withDocument + ? fileType + ? { ...mockDocument, fileType } + : mockDocument + : null; + const Hydrate = ({ children }: { children: ReactNode }) => { useHydrateAtoms([ - [selectedDocumentAtom, withDocument ? mockDocument : null], + [selectedDocumentAtom, documentForAtom], [ corpusStateAtom, { @@ -673,6 +687,118 @@ describe("AnnotationHooks", () => { expect(result.current.state.pdfAnnotations.annotations).toHaveLength(0); }); + + it("short-circuits without a selected document", async () => { + // The hook checks both ``selectedCorpus`` *and* ``selectedDocument``. + // The corpus-only short-circuit is covered above; this exercises the + // document branch (else-leg of the ``!selectedCorpus || !selectedDocument`` + // guard) so the no-document log/warning path is locked in. + const ann = makeSpan("local"); + const { result } = renderHook( + () => ({ + create: useCreateUrlAnnotation(), + state: usePdfAnnotations(), + }), + { wrapper: buildWrapper({ withDocument: false }) } + ); + + await act(async () => { + await result.current.create(ann, "https://example.com"); + }); + + expect(result.current.state.pdfAnnotations.annotations).toHaveLength(0); + }); + + it("creates a ServerTokenAnnotation for PDF documents on success", async () => { + // Companion to the Span-branch test above. PDFs use Token annotations, + // so the hook's ``isSpanBasedFileType(...) ? Span : Token`` ternary must + // produce a ``ServerTokenAnnotation`` with the server-returned linkUrl + // when the selected document is a PDF. Without this test, the Token + // branch (and ``created.linkUrl`` passthrough on the Token wrapper) + // is never exercised — exactly the 3 misses + 4 partials codecov + // flagged in ``AnnotationHooks.tsx`` for this PR. + const pdfTokenAnn = new ServerTokenAnnotation( + 0, + mockLabel, + "hello", + false, + { 0: { bounds: {}, tokensJsons: [], rawText: "hello" } } as any, + [ + PermissionTypes.CAN_READ, + PermissionTypes.CAN_UPDATE, + PermissionTypes.CAN_REMOVE, + ], + false, + false, + false, + "local-pdf-tmp" + ); + const serverId = "server-pdf-url"; + const linkUrl = "https://example.com/pdf"; + + const mocks: MockedResponse[] = [ + { + request: { + query: REQUEST_ADD_URL_ANNOTATION, + variables: { + json: pdfTokenAnn.json, + documentId: mockDocument.id, + corpusId: mockCorpus.id, + rawText: pdfTokenAnn.rawText, + page: pdfTokenAnn.page, + annotationType: LabelType.TokenLabel, + linkUrl, + }, + }, + result: { + data: { + addUrlAnnotation: { + ok: true, + message: "OK", + annotation: { + id: serverId, + page: 0, + rawText: "hello", + json: { + "0": { bounds: {}, tokensJsons: [], rawText: "hello" }, + }, + linkUrl, + annotationType: LabelType.TokenLabel, + annotationLabel: ocUrlLabel, + myPermissions: ["CAN_READ", "CAN_UPDATE", "CAN_REMOVE"], + isPublic: false, + }, + }, + }, + }, + }, + ]; + + const { result } = renderHook( + () => ({ + create: useCreateUrlAnnotation(), + state: usePdfAnnotations(), + }), + { + wrapper: buildWrapper({ + mocks, + fileType: "application/pdf", + }), + } + ); + + await act(async () => { + await result.current.create(pdfTokenAnn, linkUrl); + }); + + const stored = result.current.state.pdfAnnotations.annotations; + expect(stored).toHaveLength(1); + expect(stored[0].id).toBe(serverId); + expect(stored[0].linkUrl).toBe(linkUrl); + // Critical assertion: the hook must have produced a Token annotation, + // not a Span one, when the document is a PDF. + expect(stored[0]).toBeInstanceOf(ServerTokenAnnotation); + }); }); describe("useUpdateAnnotation", () => { @@ -721,6 +847,73 @@ describe("AnnotationHooks", () => { expect(result.current.state.pdfAnnotations.unsavedChanges).toBe(true); }); + it("preserves linkUrl on the Token branch for PDF documents", async () => { + // The PDF (Token) branch in useUpdateAnnotation has the same + // ``annotation.linkUrl ?? null`` constructor parameter as the Span + // branch, but a separate code path. Without a PDF-typed wrapper, + // the Token-branch line (501 in AnnotationHooks.tsx) stays unhit + // and codecov flags it as a missed patch. + const existing = new ServerTokenAnnotation( + 0, + mockLabel, + "hello", + false, + { 0: { bounds: {}, tokensJsons: [], rawText: "hello" } } as any, + [ + PermissionTypes.CAN_READ, + PermissionTypes.CAN_UPDATE, + PermissionTypes.CAN_REMOVE, + ], + false, + false, + false, + "ann-pdf-1", + undefined, + "https://example.com/keep-on-update" + ); + const mocks: MockedResponse[] = [ + { + request: { + query: REQUEST_UPDATE_ANNOTATION, + variables: { + id: existing.id, + json: existing.json, + rawText: existing.rawText, + page: existing.page, + annotationLabel: mockLabel.id, + linkUrl: existing.linkUrl, + }, + }, + result: { + data: { updateAnnotation: { ok: true, message: "ok" } }, + }, + }, + ]; + + const { result } = renderHook( + () => ({ + update: useUpdateAnnotation(), + state: usePdfAnnotations(), + }), + { + wrapper: buildWrapper({ + mocks, + initialAnnotations: [existing], + fileType: "application/pdf", + }), + } + ); + + await act(async () => { + await result.current.update(existing); + }); + + const stored = result.current.state.pdfAnnotations.annotations; + expect(stored).toHaveLength(1); + expect(stored[0]).toBeInstanceOf(ServerTokenAnnotation); + expect(stored[0].linkUrl).toBe("https://example.com/keep-on-update"); + }); + it("updates one annotation in place without dropping siblings", async () => { // Regression: earlier versions called replaceAnnotations([updated]) // which collapsed the full list to the one updated annotation. diff --git a/frontend/src/components/annotator/renderers/pdf/__tests__/SelectionLayer.urlFlow.test.tsx b/frontend/src/components/annotator/renderers/pdf/__tests__/SelectionLayer.urlFlow.test.tsx new file mode 100644 index 000000000..30a0dec28 --- /dev/null +++ b/frontend/src/components/annotator/renderers/pdf/__tests__/SelectionLayer.urlFlow.test.tsx @@ -0,0 +1,243 @@ +/** + * Unit-level coverage for SelectionLayer's URL-annotation flow. + * + * Exercises ``handleStartCreateLink`` → ``CreateUrlAnnotationModal`` → + * ``handleConfirmCreateLink`` / ``handleCancelCreateLink`` so the + * URL creation path is locked in without needing a full Playwright + * component test. These callbacks are entirely unhit by the existing + * jsdom test suite (which only drives the basic selection lifecycle), + * so codecov flags them as missed patches. + */ +import React from "react"; +import { render, fireEvent, act, screen } from "@testing-library/react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Provider as JotaiProvider } from "jotai"; +import { MemoryRouter } from "react-router-dom"; + +import SelectionLayer from "../SelectionLayer"; +import { PDFPageInfo } from "../../../types/pdf"; +import { + AnnotationLabelType, + LabelType, +} from "../../../../../types/graphql-api"; +import { PermissionTypes } from "../../../../types"; + +vi.mock("../../../context/CorpusAtom", () => ({ + useCorpusState: vi.fn(), +})); + +vi.mock("../../../hooks/useAnnotationSelection", () => ({ + useAnnotationSelection: () => ({ + setSelectedAnnotations: vi.fn(), + }), +})); + +// ``useAnnotationRefs`` returns a refs bundle expected to have a +// ``annotationElementRefs.current`` shape; SelectionBoundary (used by +// pending-selection bounds) accesses it during render. Stub it with a +// minimal shape so the SelectionLayer tree renders inside jsdom. +vi.mock("../../../hooks/useAnnotationRefs", () => ({ + useAnnotationRefs: () => ({ + annotationElementRefs: { current: {} }, + textSearchElementRefs: { current: {} }, + chatSourceElementRefs: { current: {} }, + PDFPageCanvasRef: { current: null }, + PDFPageRendererRef: { current: null }, + PDFPageContainerRefs: { current: {} }, + scrollContainerRef: { current: null }, + registerRef: vi.fn(), + unregisterRef: vi.fn(), + }), +})); + +// SelectionLayer reads ``isCreatingAnnotationAtom`` via ``useAtom`` and +// ``scrollContainerRefAtom`` via ``useAtomValue``. Mock both with safe +// defaults so the component renders without a Jotai store. +vi.mock("jotai", async () => { + const actual = await vi.importActual("jotai"); + return { + ...actual, + useAtom: vi.fn(() => [false, vi.fn()]), + useAtomValue: vi.fn(() => null), + }; +}); + +import { useCorpusState } from "../../../context/CorpusAtom"; + +const activeLabel: AnnotationLabelType = { + id: "label-1", + text: "Test Label", + color: "#0066cc", + description: "Test label", + labelType: LabelType.SpanLabel, + icon: "tag", + readonly: false, +}; + +/** + * Mock PDFPageInfo that yields a deterministic annotation payload — + * required because the URL-confirm handler iterates over the captured + * pending selections and asks for their PAWLs-format JSON. + */ +function buildPageInfo() { + return { + page: { pageNumber: 1 }, + getPageAnnotationJson: vi.fn(() => ({ + bounds: { left: 0, top: 0, right: 10, bottom: 10 }, + rawText: "linked text", + tokensJsons: [{ pageIndex: 0, tokenIndex: 0 }], + })), + getAnnotationForBounds: vi.fn(() => null), + } as unknown as PDFPageInfo; +} + +function mountLayer(opts: { + createUrlAnnotation?: ReturnType; + createAnnotation?: ReturnType; +}) { + const corpusMock = vi.mocked(useCorpusState); + corpusMock.mockReturnValue({ + canUpdateCorpus: true, + myPermissions: [PermissionTypes.CAN_UPDATE], + selectedCorpus: { id: "corpus-1" }, + humanSpanLabels: [activeLabel], + humanTokenLabels: [activeLabel], + relationLabels: [], + } as unknown as ReturnType); + + const utils = render( + + + + + + ); + + // Stub the canvas previousSibling so SelectionLayer's mousedown can + // read a bounding rect from inside jsdom. + const layer = utils.container.querySelector("#selection-layer") as HTMLElement; + const fakeCanvas = document.createElement("canvas"); + Object.defineProperty(fakeCanvas, "getBoundingClientRect", { + value: () => ({ + left: 0, + top: 0, + right: 200, + bottom: 200, + width: 200, + height: 200, + x: 0, + y: 0, + toJSON: () => ({}), + }), + configurable: true, + }); + layer.parentElement?.insertBefore(fakeCanvas, layer); + + // Run a synthetic selection so ``pendingSelections`` is non-empty and + // the action menu (which hosts the "Add link…" button) is rendered. + // Wrap each lifecycle phase in its own act() so the doc-level listeners + // (attached only while ``localPageSelection`` is set, via a useEffect + // dependency) are bound before the mouseup arrives. + act(() => { + fireEvent.mouseDown(layer, { clientX: 5, clientY: 5, buttons: 1 }); + }); + act(() => { + fireEvent.mouseMove(document, { clientX: 100, clientY: 100, buttons: 1 }); + }); + act(() => { + fireEvent.mouseUp(document, { clientX: 100, clientY: 100 }); + }); + + return { ...utils, layer }; +} + +describe("SelectionLayer URL-annotation flow", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("clicking 'Add link…' opens the URL modal", async () => { + const createUrl = vi.fn(async () => undefined); + mountLayer({ createUrlAnnotation: createUrl }); + + // The action menu portals to document.body once the selection lands. + const linkBtn = await screen.findByTestId("create-link-button"); + expect(linkBtn).toBeInTheDocument(); + + act(() => { + fireEvent.click(linkBtn); + }); + + // After the click the menu collapses and the URL modal mounts. + // ``CreateUrlAnnotationModal`` renders an input the user can type into. + const urlInput = await screen.findByPlaceholderText(/https:\/\//); + expect(urlInput).toBeInTheDocument(); + }); + + it("confirming the URL modal invokes createUrlAnnotation with the typed URL", async () => { + const createUrl = vi.fn(async () => undefined); + mountLayer({ createUrlAnnotation: createUrl }); + + const linkBtn = await screen.findByTestId("create-link-button"); + act(() => { + fireEvent.click(linkBtn); + }); + + const urlInput = (await screen.findByPlaceholderText( + /https:\/\// + )) as HTMLInputElement; + + act(() => { + fireEvent.change(urlInput, { + target: { value: "https://example.com/from-test" }, + }); + }); + + // ``CreateUrlAnnotationModal`` renders the confirm button with text + // "Create link" when no initialUrl is supplied — find it by role/name. + const confirmBtn = await screen.findByRole("button", { name: /create link/i }); + await act(async () => { + fireEvent.click(confirmBtn); + }); + + expect(createUrl).toHaveBeenCalledTimes(1); + const callArgs = createUrl.mock.calls[0] as unknown as [unknown, string]; + expect(callArgs[1]).toBe("https://example.com/from-test"); + }); + + it("cancelling the URL modal does NOT invoke createUrlAnnotation", async () => { + const createUrl = vi.fn(async () => undefined); + mountLayer({ createUrlAnnotation: createUrl }); + + const linkBtn = await screen.findByTestId("create-link-button"); + act(() => { + fireEvent.click(linkBtn); + }); + + await screen.findByPlaceholderText(/https:\/\//); + + // Modal's cancel button is rendered with "Cancel" label. + const cancelBtn = await screen.findByRole("button", { name: /^cancel$/i }); + act(() => { + fireEvent.click(cancelBtn); + }); + + expect(createUrl).not.toHaveBeenCalled(); + }); + + it("does NOT render the 'Add link…' button when createUrlAnnotation is missing", async () => { + // The button is gated on ``createUrlAnnotation && ...`` so omitting the + // prop must hide the entry point entirely. Without this gate, users + // could click into a no-op modal. + mountLayer({ createUrlAnnotation: undefined }); + + expect(screen.queryByTestId("create-link-button")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/annotator/renderers/txt/__tests__/TxtAnnotator.urlFlow.test.tsx b/frontend/src/components/annotator/renderers/txt/__tests__/TxtAnnotator.urlFlow.test.tsx new file mode 100644 index 000000000..50b9a96f6 --- /dev/null +++ b/frontend/src/components/annotator/renderers/txt/__tests__/TxtAnnotator.urlFlow.test.tsx @@ -0,0 +1,371 @@ +/** + * Unit-level coverage for TxtAnnotator's URL-annotation flow. + * + * Drives ``handleTxtStartCreateLink``, ``handleTxtConfirmCreateLink``, + * and ``handleTxtCancelCreateLink`` so the Span-based URL creation path + * is locked in for text/markdown documents. These callbacks share the + * contract with the PDF flow but live on a separate code path + * (Span vs Token), so codecov flags them independently. + * + * jsdom only partially implements ``Selection``; we stub ``getSelection`` + * to return a deterministic range so ``handleMouseUp`` reaches + * ``setPendingSelection`` without depending on the browser's text-layout + * subsystem. + */ +import React from "react"; +import { render, fireEvent, act, screen } from "@testing-library/react"; +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { MemoryRouter } from "react-router-dom"; + +import TxtAnnotator from "../TxtAnnotator"; +import { ServerSpanAnnotation } from "../../../types/annotations"; +import { LabelType } from "../../../../../types/graphql-api"; +import { PermissionTypes } from "../../../../types"; +import { OC_URL_LABEL } from "../../../../../assets/configurations/constants"; + +const mockLabel = { + id: "label-1", + text: "TestLabel", + color: "#3B82F6", + icon: "tag" as any, + description: "Test label", + labelType: LabelType.SpanLabel, +}; + +const ocUrlLabel = { + id: "label-url", + text: OC_URL_LABEL, + color: "#2563EB", + icon: "link" as any, + description: "url", + labelType: LabelType.SpanLabel, +}; + +const EMPTY: any[] = []; + +const defaultProps = { + searchResults: EMPTY, + getSpan: vi.fn((sel: { start: number; end: number }) => + new ServerSpanAnnotation( + 0, + mockLabel, + "hello", + false, + { start: sel.start, end: sel.end }, + [PermissionTypes.CAN_READ, PermissionTypes.CAN_UPDATE], + false, + false, + false, + "local-tmp-id" + ) + ), + visibleLabels: null, + availableLabels: [mockLabel], + selectedLabelTypeId: mockLabel.id, + // allowInput=true + read_only=false unlocks the menu's annotation-creation + // entry points (Apply Label / Add link…), matching the live UX gate. + read_only: false, + allowInput: true, + zoom_level: 1, + createAnnotation: vi.fn(), + updateAnnotation: vi.fn(), + deleteAnnotation: vi.fn(), + selectedAnnotations: [] as string[], + setSelectedAnnotations: vi.fn(), + showStructuralAnnotations: true, + chatSources: EMPTY, +}; + +/** + * Stub ``document.getSelection`` so ``handleMouseUp`` reads a deterministic + * text range and pushes ``pendingSelection`` into state. The values mirror + * what jsdom would return after a real ``span.click``+drag, but without + * depending on jsdom's incomplete layout engine. + */ +function mockSelection(opts: { + text: string; + anchorNode: Node; + anchorOffset: number; + focusNode: Node; + focusOffset: number; +}) { + // ``TxtAnnotator.dismissMenu`` calls ``Selection.removeAllRanges`` after + // a menu interaction; jsdom's getSelection() shim implements it but the + // mocked instance does not by default. Provide the full surface area + // the component touches so the dismissal path doesn't throw. + const sel = { + toString: () => opts.text, + anchorNode: opts.anchorNode, + focusNode: opts.focusNode, + anchorOffset: opts.anchorOffset, + focusOffset: opts.focusOffset, + removeAllRanges: vi.fn(), + rangeCount: 1, + } as unknown as Selection; + vi.spyOn(document, "getSelection").mockReturnValue(sel); +} + +describe("TxtAnnotator URL-annotation flow", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset menu cooldown by faking the timestamp far enough in the past. + vi.useFakeTimers({ now: Date.now() + 10_000 }); + vi.useRealTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("'Add link…' button is only rendered when createUrlAnnotation is provided", async () => { + // Without ``createUrlAnnotation`` the menu must NOT advertise the + // link-creation entry point — clicking it would be a no-op confusion. + const { container, rerender } = render( + + + + ); + + const wrapper = container.querySelector("div[id]") as HTMLDivElement; + expect(wrapper).toBeTruthy(); + + // Stub selection covering "hello". + const textNode = wrapper.querySelector("span")?.firstChild ?? wrapper; + mockSelection({ + text: "hello", + anchorNode: textNode, + anchorOffset: 0, + focusNode: textNode, + focusOffset: 5, + }); + + act(() => { + fireEvent.mouseUp(wrapper, { clientX: 50, clientY: 50 }); + }); + + // The menu may not render the link button without createUrlAnnotation + expect(screen.queryByText(/add link/i)).not.toBeInTheDocument(); + + // Now re-render with the prop; the button must appear. + const createUrl = vi.fn(async () => undefined); + rerender( + + + + ); + + const wrapper2 = container.querySelector("div[id]") as HTMLDivElement; + const textNode2 = wrapper2.querySelector("span")?.firstChild ?? wrapper2; + mockSelection({ + text: "hello", + anchorNode: textNode2, + anchorOffset: 0, + focusNode: textNode2, + focusOffset: 5, + }); + + act(() => { + fireEvent.mouseUp(wrapper2, { clientX: 50, clientY: 50 }); + }); + + // "Add link…" should now appear in the menu. + expect(await screen.findByText(/add link/i)).toBeInTheDocument(); + }); + + it("opens the URL modal when 'Add link…' is clicked, then awaits createUrlAnnotation on confirm", async () => { + const createUrl = vi.fn(async () => undefined); + + const { container } = render( + + + + ); + + const wrapper = container.querySelector("div[id]") as HTMLDivElement; + const textNode = wrapper.querySelector("span")?.firstChild ?? wrapper; + mockSelection({ + text: "hello", + anchorNode: textNode, + anchorOffset: 0, + focusNode: textNode, + focusOffset: 5, + }); + + act(() => { + fireEvent.mouseUp(wrapper, { clientX: 50, clientY: 50 }); + }); + + const linkBtn = await screen.findByText(/add link/i); + act(() => { + fireEvent.click(linkBtn); + }); + + // CreateUrlAnnotationModal must mount with the placeholder input. + const urlInput = (await screen.findByPlaceholderText( + /https:\/\// + )) as HTMLInputElement; + act(() => { + fireEvent.change(urlInput, { + target: { value: "https://example.com/txt" }, + }); + }); + + const confirmBtn = await screen.findByRole("button", { + name: /create link/i, + }); + await act(async () => { + fireEvent.click(confirmBtn); + }); + + expect(createUrl).toHaveBeenCalledTimes(1); + const callArgs = createUrl.mock.calls[0] as unknown as [unknown, string]; + expect(callArgs[1]).toBe("https://example.com/txt"); + }); + + it("renders existing OC_URL annotations as hyperlinks (underline + pointer)", async () => { + // The renderer's hyperlink-styling block (cursor: pointer + underline) + // only fires when ``isUrlAnnotation`` matches — i.e. an annotation + // carries the OC_URL label AND a non-empty linkUrl. Without an OC_URL + // fixture, those style branches stay unhit. + const linkAnn = new ServerSpanAnnotation( + 0, + ocUrlLabel, + "hello", + false, + { start: 0, end: 5 }, + [PermissionTypes.CAN_READ, PermissionTypes.CAN_UPDATE], + false, + false, + false, + "ann-link-1", + undefined, + "https://example.com/anchor" + ); + + const { container } = render( + + + + ); + + // The annotated span (covering "hello") must be present with the + // hyperlink styling derived from ``isUrlAnnotation`` matching. + const annotatedSpan = container.querySelector('[data-testid^="annotated-span-"]'); + expect(annotatedSpan).toBeTruthy(); + const style = (annotatedSpan as HTMLElement).getAttribute("style") || ""; + // Pointer cursor + underline are the two visible hyperlink signals. + expect(style.toLowerCase()).toContain("cursor: pointer"); + expect(style.toLowerCase()).toContain("text-decoration: underline"); + }); + + it("clicking a hyperlink annotation opens the URL", async () => { + // Companion to the styling test above: a plain (no shift/meta/ctrl) + // click on a hyperlink span must route through ``openAnnotationUrl`` + // — which we observe via the mocked ``window.open`` for absolute URLs. + const openSpy = vi + .spyOn(window, "open") + .mockReturnValue(null as unknown as Window); + + const linkAnn = new ServerSpanAnnotation( + 0, + ocUrlLabel, + "hello", + false, + { start: 0, end: 5 }, + [PermissionTypes.CAN_READ], + false, + false, + false, + "ann-link-2", + undefined, + "https://example.com/click-target" + ); + + const { container } = render( + + + + ); + + const annotatedSpan = container.querySelector( + '[data-testid^="annotated-span-"]' + ) as HTMLElement; + expect(annotatedSpan).toBeTruthy(); + + act(() => { + fireEvent.click(annotatedSpan); + }); + + expect(openSpy).toHaveBeenCalledWith( + "https://example.com/click-target", + "_blank", + "noopener,noreferrer" + ); + openSpy.mockRestore(); + }); + + it("cancelling the URL modal does NOT invoke createUrlAnnotation", async () => { + const createUrl = vi.fn(async () => undefined); + + const { container } = render( + + + + ); + + const wrapper = container.querySelector("div[id]") as HTMLDivElement; + const textNode = wrapper.querySelector("span")?.firstChild ?? wrapper; + mockSelection({ + text: "hello", + anchorNode: textNode, + anchorOffset: 0, + focusNode: textNode, + focusOffset: 5, + }); + + act(() => { + fireEvent.mouseUp(wrapper, { clientX: 50, clientY: 50 }); + }); + + const linkBtn = await screen.findByText(/add link/i); + act(() => { + fireEvent.click(linkBtn); + }); + + await screen.findByPlaceholderText(/https:\/\//); + + const cancelBtn = await screen.findByRole("button", { name: /^cancel$/i }); + act(() => { + fireEvent.click(cancelBtn); + }); + + expect(createUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/annotator/utils/__tests__/urlAnnotation.test.ts b/frontend/src/components/annotator/utils/__tests__/urlAnnotation.test.ts index 683332650..c361554b3 100644 --- a/frontend/src/components/annotator/utils/__tests__/urlAnnotation.test.ts +++ b/frontend/src/components/annotator/utils/__tests__/urlAnnotation.test.ts @@ -21,7 +21,7 @@ import { ServerSpanAnnotation, ServerTokenAnnotation, } from "../../types/annotations"; -import { isUrlAnnotation, openAnnotationUrl } from "../urlAnnotation"; +import { isSafeUrl, isUrlAnnotation, openAnnotationUrl } from "../urlAnnotation"; import type { AnnotationLabelType } from "../../../../types/graphql-api"; // SemanticICONS unions are unwieldy in tests; cast via ``unknown`` once @@ -237,3 +237,43 @@ describe("openAnnotationUrl", () => { ); }); }); + +describe("isSafeUrl", () => { + // Direct coverage of the exported helper used by authoring UIs + // (``CreateUrlAnnotationModal`` shares it via import) so the + // empty-string branch is exercised independently of + // ``openAnnotationUrl`` (which short-circuits earlier on ``!url``). + it("returns false for an empty string", () => { + expect(isSafeUrl("")).toBe(false); + }); + + it("returns false for a whitespace-only string", () => { + // ``isSafeUrl`` trims internally and then checks the *normalised* + // length, so leading/trailing whitespace must collapse to false. + expect(isSafeUrl(" ")).toBe(false); + expect(isSafeUrl("\t\n ")).toBe(false); + }); + + it("returns true for absolute http(s) URLs", () => { + expect(isSafeUrl("http://example.com")).toBe(true); + expect(isSafeUrl("https://example.com/path")).toBe(true); + // Case-insensitive scheme. + expect(isSafeUrl("HTTPS://EXAMPLE.COM")).toBe(true); + }); + + it("returns true for site-relative paths", () => { + expect(isSafeUrl("/corpus/foo")).toBe(true); + }); + + it("rejects protocol-relative URLs (open-redirect guard)", () => { + expect(isSafeUrl("//evil.com")).toBe(false); + expect(isSafeUrl("//evil.com/path?x=1")).toBe(false); + }); + + it("rejects dangerous schemes", () => { + expect(isSafeUrl("javascript:alert(1)")).toBe(false); + expect(isSafeUrl("data:text/html,