diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 37b6908e070..36ee58a26fb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -224,13 +224,6 @@ jobs: git push fi - - name: Test Weblate API addon access - env: - WEBLATE_API_TOKEN: ${{ secrets.WEBLATE_API_TOKEN }} - run: | - curl --fail-with-body --show-error \ - -H "Authorization: Token $WEBLATE_API_TOKEN" \ - https://hosted.weblate.org/api/addons/34603/ # This step must be run after pushing to github or else it will fail in # cases when new components are to be created (Weblate requires files to # be pushed before component can be created) diff --git a/specifyweb/backend/stored_queries/queryfieldspec.py b/specifyweb/backend/stored_queries/queryfieldspec.py index 74fc05350f4..89357b6c15a 100644 --- a/specifyweb/backend/stored_queries/queryfieldspec.py +++ b/specifyweb/backend/stored_queries/queryfieldspec.py @@ -3,7 +3,9 @@ from datetime import datetime, timedelta import re from collections import namedtuple, deque +from types import SimpleNamespace from typing import TypedDict, Iterable +from urllib.parse import urlencode from specifyweb.backend.inheritance.utils import get_cat_num_inheritance_setting, get_parent_cat_num_inheritance_setting from specifyweb.specify.utils.uiformatters import get_uiformatter @@ -553,6 +555,7 @@ def add_to_query( def add_spec_to_query( self, query, formatter=None, aggregator=None, cycle_detector=[] ): + """Build the shared field projection used by query results and exports.""" if self.get_field() is None: return ( @@ -602,6 +605,21 @@ def add_spec_to_query( else: raise + if table.name == "Attachment" and field_name == "resourceURL": + from specifyweb.backend.attachment_gw import views as attachment_gw + + if attachment_gw.server_urls is not None: + read_url = attachment_gw.server_urls["read"] + collection_name = attachment_gw.get_collection( + SimpleNamespace(specify_collection=query.collection) + ) + params = urlencode({"coll": collection_name, "type": "O"}) + separator = "&" if "?" in read_url else "?" + prefix = f"{read_url}{separator}{params}&filename=" + orm_field = sql.func.coalesce( + orm_field, prefix + orm_model.attachmentLocation + ) + if field.is_temporal() and self.date_part != "Full Date": precision_field_name = field.name + "Precision" precision_field = getattr(orm_model, precision_field_name, None) diff --git a/specifyweb/backend/stored_queries/tests/test_attachment_resource_url.py b/specifyweb/backend/stored_queries/tests/test_attachment_resource_url.py new file mode 100644 index 00000000000..f5fd80e1249 --- /dev/null +++ b/specifyweb/backend/stored_queries/tests/test_attachment_resource_url.py @@ -0,0 +1,169 @@ +import csv +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from django.test import SimpleTestCase, override_settings +from sqlalchemy import String, create_engine, sql + +from specifyweb.backend.attachment_gw import views as attachment_gw +from specifyweb.backend.stored_queries.execution import execute, query_to_csv +from specifyweb.backend.stored_queries.queryfieldspec import QueryFieldSpec +from specifyweb.backend.stored_queries.tests.tests import SQLAlchemySetup +from specifyweb.backend.stored_queries.tests.utils import make_query_fields_test +from specifyweb.specify.models import Attachment + + +@override_settings(WEB_ATTACHMENT_COLLECTION="Test Collection & Images") +class TestAttachmentResourceURLProjection(SimpleTestCase): + def project(self, resource_url, location, path=None): + spec = QueryFieldSpec.from_path(path or ["Attachment", "resourceURL"]) + attachment = sql.select([ + sql.literal(resource_url, type_=String).label("resourceURL"), + sql.literal(location, type_=String).label("attachmentLocation"), + ]).alias("attachment") + orm_model = SimpleNamespace(**attachment.c) + query = Mock() + query.collection = SimpleNamespace(collectionname="Separate Collection") + query.build_join.return_value = ( + query, orm_model, spec.table, spec.get_field() + ) + returned_query, projection, field, table = spec.add_spec_to_query(query) + self.assertIs(returned_query, query) + self.assertIs(field, spec.get_field()) + self.assertIs(table, spec.table) + + engine = create_engine("sqlite://") + try: + with engine.connect() as connection: + return connection.execute(sql.select([projection])).scalar() + finally: + engine.dispose() + + def test_projection(self): + with patch.object( + attachment_gw, "server_urls", {"read": "https://assets.example/fileget"} + ): + for resource_url, location, expected in [ + ("https://external.example/image.jpg", "image.jpg", + "https://external.example/image.jpg"), + ("https://external.example/image.jpg", None, + "https://external.example/image.jpg"), + ("", "image.jpg", ""), + (None, "image.jpg", + "https://assets.example/fileget" + "?coll=Test+Collection+%26+Images&type=O&filename=image.jpg"), + (None, None, None), + ]: + with self.subTest(resource_url=resource_url, location=location): + self.assertEqual(self.project(resource_url, location), expected) + + def test_unconfigured_asset_server(self): + with patch.object(attachment_gw, "server_urls", None): + self.assertEqual( + self.project("https://external.example/image.jpg", None), + "https://external.example/image.jpg", + ) + self.assertIsNone(self.project(None, "image.jpg")) + + def test_joined_attachment(self): + with patch.object( + attachment_gw, "server_urls", {"read": "https://assets.example/fileget"} + ): + self.assertEqual( + self.project(None, "joined.jpg", [ + "CollectionObject", "collectionObjectAttachments", + "attachment", "resourceURL" + ]), + "https://assets.example/fileget" + "?coll=Test+Collection+%26+Images&type=O&filename=joined.jpg", + ) + + def test_read_endpoint_with_query_parameters(self): + with patch.object(attachment_gw, "server_urls", { + "read": "https://assets.example/fileget?existing=value" + }): + self.assertEqual( + self.project(None, "image.jpg"), + "https://assets.example/fileget?existing=value" + "&coll=Test+Collection+%26+Images&type=O&filename=image.jpg", + ) + + def test_other_attachment_fields_are_unchanged(self): + with patch.object( + attachment_gw, "server_urls", {"read": "https://assets.example/fileget"} + ): + self.assertEqual( + self.project(None, "image.jpg", ["Attachment", "attachmentLocation"]), + "image.jpg", + ) + + @override_settings( + WEB_ATTACHMENT_COLLECTION=None, SEPARATE_WEB_ATTACHMENT_FOLDERS=True + ) + def test_separate_collection_folder(self): + with patch.object( + attachment_gw, "server_urls", {"read": "https://assets.example/fileget"} + ): + self.assertEqual( + self.project(None, "image.jpg"), + "https://assets.example/fileget" + "?coll=Separate+Collection&type=O&filename=image.jpg", + ) + + +@override_settings(WEB_ATTACHMENT_COLLECTION="Test Collection") +class TestAttachmentResourceURLResults(SQLAlchemySetup): + def setUp(self): + super().setUp() + server_urls = patch.object( + attachment_gw, "server_urls", {"read": "https://assets.example/fileget"} + ) + server_urls.start() + self.addCleanup(server_urls.stop) + self.attachments = [ + Attachment.objects.create(resourceurl=url, attachmentlocation=location) + for url, location in [ + ("https://external.example/image.jpg", "external.jpg"), + (None, "image.jpg"), + ("", "blank.jpg"), + (None, None), + ] + ] + self.expected = [ + (self.attachments[0].id, "https://external.example/image.jpg"), + (self.attachments[1].id, + "https://assets.example/fileget" + "?coll=Test+Collection&type=O&filename=image.jpg"), + (self.attachments[2].id, ""), + (self.attachments[3].id, None), + ] + self.table, self.fields = make_query_fields_test( + "Attachment", [["resourceURL"]] + ) + + def test_query_results(self): + with TestAttachmentResourceURLResults.test_session_context() as session: + result = execute( + session, self.collection, self.specifyuser, self.table.tableId, + search_synonymy=False, distinct=False, series=False, + count_only=False, query_fields=self.fields, limit=0, offset=0, + ) + self.assertCountEqual(result["results"], self.expected) + + def test_csv_export(self): + with TemporaryDirectory() as directory: + path = Path(directory) / "attachments.csv" + with TestAttachmentResourceURLResults.test_session_context() as session: + query_to_csv( + session, self.collection, self.specifyuser, + self.table.tableId, self.fields, path, + ) + with path.open(newline="") as exported: + rows = list(csv.reader(exported)) + self.assertCountEqual( + rows, + [[str(record_id), url if url is not None else ""] + for record_id, url in self.expected], + ) diff --git a/specifyweb/backend/workbench/upload/parse.py b/specifyweb/backend/workbench/upload/parse.py index d187f81e87d..37d3cc24a80 100644 --- a/specifyweb/backend/workbench/upload/parse.py +++ b/specifyweb/backend/workbench/upload/parse.py @@ -6,6 +6,9 @@ from datetime import datetime from decimal import Decimal +from django.core.exceptions import ValidationError +from django.core.validators import URLValidator + from specifyweb.specify.utils.agent_types import agent_types from specifyweb.backend.stored_queries.format import get_date_format, MYSQL_TO_YEAR, MYSQL_TO_MONTH from specifyweb.specify.datamodel import datamodel, Table, Field, Relationship @@ -19,6 +22,7 @@ 'failedParsingFloat', 'failedParsingBoolean', 'failedParsingAgentType', + 'failedParsingURL', 'invalidYear', 'badDateFormat', @@ -43,6 +47,8 @@ class ParseSucess(NamedTuple): ParseResult = ParseSucess | ParseFailure +validate_resource_url = URLValidator(schemes=['http', 'https']) + def parse_field( table_name: str, @@ -57,7 +63,14 @@ def parse_field( return parse_integer(field.name, raw_value) if formatter is not None: - return parse_formatted(formatter, table, field, raw_value) + result = parse_formatted(formatter, table, field, raw_value) + if ( + table.name == 'Attachment' + and field.name.lower() == 'resourceurl' + and isinstance(result, ParseSucess) + ): + return parse_field(table_name, field_name, result.payload[field.name.lower()]) + return result if is_latlong(table, field): return parse_latlong(field, raw_value) @@ -84,6 +97,11 @@ def parse_field( if hasattr(field, 'length') and field.length is not None and len(raw_value) > field.length: return ParseFailure('valueTooLong', {'field': field_name, 'maxLength': field.length}) + if table.name == 'Attachment' and field.name.lower() == 'resourceurl': + try: + validate_resource_url(raw_value) + except ValidationError: + return ParseFailure('failedParsingURL', {'value': raw_value}) return ParseSucess({field_name.lower(): raw_value}) diff --git a/specifyweb/backend/workbench/upload/parsing.py b/specifyweb/backend/workbench/upload/parsing.py index eadf254ec63..555bab0fe1f 100644 --- a/specifyweb/backend/workbench/upload/parsing.py +++ b/specifyweb/backend/workbench/upload/parsing.py @@ -117,6 +117,14 @@ def _parse(tablename: str, fieldname: str, colopts: ExtendedColumnOptions, value }, colopts.column ) + if ( + isinstance(result, ParseResult) + and table.name == 'Attachment' + and field.name.lower() == 'resourceurl' + ): + parsed_url = parse_field(tablename, fieldname, result.upload[fieldname]) + if isinstance(parsed_url, ParseFailure): + return WorkBenchParseFailure.from_parse_failure(parsed_url, colopts.column) return result parsed = parse_field(tablename, fieldname, value, colopts.uiformatter) diff --git a/specifyweb/backend/workbench/upload/tests/test_attachment_resource_url.py b/specifyweb/backend/workbench/upload/tests/test_attachment_resource_url.py new file mode 100644 index 00000000000..b18091fa1ee --- /dev/null +++ b/specifyweb/backend/workbench/upload/tests/test_attachment_resource_url.py @@ -0,0 +1,107 @@ +from unittest.mock import Mock, patch + +from django.test import SimpleTestCase + +from ..column_options import ExtendedColumnOptions +from ..parse import ParseFailure, ParseSucess, parse_field +from ..parsing import ParseResult, WorkBenchParseFailure, filter_and_upload, parse_value + + +class AttachmentResourceURLTests(SimpleTestCase): + options = ExtendedColumnOptions( + column='Image URL', + matchBehavior='ignoreNever', + nullAllowed=True, + default=None, + disambiguationBehavior='ask', + uiformatter=None, + schemaitem=None, + picklist=None, + dateformat=None, + ) + + def test_accepts_http_and_https(self): + for value in ( + 'http://example.org/image.jpg', + 'https://example.org/resource?id=123#image', + ): + with self.subTest(value=value): + self.assertEqual( + parse_field('Attachment', 'resourceURL', value), + ParseSucess({'resourceurl': value}), + ) + + def test_reports_invalid_urls_against_mapped_column(self): + for value in ( + 'not a URL', + '/relative/path', + 'javascript:alert(1)', + 'ftp://example.org/image.jpg', + 'https://', + 'https://example.org:invalid/image.jpg', + ): + with self.subTest(value=value): + self.assertEqual( + parse_value('Attachment', 'resourceurl', value, self.options), + WorkBenchParseFailure( + 'failedParsingURL', {'value': value}, 'Image URL' + ), + ) + + def test_trims_surrounding_whitespace(self): + result = parse_value( + 'Attachment', 'resourceurl', ' https://example.org/image.jpg ', self.options + ) + self.assertIsInstance(result, ParseResult) + self.assertEqual(result.upload, {'resourceurl': 'https://example.org/image.jpg'}) + + def test_validates_default_url(self): + self.assertEqual( + parse_value( + 'Attachment', 'resourceurl', '', + self.options._replace(default='ftp://example.org/image.jpg'), + ), + WorkBenchParseFailure( + 'failedParsingURL', {'value': 'ftp://example.org/image.jpg'}, 'Image URL' + ), + ) + + def test_formatter_cannot_bypass_url_validation(self): + formatter = Mock(return_value='javascript:alert(1)') + self.assertEqual( + parse_field('Attachment', 'resourceurl', 'image', formatter), + ParseFailure('failedParsingURL', {'value': 'javascript:alert(1)'}), + ) + + @patch('specifyweb.backend.workbench.upload.parsing.parse_with_picklist') + def test_picklist_cannot_bypass_url_validation(self, parse_picklist): + parse_picklist.return_value = filter_and_upload( + {'resourceurl': 'javascript:alert(1)'}, 'Image URL' + ) + self.assertEqual( + parse_value( + 'Attachment', 'resourceurl', 'image', + self.options._replace(picklist=Mock()), + ), + WorkBenchParseFailure( + 'failedParsingURL', {'value': 'javascript:alert(1)'}, 'Image URL' + ), + ) + + def test_allows_blank_optional_url(self): + result = parse_value('Attachment', 'resourceurl', ' ', self.options) + self.assertIsInstance(result, ParseResult) + self.assertEqual(result.upload, {'resourceurl': None}) + + def test_preserves_length_limit(self): + value = 'https://example.org/' + 'a' * 1024 + self.assertEqual( + parse_field('Attachment', 'resourceurl', value), + ParseFailure('valueTooLong', {'field': 'resourceurl', 'maxLength': 1024}), + ) + + def test_does_not_validate_unrelated_text_fields(self): + self.assertEqual( + parse_field('Attachment', 'remarks', 'not a URL'), + ParseSucess({'remarks': 'not a URL'}), + ) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Plugin.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Plugin.tsx index 6b5423b7fb6..5364d542cb4 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Plugin.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Plugin.tsx @@ -13,11 +13,13 @@ import { formsText } from '../../localization/forms'; import { f } from '../../utils/functools'; import type { GetOrSet } from '../../utils/types'; import { Progress } from '../Atoms'; +import { Button } from '../Atoms/Button'; +import { Input, Label } from '../Atoms/Form'; import { LoadingContext, ReadOnlyContext } from '../Core/Contexts'; import { toTable } from '../DataModel/helpers'; import type { AnySchema } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; -import { getTable } from '../DataModel/tables'; +import { getTable, tables } from '../DataModel/tables'; import type { Attachment } from '../DataModel/types'; import { raise } from '../Errors/Crash'; import { loadingBar } from '../Molecules'; @@ -27,19 +29,27 @@ import { ProtectedTable } from '../Permissions/PermissionDenied'; import { collectionPreferences } from '../Preferences/collectionPreferences'; import { userPreferences } from '../Preferences/userPreferences'; import { AttachmentPluginSkeleton } from '../SkeletonLoaders/AttachmentPlugin'; -import { attachmentSettingsPromise, uploadFile } from './attachments'; +import { + MAX_EXTERNAL_RESOURCE_URL_LENGTH, + attachmentSettingsPromise, + validateExternalResourceURL, + uploadFile, +} from './attachments'; import { AttachmentViewer } from './Viewer'; export function AttachmentsPlugin( - props: Parameters[0] + props: { + readonly resource: SpecifyResource | undefined; + } ): JSX.Element | null { const [available] = usePromise(attachmentSettingsPromise, true); - return available === undefined ? null : available ? ( + return available === undefined ? null : ( - + - ) : ( -

{attachmentsText.attachmentServerUnavailable()}

); } @@ -61,8 +71,10 @@ export function useAttachment( function ProtectedAttachmentsPlugin({ resource, + attachmentServerAvailable, }: { readonly resource: SpecifyResource | undefined; + readonly attachmentServerAvailable: boolean; }): JSX.Element | null { const [attachment, setAttachment] = useAttachment(resource); const isReadOnly = React.useContext(ReadOnlyContext); @@ -117,6 +129,7 @@ function ProtectedAttachmentsPlugin({

{formsText.noData()}

) : ( { // Fix focus loss when ) => void; + readonly attachmentServerAvailable?: boolean; }): JSX.Element { + const [externalUrl, setExternalUrl] = React.useState(''); + const [urlError, setUrlError] = React.useState< + 'invalid' | 'tooLong' | undefined + >(undefined); const [uploadProgress, setUploadProgress] = React.useState< number | true | undefined >(undefined); @@ -159,10 +178,26 @@ export function UploadAttachment({ 'attachments', 'attachment.is_public_default' ); + const canUploadFile = attachmentServerAvailable ?? true; - return isFailed ? ( -

{attachmentsText.attachmentServerUnavailable()}

- ) : typeof uploadProgress === 'object' ? ( + const addExternalAttachment = (): void => { + const result = validateExternalResourceURL(externalUrl); + if ('error' in result) { + setUrlError(result.error); + return; + } + setUrlError(undefined); + const { normalizedUrl } = result; + const attachment = new tables.Attachment.Resource({ + resourceurl: normalizedUrl, + origfilename: normalizedUrl, + title: new URL(normalizedUrl).hostname, + isPublic: attachmentIsPublicDefault, + }); + handleUploaded(attachment); + }; + + return typeof uploadProgress === 'object' ? ( ) : ( - - loading( - uploadFile({ - file, - handleProgress: setUploadProgress, - attachmentIsPublicDefault, - }) - .then((attachment) => - attachment === undefined - ? handleFailed() - : handleUploaded(attachment) +
+ {isFailed &&

{attachmentsText.attachmentServerUnavailable()}

} + {canUploadFile && ( + + loading( + uploadFile({ + file, + handleProgress: setUploadProgress, + attachmentIsPublicDefault, + }) + .then((attachment) => + attachment === undefined + ? handleFailed() + : handleUploaded(attachment) + ) + .catch((error) => { + handleFailed(); + raise(error); + }) + .finally(() => setUploadProgress(undefined)) ) - .catch((error) => { - handleFailed(); - raise(error); - }) - .finally(() => setUploadProgress(undefined)) - ) - } - /> + } + /> + )} +
+ + {attachmentsText.externalResourceUrl()} + { + if (event.key === 'Enter') { + event.preventDefault(); + addExternalAttachment(); + } + }} + onValueChange={(value): void => { + setExternalUrl(value); + setUrlError(undefined); + }} + /> + + {urlError && ( + + )} + + {attachmentsText.addExternalResource()} + +
+
); } diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/README.md b/specifyweb/frontend/js_src/lib/components/Attachments/README.md index 2c6d85b8619..9e5bda6de73 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/README.md +++ b/specifyweb/frontend/js_src/lib/components/Attachments/README.md @@ -1,3 +1,9 @@ # Attachments -Note: asset server needs to be configured for the attachments to work. +Uploaded file attachments require the asset server to be configured. External +HTTP(S) resource URLs can be attached to records and opened without an asset +server. + +WorkBench validates mapped `Attachment.resourceURL` values during validation and +upload. Non-empty values must be valid HTTP(S) URLs; optional URL cells may be +left blank. diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx index e3ca273134c..a65455a82a6 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx @@ -32,6 +32,7 @@ import { userPreferences } from '../Preferences/userPreferences'; import { fetchOriginalUrl, fetchThumbnail, + normalizeExternalResourceURL, reportAttachmentServerFailure, useAttachmentServerStatus, } from './attachments'; @@ -58,13 +59,17 @@ export function AttachmentViewer({ () => serializeResource(attachment), [attachment] ); + const externalResourceURL = normalizeExternalResourceURL( + attachment.get('resourceURL') ?? '' + ); const [originalUrl] = useAsyncState( React.useCallback( async () => + externalResourceURL === undefined && attachmentServerStatus !== 'unavailable' ? fetchOriginalUrl(serialized) : undefined, - [attachmentServerStatus, serialized] + [attachmentServerStatus, externalResourceURL, serialized] ), false ); @@ -118,10 +123,11 @@ export function AttachmentViewer({ const [thumbnail] = useAsyncState( React.useCallback( async () => + externalResourceURL === undefined && attachmentServerStatus !== 'unavailable' ? fetchThumbnail(serialized) : undefined, - [attachmentServerStatus, serialized] + [attachmentServerStatus, externalResourceURL, serialized] ), false ); @@ -154,7 +160,11 @@ export function AttachmentViewer({ return ( <>
- {attachmentServerStatus === 'unavailable' ? ( + {externalResourceURL !== undefined ? ( + + {attachmentsText.openExternalResource()} + + ) : attachmentServerStatus === 'unavailable' ? ( ) : displayOriginal === 'full' && !isTiffImage ? ( originalUrl === undefined ? ( diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/UploadAttachment.test.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/UploadAttachment.test.tsx index 25267303627..1e3a144b25a 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/UploadAttachment.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/UploadAttachment.test.tsx @@ -42,12 +42,18 @@ describe('UploadAttachment', () => { const handleUploaded = jest.fn(); overrideAttachmentSettings(attachmentSettings); - const { asFragment, container, user } = mount( + const { container, getByRole, user } = mount( - + ); - expect(asFragment()).toMatchSnapshot(); + expect(container.querySelector('input[type="file"]')).not.toBeNull(); + expect( + getByRole('textbox', { name: 'External resource URL' }) + ).toBeInTheDocument(); const input = Array.from(container.getElementsByTagName('input'))[0]; const testFile = new File(['Some Text Contents'], 'testName', { @@ -61,4 +67,76 @@ describe('UploadAttachment', () => { expect(handleUploaded).toHaveBeenCalled(); }); }); + + test('creates external URL attachments without the asset server', async () => { + const handleUploaded = jest.fn(); + const { getByRole, user } = mount( + + + + ); + + await user.type( + getByRole('textbox', { name: 'External resource URL' }), + 'https://example.org/specimen/123' + ); + await user.click(getByRole('button', { name: 'Add external URL' })); + + expect(handleUploaded).toHaveBeenCalledTimes(1); + const [attachment] = handleUploaded.mock.calls[0]; + expect(attachment.get('resourceURL')).toBe( + 'https://example.org/specimen/123' + ); + expect(attachment.get('origFilename')).toBe( + 'https://example.org/specimen/123' + ); + }); + + test('rejects non-HTTP external URLs', async () => { + const handleUploaded = jest.fn(); + const { getByRole, user } = mount( + + + + ); + + await user.type( + getByRole('textbox', { name: 'External resource URL' }), + 'javascript:alert(1)' + ); + await user.click(getByRole('button', { name: 'Add external URL' })); + + expect(handleUploaded).not.toHaveBeenCalled(); + expect(getByRole('alert')).toHaveTextContent( + 'Enter a valid HTTP or HTTPS URL.' + ); + }); + + test('rejects normalized URLs that exceed the field limit', async () => { + const handleUploaded = jest.fn(); + const { getByRole, user } = mount( + + + + ); + + const input = `https://example.org/${'a '.repeat(502)}`; + const urlInput = getByRole('textbox', { name: 'External resource URL' }); + fireEvent.change(urlInput, { target: { value: input } }); + await user.click(getByRole('button', { name: 'Add external URL' })); + + expect(handleUploaded).not.toHaveBeenCalled(); + expect(getByRole('alert')).toHaveTextContent( + 'URL must be 1,024 characters or fewer.' + ); + }); }); diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/AttachmentCell.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/AttachmentCell.test.tsx.snap index c527650144e..e8e0a6edb19 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/AttachmentCell.test.tsx.snap +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/AttachmentCell.test.tsx.snap @@ -123,6 +123,7 @@ exports[`AttachmentCell simple render 2`] = ` "remarks": null, "repositoryagreementattachments": "/api/specify/repositoryagreementattachment/?attachment=5490", "resource_uri": "/api/specify/attachment/5490/", + "resourceurl": null, "scopeid": null, "scopetype": null, "spdatasetattachments": "/api/specify/spdatasetattachment/?attachment=5490", diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/UploadAttachment.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/UploadAttachment.test.tsx.snap deleted file mode 100644 index d1c14d3f3dc..00000000000 --- a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/UploadAttachment.test.tsx.snap +++ /dev/null @@ -1,32 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`UploadAttachment simple render 1`] = ` - - - -`; diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/normalizeExternalResourceURL.test.ts b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/normalizeExternalResourceURL.test.ts new file mode 100644 index 00000000000..536fb4c0e12 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/normalizeExternalResourceURL.test.ts @@ -0,0 +1,34 @@ +import { + MAX_EXTERNAL_RESOURCE_URL_LENGTH, + normalizeExternalResourceURL, +} from '../attachments'; + +describe('normalizeExternalResourceURL', () => { + test.each([ + ['https://example.org/resource', 'https://example.org/resource'], + [' http://example.org/resource ', 'http://example.org/resource'], + ['javascript:alert(1)', undefined], + ['/relative/path', undefined], + ['not a URL', undefined], + ])('normalizes %s', (input, expected) => { + expect(normalizeExternalResourceURL(input)).toBe(expected); + }); + + test('accepts normalized URLs at the field limit', () => { + const prefix = 'https://example.org/'; + const input = `${prefix}${'a'.repeat( + MAX_EXTERNAL_RESOURCE_URL_LENGTH - prefix.length + )}`; + + expect(normalizeExternalResourceURL(input)).toHaveLength( + MAX_EXTERNAL_RESOURCE_URL_LENGTH + ); + }); + + test('rejects URLs that exceed the field limit after normalization', () => { + const input = `https://example.org/${'a '.repeat(502)}`; + + expect(input.length).toBeLessThanOrEqual(MAX_EXTERNAL_RESOURCE_URL_LENGTH); + expect(normalizeExternalResourceURL(input)).toBeUndefined(); + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/utils.ts b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/utils.ts index d6428c50c18..1ca71f740e1 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/utils.ts +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/utils.ts @@ -16,6 +16,7 @@ export const testAttachment = { mimeType: 'image/jpeg', origFilename: 'topbrc4.jpg', remarks: null, + resourceURL: null, scopeID: null, scopeType: null, subjectOrientation: null, diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts index eca55affc7c..256158fea1d 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts +++ b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts @@ -86,6 +86,34 @@ export const attachmentSettingsPromise = load>( export const attachmentsAvailable = (): boolean => typeof settings === 'object'; +export const MAX_EXTERNAL_RESOURCE_URL_LENGTH = 1024; + +type ExternalResourceURLValidation = + | { readonly normalizedUrl: string } + | { readonly error: 'invalid' | 'tooLong' }; + +export function validateExternalResourceURL( + value: string +): ExternalResourceURLValidation { + try { + const url = new URL(value.trim()); + if (url.protocol !== 'http:' && url.protocol !== 'https:') + return { error: 'invalid' }; + if (url.href.length > MAX_EXTERNAL_RESOURCE_URL_LENGTH) + return { error: 'tooLong' }; + return { normalizedUrl: url.href }; + } catch { + return { error: 'invalid' }; + } +} + +export function normalizeExternalResourceURL( + value: string +): string | undefined { + const result = validateExternalResourceURL(value); + return 'normalizedUrl' in result ? result.normalizedUrl : undefined; +} + const checkAttachmentServer = async (): Promise => { const { status } = await ajax('/attachment_gw/health/', { cache: 'no-store', @@ -272,6 +300,13 @@ export async function fetchThumbnail( attachment: SerializedResource, scale = getPref('attachment.preview_size') ): Promise { + if (normalizeExternalResourceURL(attachment.resourceURL ?? '') !== undefined) + return { + ...iconForMimeType('text/html'), + width: scale, + height: scale, + }; + const mimeType = attachment.mimeType ?? undefined; const thumbnail = mimeType === undefined || thumbnailable.has(mimeType) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/types.ts b/specifyweb/frontend/js_src/lib/components/DataModel/types.ts index 55992cb2255..ac80b80240f 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/types.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/types.ts @@ -666,6 +666,7 @@ export type Attachment = { readonly mimeType: string | null; readonly origFilename: string; readonly remarks: string | null; + readonly resourceURL: string | null; readonly scopeID: number | null; readonly scopeType: number | null; readonly subjectOrientation: string | null; diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/__snapshots__/formatters.test.ts.snap b/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/__snapshots__/formatters.test.ts.snap index 81a5cf42986..986d20183a9 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/__snapshots__/formatters.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/__snapshots__/formatters.test.ts.snap @@ -2203,6 +2203,7 @@ exports[`getMainTableFields 1`] = ` "guid", "licenseLogoUrl", "metadataText", + "resourceURL", "subjectOrientation", "subtype", "title", diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts index 79833ac223c..0e08e7b975c 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts @@ -1,3 +1,4 @@ +import { attachmentsText } from '../../../localization/attachments'; import { backEndText } from '../../../localization/backEnd'; import { requireContext } from '../../../tests/helpers'; @@ -5,6 +6,12 @@ import { resolveValidationMessage } from '../resultsParser'; requireContext(); +test('resolves invalid attachment URLs to the form validation message', () => { + expect( + resolveValidationMessage('failedParsingURL', { value: 'javascript:alert(1)' }) + ).toBe(attachmentsText.invalidExternalResourceUrl()); +}); + describe('resolveValidationMessage business-rule handling', () => { test('formats childFieldNotUnique and appends conflicting record ids', () => { const message = resolveValidationMessage('notAParsingKey', { diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts index c057d78d478..2e3949e40a8 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts @@ -1,5 +1,6 @@ import type { LocalizedString } from 'typesafe-i18n'; +import { attachmentsText } from '../../localization/attachments'; import { backEndText } from '../../localization/backEnd'; import type { IR, RA, RR } from '../../utils/types'; import { localized } from '../../utils/types'; @@ -20,6 +21,8 @@ export const backendParsingMessageResolvers: RR< string, PayloadMessageResolver > = { + failedParsingURL: (): LocalizedString => + attachmentsText.invalidExternalResourceUrl(), failedParsingBoolean: (payload): LocalizedString => backEndText.failedParsingBoolean({ value: payload.value as string }), failedParsingDecimal: (payload): LocalizedString => diff --git a/specifyweb/frontend/js_src/lib/localization/attachments.ts b/specifyweb/frontend/js_src/lib/localization/attachments.ts index 349e61652d5..59acfa9f854 100644 --- a/specifyweb/frontend/js_src/lib/localization/attachments.ts +++ b/specifyweb/frontend/js_src/lib/localization/attachments.ts @@ -42,6 +42,53 @@ export const attachmentsText = createDictionary({ 'hr-hr': 'Poslužitelj priloga nije dostupan', nb: 'Vedleggsserveren utilgjengelig', }, + addExternalResource: { + 'en-us': 'Add external URL', + 'ru-ru': 'Добавить внешнюю ссылку', + 'es-es': 'Añadir URL externa', + 'fr-fr': 'Ajouter une URL externe', + 'uk-ua': 'Додати зовнішню URL-адресу', + 'de-ch': 'Externe URL hinzufügen', + 'pt-br': 'Adicionar URL externo', + 'hr-hr': 'Dodaj vanjski URL', + nb: 'Legg til ekstern URL', + }, + externalResourceUrl: { + 'en-us': 'External resource URL', + 'ru-ru': 'Ссылка на внешний ресурс', + 'es-es': 'URL del recurso externo', + 'fr-fr': 'URL de la ressource externe', + 'uk-ua': 'URL-адреса зовнішнього ресурсу', + 'de-ch': 'URL der externen Ressource', + 'pt-br': 'URL do recurso externo', + 'hr-hr': 'URL vanjskog resursa', + nb: 'URL for ekstern ressurs', + }, + invalidExternalResourceUrl: { + 'en-us': 'Enter a valid HTTP or HTTPS URL.', + 'ru-ru': 'Введите действительный URL-адрес HTTP или HTTPS.', + 'es-es': 'Introduce una URL HTTP o HTTPS válida.', + 'fr-fr': 'Saisissez une URL HTTP ou HTTPS valide.', + 'uk-ua': 'Введіть дійсну URL-адресу HTTP або HTTPS.', + 'de-ch': 'Geben Sie eine gültige HTTP- oder HTTPS-URL ein.', + 'pt-br': 'Insira uma URL HTTP ou HTTPS válida.', + 'hr-hr': 'Unesite valjan HTTP ili HTTPS URL.', + nb: 'Skriv inn en gyldig HTTP- eller HTTPS-URL.', + }, + externalResourceUrlTooLong: { + 'en-us': 'URL must be 1,024 characters or fewer.', + }, + openExternalResource: { + 'en-us': 'Open external resource', + 'ru-ru': 'Открыть внешний ресурс', + 'es-es': 'Abrir recurso externo', + 'fr-fr': 'Ouvrir la ressource externe', + 'uk-ua': 'Відкрити зовнішній ресурс', + 'de-ch': 'Externe Ressource öffnen', + 'pt-br': 'Abrir recurso externo', + 'hr-hr': 'Otvori vanjski resurs', + nb: 'Åpne ekstern ressurs', + }, attachmentServerUnavailableDescription: { 'en-us': 'Setup your attachment server', 'es-es': 'Configura tu servidor de archivos adjuntos', diff --git a/specifyweb/frontend/js_src/lib/tests/ajax/static/context/datamodel.json b/specifyweb/frontend/js_src/lib/tests/ajax/static/context/datamodel.json index 247bcfc2b47..3e203aed26e 100644 --- a/specifyweb/frontend/js_src/lib/tests/ajax/static/context/datamodel.json +++ b/specifyweb/frontend/js_src/lib/tests/ajax/static/context/datamodel.json @@ -2531,6 +2531,15 @@ "type": "text", "length": 4096 }, + { + "name": "resourceURL", + "column": "ResourceURL", + "indexed": false, + "unique": false, + "required": false, + "type": "java.lang.String", + "length": 1024 + }, { "name": "scopeID", "column": "ScopeID", diff --git a/specifyweb/specify/datamodel.py b/specifyweb/specify/datamodel.py index 7c6d3f86326..f09b978438d 100644 --- a/specifyweb/specify/datamodel.py +++ b/specifyweb/specify/datamodel.py @@ -589,6 +589,7 @@ def is_tree_table(table: Table): Field(name='mimeType', column='MimeType', indexed=False, unique=False, required=False, type='java.lang.String', length=1024), Field(name='origFilename', column='OrigFilename', indexed=False, unique=False, required=True, type='text', length=65535), Field(name='remarks', column='Remarks', indexed=False, unique=False, required=False, type='text', length=4096), + Field(name='resourceURL', column='ResourceURL', indexed=False, unique=False, required=False, type='java.lang.String', length=1024), Field(name='scopeID', column='ScopeID', indexed=True, unique=False, required=False, type='java.lang.Integer'), Field(name='scopeType', column='ScopeType', indexed=True, unique=False, required=False, type='java.lang.Byte'), Field(name='subjectOrientation', column='SubjectOrientation', indexed=False, unique=False, required=False, type='java.lang.String', length=64), diff --git a/specifyweb/specify/migrations/0049_add_attachment_resource_url.py b/specifyweb/specify/migrations/0049_add_attachment_resource_url.py new file mode 100644 index 00000000000..d34634aada8 --- /dev/null +++ b/specifyweb/specify/migrations/0049_add_attachment_resource_url.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('specify', '0048_taxontreedefitem_parent_context_delete'), + ] + + operations = [ + migrations.AddField( + model_name='attachment', + name='resourceurl', + field=models.CharField( + blank=True, + db_column='ResourceURL', + max_length=1024, + null=True, + ), + ), + ] diff --git a/specifyweb/specify/models.py b/specifyweb/specify/models.py index 0c791d2f070..bb5d665441e 100644 --- a/specifyweb/specify/models.py +++ b/specifyweb/specify/models.py @@ -592,6 +592,7 @@ class Attachment(models.Model): mimetype = models.CharField(blank=True, max_length=1024, null=True, unique=False, db_column='MimeType', db_index=False) origfilename = models.TextField(blank=False, null=False, unique=False, db_column='OrigFilename', db_index=False) remarks = models.TextField(blank=True, null=True, unique=False, db_column='Remarks', db_index=False) + resourceurl = models.CharField(blank=True, max_length=1024, null=True, unique=False, db_column='ResourceURL', db_index=False) scopeid = models.IntegerField(blank=True, null=True, unique=False, db_column='ScopeID', db_index=False) scopetype = models.SmallIntegerField(blank=True, null=True, unique=False, db_column='ScopeType', db_index=False) subjectorientation = models.CharField(blank=True, max_length=64, null=True, unique=False, db_column='SubjectOrientation', db_index=False)