diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index b18f6b7f7..e8a9ea1dd 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -59,6 +59,7 @@ export default withMermaid( { text: "Data Import", link: "/guide/data-import" }, { text: "Bulk Operations", link: "/guide/bulk-operations" }, { text: "Media Management", link: "/guide/media" }, + { text: "Redaction", link: "/guide/redaction" }, { text: "Notifications", link: "/guide/notifications" }, { text: "Map Visualization", link: "/guide/map-visualization" }, { text: "Dynamic Fields", link: "/guide/dynamic-fields" }, diff --git a/docs/guide/redaction.md b/docs/guide/redaction.md new file mode 100644 index 000000000..b53c78b5c --- /dev/null +++ b/docs/guide/redaction.md @@ -0,0 +1,50 @@ +# Redaction + +Bayanat includes a built-in redaction tool for permanently removing sensitive information from documents and images, directly in the browser. Use it to black out names, faces, addresses, signatures, or any detail that must be protected before a file is shared, exported, or published. + +Redaction is available to Admin and Data Analyst roles, from any PDF or image attached to a Bulletin. + +## True Redaction, Not a Cover-Up + +The distinction matters for human rights work, where a leaked identity can put someone at risk. Bayanat does not simply draw a black box on top of the content: + +- **PDFs** — the text and graphics beneath each box are permanently deleted from the file, and document metadata is scrubbed. There is no hidden layer to copy, search, or peel back. +- **Images** — the selected regions are painted out and the image is re-encoded, so the original pixels are gone from the redacted file. + +What you see is what remains. Nothing sensitive survives underneath. + +## The Original Is Never Touched + +Redaction always produces a **separate redacted copy**. Your original, unredacted file stays intact and attached to the Bulletin as the authoritative master. This means you can: + +- Keep the complete evidence internally while sharing only a safe version +- Produce different redactions of the same source for different audiences +- Re-redact later if more information needs protecting, always working from the clean original + +::: tip Immutable by design +The original media can never be overwritten by the redaction tool. Edits only ever apply to a redacted copy. +::: + +## How to Redact a File + +1. Open a PDF or image from a Bulletin's media +2. Choose the redact option to open the redaction editor +3. Draw boxes over the regions to remove. For multi-page PDFs, move through the pages and mark regions on each. +4. Save as a redacted copy + +The new copy is created alongside the original, tagged with the **Redaction** media category and grouped with its source in the media view so the relationship is always clear. + +## Revising a Redacted Copy + +If you need to adjust a redaction, you can reopen a redacted copy and either save the result as another new copy or overwrite that copy in place. Overwriting is only ever permitted on a redacted copy; the original remains protected. + +## Supported Files + +- **Documents:** PDF +- **Images:** JPG, JPEG, PNG + +## Good Practice + +- Redact from the original each time rather than redacting a redaction, so you always start from a known-clean source. +- Review the saved copy before sharing it, especially multi-page documents, to confirm every sensitive region was covered. +- Share or export only the redacted copy. Keep the original within Bayanat under its normal access controls. diff --git a/enferno/admin/models/Media.py b/enferno/admin/models/Media.py index e0d4c7fc9..61bb3723c 100644 --- a/enferno/admin/models/Media.py +++ b/enferno/admin/models/Media.py @@ -102,6 +102,8 @@ def to_dict(self) -> dict[str, Any]: DateHelper.serialize_datetime(self.updated_at) if self.updated_at else None ), "extraction": self.extraction.to_compact_dict() if self.extraction else None, + "isRedaction": self.redaction is not None, + "originalMediaId": self.redaction.original_media_id if self.redaction else None, } def to_json(self) -> str: diff --git a/enferno/admin/models/MediaRedaction.py b/enferno/admin/models/MediaRedaction.py new file mode 100644 index 000000000..8192c3b8e --- /dev/null +++ b/enferno/admin/models/MediaRedaction.py @@ -0,0 +1,46 @@ +from typing import Any + +from enferno.extensions import db +from enferno.utils.base import BaseMixin +from enferno.utils.date_helper import DateHelper + + +class MediaRedaction(db.Model, BaseMixin): + __tablename__ = "media_redaction" + + id = db.Column(db.Integer, primary_key=True) + source_media_id = db.Column( + db.Integer, + db.ForeignKey("media.id"), + nullable=False, + index=True, + ) + original_media_id = db.Column( + db.Integer, + db.ForeignKey("media.id"), + nullable=True, + index=True, + ) + result_media_id = db.Column( + db.Integer, + db.ForeignKey("media.id"), + nullable=False, + index=True, + ) + regions = db.Column(db.JSON, nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey("user.id", ondelete="SET NULL"), index=True) + + source_media = db.relationship("Media", foreign_keys=[source_media_id]) + original_media = db.relationship("Media", foreign_keys=[original_media_id]) + result_media = db.relationship("Media", foreign_keys=[result_media_id], backref=db.backref("redaction", uselist=False)) + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "source_media_id": self.source_media_id, + "original_media_id": self.original_media_id, + "result_media_id": self.result_media_id, + "regions": self.regions, + "user_id": self.user_id, + "created_at": DateHelper.serialize_datetime(self.created_at), + } diff --git a/enferno/admin/models/__init__.py b/enferno/admin/models/__init__.py index a3b57dca8..71a3c33c9 100644 --- a/enferno/admin/models/__init__.py +++ b/enferno/admin/models/__init__.py @@ -38,6 +38,7 @@ from .LocationType import LocationType from .Media import Media from .MediaCategory import MediaCategory +from .MediaRedaction import MediaRedaction from .PotentialViolation import PotentialViolation from .Query import Query from .Settings import Settings diff --git a/enferno/admin/templates/admin/actors.html b/enferno/admin/templates/admin/actors.html index 50d75b80a..cbd43d099 100644 --- a/enferno/admin/templates/admin/actors.html +++ b/enferno/admin/templates/admin/actors.html @@ -6,6 +6,7 @@ {% endblock %} {% block content %} + {% include 'admin/partials/media_transcription_dialog.html' %} {% include 'admin/partials/actor_drawer.html' %} {% include 'admin/partials/bulk_actor_drawer.html' %} @@ -402,6 +403,7 @@ + @@ -449,6 +451,7 @@ valid: false, leftDialogProps: null, rightDialogProps: null, + redaction: { dialog: false, media: null }, translations: window.translations, validationRules: validationRules, advFeatures: ('{{ config.ADV_ANALYSIS }}' === 'True'), @@ -834,6 +837,41 @@ this.refreshEditedMedia(media.id); if (this.$route.params.id) this.showActor(this.$route.params.id); }, + openRedactor(media) { + this.redaction.media = media; + this.redaction.dialog = true; + }, + removeRedaction(redaction) { + if (!redaction.isRedaction) { + this.showSnack(window.translations.onlyRedactionsCanBeDeleted_); + return; + } + api.delete(`/admin/api/media/${redaction.id}/redact`) + .then(() => { + this.onRedactionDeleted(redaction); + }) + .catch(err => { + this.showSnack(handleRequestError(err)); + }); + }, + refreshRedactionState() { + const id = this.$route.params.id || this.actor?.id; + if (id) { + this.showActor(id); + } else if (this.editedItem?.id) { + api.get(`/admin/api/actor/${this.editedItem.id}`).then(response => { + this.editedItem.medias = response.data?.medias; + }).catch(err => this.showSnack(handleRequestError(err))); + } + }, + onRedactionSaved() { + this.showSnack("{{ _('Redaction saved') }}"); + this.refreshRedactionState(); + }, + onRedactionDeleted() { + this.showSnack("{{ _('Redaction deleted') }}"); + this.refreshRedactionState(); + }, handleRoute({ initialLoad = false } = {}) { if (this.$route.params.id) this.showActor(this.$route.params.id); if (this.$route.query.reltob) return this.filter_related_to_bulletin(this.$route.query.reltob); @@ -1816,6 +1854,7 @@ app.component('FieldRenderer', FieldRenderer); app.component('OcrTextLayer', OcrTextLayer); app.component('MediaTranscriptionDialog', MediaTranscriptionDialog); + app.component('MediaRedactor', MediaRedactor); app.component('RelatedBulletinsCard', RelatedBulletinsCard); app.component('RelatedActorsCard', RelatedActorsCard); diff --git a/enferno/admin/templates/admin/bulletins.html b/enferno/admin/templates/admin/bulletins.html index 290bdbcfb..0263ef312 100644 --- a/enferno/admin/templates/admin/bulletins.html +++ b/enferno/admin/templates/admin/bulletins.html @@ -11,6 +11,7 @@ + {% include 'admin/partials/media_transcription_dialog.html' %} {% include 'admin/partials/bulletin_advsearch.html' %} {% include 'admin/partials/bulletin_drawer.html' %} @@ -423,6 +424,7 @@ + @@ -478,6 +480,7 @@ data: () => ({ itemsPerPageOptions: window.itemsPerPageOptions, helper: {}, // helper object for review items + redaction: { dialog: false, media: null }, valid: false, leftDialogProps: { @@ -916,6 +919,43 @@ this.refreshEditedMedia(media.id); if (this.$route.params.id) this.showBulletin(this.$route.params.id); }, + openRedactor(media) { + this.redaction.media = media; + this.redaction.dialog = true; + }, + removeRedaction(redaction) { + if (!redaction.isRedaction) { + this.showSnack(window.translations.onlyRedactionsCanBeDeleted_); + return; + } + api.delete(`/admin/api/media/${redaction.id}/redact`) + .then(() => { + this.onRedactionDeleted(redaction); + }) + .catch(err => { + this.showSnack(handleRequestError(err)); + }); + }, + refreshRedactionState() { + const id = this.$route.params.id || this.bulletin?.id; + if (id) { + // Media redacted on bulletin drawer, refresh the bulletin to get the updated media list + this.showBulletin(id); + } else if (this.editedItem?.id) { + // Media redacted on bulletin editor dialog, refresh the bulletin to get the updated media list + api.get(`/admin/api/bulletin/${this.editedItem.id}`).then(response => { + this.editedItem.medias = response.data?.medias; + }).catch(err => this.showSnack(handleRequestError(err))); + } + }, + onRedactionSaved() { + this.showSnack("{{ _('Redaction saved') }}"); + this.refreshRedactionState(); + }, + onRedactionDeleted() { + this.showSnack("{{ _('Redaction deleted') }}"); + this.refreshRedactionState(); + }, handleRoute({ initialLoad = false } = {}) { if (this.$route.params.id) this.showBulletin(this.$route.params.id); if (this.$route.query.reltob) return this.filter_related_to_bulletin(this.$route.query.reltob); @@ -1346,29 +1386,9 @@ showBulletin(id) { this.bulletinLoader = true - - // Save current s3urls before reload - const s3urlCache = {}; - if (this.bulletin?.medias) { - this.bulletin.medias.forEach(media => { - if (media.s3url) { - s3urlCache[media.id] = media.s3url; - } - }); - } - api.get(`/admin/api/bulletin/${id}?mode=3`).then(response => { this.bulletin = response.data; this.bulletinDrawer = true; - - // Restore s3urls after reload - if (this.bulletin?.medias) { - this.bulletin.medias.forEach(media => { - if (s3urlCache[media.id]) { - media.s3url = s3urlCache[media.id]; - } - }); - } }).catch(error => { this.bulletinDrawer = false; }).finally(() => { @@ -1817,6 +1837,7 @@ app.component('EventsSection', EventsSection); app.component('FieldRenderer', FieldRenderer); app.component('OcrTextLayer', OcrTextLayer); + app.component('MediaRedactor', MediaRedactor); app.component('MediaTranscriptionDialog', MediaTranscriptionDialog); app.use(router).use(vuetify); diff --git a/enferno/admin/templates/admin/jsapi.jinja2 b/enferno/admin/templates/admin/jsapi.jinja2 index 21910b908..d95b84d17 100644 --- a/enferno/admin/templates/admin/jsapi.jinja2 +++ b/enferno/admin/templates/admin/jsapi.jinja2 @@ -627,6 +627,11 @@ etag_ : "{{ _('File Hash') }}", filename_ : "{{ _('Filename') }}", previewNotAvailable_ : "{{ _('Preview not available') }}", downloadFile_ : "{{ _('Download file') }}", +onlyRedactionsCanBeDeleted_: "{{ _('Only redactions can be deleted') }}", +redact_: "{{ _('Redact') }}", +youreAboutToDeleteARedactedCopy_: "{{ _('You\'re about to delete a redacted copy') }}", +deletingTheRedactedCopyWillNotDeleteTheOriginalMedia_: "{{ _('Deleting this redacted copy will not delete the original media.') }}", +deleteRedaction_: "{{ _('Delete Redaction') }}", // System Administration defaultsLoaded_: "{{ _('Default settings loaded. Click Save to apply changes.') }}", @@ -843,4 +848,3 @@ translations['whisperModels'] = [ {"en": "{{ model['model_label'] }}", "tr": "{{ _(model['model_label']) }}"}, {% endfor %} ]; - diff --git a/enferno/admin/templates/admin/media-dashboard.html b/enferno/admin/templates/admin/media-dashboard.html index e6f603fee..411aebad8 100644 --- a/enferno/admin/templates/admin/media-dashboard.html +++ b/enferno/admin/templates/admin/media-dashboard.html @@ -48,6 +48,12 @@ + + ${formatDate(item.updated_at)} + + @@ -234,6 +256,7 @@ + @@ -256,6 +279,8 @@ drawer: drawer, loading: true, bulkLoading: false, + isCurrentUserAdmin: window.__isAdmin__ || false, + isCurrentUserDA: window.__isDA__ || false, itemsPerPageOptions: window.itemsPerPageOptions, hasOcrProvider: Boolean('{{ config.OCR_PROVIDER }}'), processingIds: new Set(), // Track media IDs being processed @@ -271,7 +296,8 @@ { title: "{{_('Filename')}}", value: "filename" }, { title: "{{_('Bulletin')}}", value: "bulletin", sortable: false }, { title: "{{_('OCR Status')}}", value: "ocr_status" }, - { title: "{{_('Date')}}", value: "updated_at" } + { title: "{{_('Date')}}", value: "updated_at" }, + { title: "{{_('Actions')}}", value: "actions", sortable: false, align: "end" } ], selected: [], @@ -290,6 +316,10 @@ bulletin_id: null, dateRange: null }, + redaction: { + dialog: false, + media: null + }, lowWordCount: 20 }), @@ -498,6 +528,24 @@ // Also fetch processing IDs in case this item just finished this.fetchProcessingIds(); }, + canRedact(item) { + if (!this.isCurrentUserAdmin && !this.isCurrentUserDA) return false; + const fileType = item.fileType || ''; + const filename = item.filename || ''; + return fileType.includes('pdf') + || fileType.includes('image') + || /\.(pdf|jpe?g|png)$/i.test(filename); + }, + openRedactor(item) { + if (!this.canRedact(item)) return; + this.redaction.media = item; + this.redaction.dialog = true; + }, + onMediaRedacted() { + this.showSnack("{{ _('Redaction saved') }}"); + this.refresh(this.options); + this.loadOCRStats(); + }, loadOCRStats() { api.get('/admin/api/ocr/stats').then(res => { this.ocrStats = res?.data; @@ -538,6 +586,7 @@ app.component('OcrTextLayer', OcrTextLayer); app.component('MediaTranscriptionDialog', MediaTranscriptionDialog); app.component('PdfViewer', PdfViewer); + app.component('MediaRedactor', MediaRedactor); app.component('NativePdfViewer', NativePdfViewer); app.component('DocxViewer', DocxViewer); app.component('ImageViewer', ImageViewer); @@ -553,4 +602,4 @@ window.app = app; }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/enferno/admin/templates/admin/partials/media_dialog.html b/enferno/admin/templates/admin/partials/media_dialog.html index dfc7bdccf..5771b5fef 100644 --- a/enferno/admin/templates/admin/partials/media_dialog.html +++ b/enferno/admin/templates/admin/partials/media_dialog.html @@ -39,6 +39,7 @@ :medias="editedItem.medias" @media-click="handleExpandedMedia({ rendererId: 'media-dialog', ...$event }); this.$refs.bulletinDialogSplitViewRef?.resetToCenter()" @remove-media="removeMedia" + @remove-redaction="removeRedaction($event)" > diff --git a/enferno/admin/views/media.py b/enferno/admin/views/media.py index d3bc3b651..07e3dfc8a 100644 --- a/enferno/admin/views/media.py +++ b/enferno/admin/views/media.py @@ -4,6 +4,7 @@ import shutil from datetime import datetime, timedelta from functools import wraps +from hashlib import md5 from typing import Optional import boto3 @@ -24,13 +25,19 @@ from sqlalchemy import func, desc from werkzeug.utils import safe_join, secure_filename -from enferno.admin.models import Media, Activity, Extraction +from enferno.admin.models import Media, Activity, Extraction, MediaRedaction, MediaCategory from enferno.admin.models.tables import bulletin_roles, actor_roles from enferno.extensions import db, rds from enferno.utils.date_helper import DateHelper from enferno.utils.data_helpers import get_file_hash from enferno.utils.http_response import HTTPResponse from enferno.utils.logging_utils import get_logger +from enferno.utils.redaction_utils import ( + RedactionError, + redact_image_bytes, + redact_pdf_bytes, + rotate_rect_to_original, +) from enferno.utils.text_utils import normalize_arabic from enferno.utils.validation_utils import validate_with from enferno.admin.validation.models import MediaRequestModel @@ -111,6 +118,61 @@ def _media_url(media_file): ) +def _read_media_bytes(media: Media) -> bytes: + if current_app.config.get("FILESYSTEM_LOCAL"): + filepath = safe_join(str(Media.media_dir), media.media_file) + if not filepath or not os.path.exists(filepath): + raise FileNotFoundError(media.media_file) + with open(filepath, "rb") as f: + return f.read() + + s3 = boto3.client( + "s3", + config=BotoConfig(signature_version="s3v4"), + aws_access_key_id=current_app.config["AWS_ACCESS_KEY_ID"], + aws_secret_access_key=current_app.config["AWS_SECRET_ACCESS_KEY"], + region_name=current_app.config["AWS_REGION"], + ) + return s3.get_object(Bucket=current_app.config["S3_BUCKET"], Key=media.media_file)[ + "Body" + ].read() + + +def _write_media_bytes(filename: str, data: bytes, content_type: str) -> None: + if current_app.config.get("FILESYSTEM_LOCAL"): + filepath = safe_join(str(Media.media_dir), filename) + if not filepath: + raise ValueError("Invalid media filename") + with open(filepath, "wb") as f: + f.write(data) + return + + s3 = boto3.resource( + "s3", + aws_access_key_id=current_app.config["AWS_ACCESS_KEY_ID"], + aws_secret_access_key=current_app.config["AWS_SECRET_ACCESS_KEY"], + region_name=current_app.config["AWS_REGION"], + ) + s3.Bucket(current_app.config["S3_BUCKET"]).put_object( + Key=filename, + Body=data, + ContentType=content_type, + ) + + +def _file_extension(media: Media) -> str: + return os.path.splitext(media.media_file)[1].lower().lstrip(".") + + +def _duplicate_redacted_media(etag: str, source: Media) -> Media | None: + query = Media.query.filter(Media.etag == etag, Media.deleted == False) + if source.bulletin_id is not None: + query = query.filter(Media.bulletin_id == source.bulletin_id) + elif source.actor_id is not None: + query = query.filter(Media.actor_id == source.actor_id) + return query.first() + + # Media dashboard page route @admin.route("/media/", defaults={"id": None}) @admin.route("/media/") @@ -613,6 +675,156 @@ def api_media_update(id: t.id, validated_data: dict) -> Response: return HTTPResponse.error("Error updating Media", status=500) +REDACTION_CATEGORY = "Redaction" + + +def _redaction_category_id() -> int: + """Resolve (creating once if missing) the category that tags redacted copies.""" + category = MediaCategory.find_by_title(REDACTION_CATEGORY) + if category is None: + category = MediaCategory(title=REDACTION_CATEGORY) + db.session.add(category) + db.session.flush() + return category.id + + +@admin.post("/api/media//redact") +@roles_accepted("Admin", "DA") +def api_media_redact(id: int) -> Response: + media = Media.query.get(id) + if media is None: + return HTTPResponse.not_found("Media not found") + if not current_user.can_access(media): + return HTTPResponse.forbidden("Restricted Access") + + payload = request.get_json(silent=True) or {} + pages = payload.get("pages", []) + title = (payload.get("title") or "").strip() + # Overwrite is only allowed on an existing redacted copy (never the immutable original). + overwrite = bool(payload.get("overwrite")) and media.redaction is not None + ext = _file_extension(media) + + try: + src = _read_media_bytes(media) + if ext == "pdf": + out = redact_pdf_bytes(src, pages) + out_ext = "pdf" + out_type = "application/pdf" + elif ext in {"jpg", "jpeg", "png"} or (media.media_file_type or "").startswith("image/"): + orientation = media.orientation or 0 + rects = [ + rotate_rect_to_original(rect, orientation) + for page in pages + for rect in page.get("rects", []) + ] + out = redact_image_bytes(src, rects) + out_ext = "jpg" + out_type = "image/jpeg" + else: + return HTTPResponse.error("This file type cannot be redacted", status=415) + except RedactionError as e: + return HTTPResponse.error(str(e), status=400) + except FileNotFoundError: + return HTTPResponse.not_found("Media file not found") + + etag = md5(out).hexdigest() + + # Edit in place: re-burn onto the same redacted copy, overwriting its file and row. + # The original stays untouched, so this is always safe and reversible from the original. + if overwrite: + _write_media_bytes(media.media_file, out, out_type) + media.etag = etag + media.media_file_type = out_type + if title: + media.title = title + media.redaction.source_media_id = media.id + media.redaction.regions = pages + media.redaction.user_id = current_user.id + db.session.commit() + Activity.create( + current_user, + Activity.ACTION_UPDATE, + Activity.STATUS_SUCCESS, + media.to_mini(), + "media", + details=f"Redacted copy {media.id} updated in place", + ) + return HTTPResponse.success(data=media.to_dict()) + + if _duplicate_redacted_media(etag, media): + return HTTPResponse.error("Redacted media already exists", status=409) + + filename = Media.generate_file_name(f"redacted.{out_ext}") + _write_media_bytes(filename, out, out_type) + + redacted = Media( + media_file=filename, + media_file_type=out_type, + etag=etag, + title=title or f"{media.title or media.media_file} (redacted)", + title_ar=media.title_ar, + comments=media.comments, + comments_ar=media.comments_ar, + category=_redaction_category_id(), + time=media.time, + orientation=media.orientation, + user_id=current_user.id, + bulletin_id=media.bulletin_id, + actor_id=media.actor_id, + ) + original_id = ( + media.redaction.original_media_id + if media.redaction and media.redaction.original_media_id + else media.id + ) + audit = MediaRedaction( + source_media_id=media.id, + original_media_id=original_id, + result_media=redacted, + regions=pages, + user_id=current_user.id, + ) + db.session.add(redacted) + db.session.add(audit) + db.session.commit() + + Activity.create( + current_user, + Activity.ACTION_CREATE, + Activity.STATUS_SUCCESS, + redacted.to_mini(), + "media", + details=f"Redacted copy created from media {media.id}", + ) + return HTTPResponse.success(data=redacted.to_dict()) + + +@admin.delete("/api/media//redact") +@roles_accepted("Admin", "DA") +def api_media_redact_delete(id: int) -> Response: + # Soft-delete a redacted copy only. The immutable original is never touched here. + media = Media.query.get(id) + if media is None: + return HTTPResponse.not_found("Media not found") + if not current_user.can_access(media): + return HTTPResponse.forbidden("Restricted Access") + if media.redaction is None: + return HTTPResponse.error("Not a redacted copy", status=400) + + media.deleted = True + db.session.commit() + + Activity.create( + current_user, + Activity.ACTION_DELETE, + Activity.STATUS_SUCCESS, + media.to_mini(), + "media", + details=f"Redacted copy {media.id} deleted", + ) + return HTTPResponse.success(data={"id": media.id, "deleted": True}) + + # OCR Extraction endpoints @admin.get("/api/media/dashboard") @auth_required("session") @@ -681,8 +893,6 @@ def api_media_dashboard(): def _media_dashboard_item(media): """Serialize a media item for the dashboard. Bypasses @check_roles since access is already enforced at the query level.""" - from enferno.admin.models import MediaCategory - media_category = MediaCategory.query.get(media.category) if media.category else None item = { "id": media.id, diff --git a/enferno/app.py b/enferno/app.py index 000c85aaa..d20bcaf7b 100755 --- a/enferno/app.py +++ b/enferno/app.py @@ -32,6 +32,7 @@ ) from enferno.admin.views import admin from enferno.data_import.views import imports +from enferno.utils.soft_delete import register_soft_delete from enferno.extensions import ( db, migrate, @@ -121,6 +122,7 @@ def register_extensions(app): """ db.init_app(app) migrate.init_app(app, db) + register_soft_delete(db) # Skip debug toolbar when CSP is enabled (they conflict) if not app.config.get("CSP_ENABLED", False): debug_toolbar.init_app(app) diff --git a/enferno/data/media_categories.csv b/enferno/data/media_categories.csv index d60b6637f..0af080e4a 100644 --- a/enferno/data/media_categories.csv +++ b/enferno/data/media_categories.csv @@ -2,3 +2,4 @@ id,title,title_tr,created_at,updated_at,deleted 1,Generic,,2023-11-07 22:18:28.791477,2023-11-07 22:18:28.791477,f 2,Humans,,2023-11-07 22:18:28.791477,2023-11-07 22:18:28.791477,f 3,Signs/Text,,2023-11-07 22:18:28.791477,2023-11-07 22:18:28.791477,f +4,Redaction,,2023-11-07 22:18:28.791477,2023-11-07 22:18:28.791477,f diff --git a/enferno/static/js/components/ActorCard.js b/enferno/static/js/components/ActorCard.js index f0f820eba..8bd69d2a4 100644 --- a/enferno/static/js/components/ActorCard.js +++ b/enferno/static/js/components/ActorCard.js @@ -50,6 +50,11 @@ const ActorCard = Vue.defineComponent({ } return false; }, + removeRedaction(redaction) { + if (typeof this.$root.removeRedaction === 'function') { + this.$root.removeRedaction(redaction); + } + }, loadRevisions() { this.hloading = true; axios @@ -428,7 +433,7 @@ const ActorCard = Vue.defineComponent({ > - + diff --git a/enferno/static/js/components/BulletinCard.js b/enferno/static/js/components/BulletinCard.js index 12c077eb5..5ae32f9ac 100644 --- a/enferno/static/js/components/BulletinCard.js +++ b/enferno/static/js/components/BulletinCard.js @@ -57,6 +57,11 @@ const BulletinCard = Vue.defineComponent({ return false; }, + removeRedaction(redaction) { + if (typeof this.$root.removeRedaction === 'function') { + this.$root.removeRedaction(redaction); + } + }, loadRevisions() { this.hloading = true; @@ -261,6 +266,7 @@ const BulletinCard = Vue.defineComponent({ :renderer-id="mediaRendererId" :media="$root.expandedByRenderer?.[mediaRendererId]?.media" :media-type="$root.expandedByRenderer?.[mediaRendererId]?.mediaType" + :initial-orientation="$root.expandedByRenderer?.[mediaRendererId]?.media?.orientation || 0" @ready="$root.onMediaRendererReady" @fullscreen="$root.handleFullscreen(mediaRendererId)" @close="$root.closeExpandedMedia(mediaRendererId)" @@ -268,7 +274,7 @@ const BulletinCard = Vue.defineComponent({ - + diff --git a/enferno/static/js/components/MediaCard.js b/enferno/static/js/components/MediaCard.js index 49e188e59..80b46500b 100644 --- a/enferno/static/js/components/MediaCard.js +++ b/enferno/static/js/components/MediaCard.js @@ -42,6 +42,18 @@ const toolbarContent = ` {{ ocrButtonState.text }} + + + +
+ Redact document + Draw black boxes to censor sensitive areas,
then save a new redacted copy
+
+
@@ -113,9 +125,13 @@ const MediaCard = Vue.defineComponent({ miniMode: { type: Boolean, default: false, - } + }, + redactions: { + type: Array, + default: () => [], + }, }, - emits: ['media-click'], + emits: ['media-click', 'remove-redaction'], data() { return { s3url: '', @@ -134,6 +150,7 @@ const MediaCard = Vue.defineComponent({ ocrDetails: null, ocrLoading: false, expansionPanel: null, + showRedactions: false, }; }, computed: { @@ -165,6 +182,17 @@ const MediaCard = Vue.defineComponent({ visible, disabled }; + }, + redactButtonState() { + // Only on pages that mounted the redactor, for redactable types, Admin/DA + if (typeof this.$root?.openRedactor !== 'function') return; + const isRedactable = this.mediaType === 'pdf' || this.mediaType === 'image'; + const visible = (this.isCurrentUserAdmin || this.isCurrentUserDA) && isRedactable; + return { + text: this.translations.redact_, + visible, + disabled: !this.media?.id, + }; } }, methods: { @@ -202,6 +230,17 @@ const MediaCard = Vue.defineComponent({ .catch(() => this.$root.showSnack('Failed to load OCR text')) .finally(() => this.ocrLoading = false); }, + confirmRemoveRedaction(redaction) { + this.$root.$confirm({ + title: this.translations.youreAboutToDeleteARedactedCopy_, + message: `${this.translations.deletingTheRedactedCopyWillNotDeleteTheOriginalMedia_}\r\n\r\n${this.translations.doYouWantToContinue_}`, + acceptProps: { text: this.translations.deleteRedaction_, color: 'error' }, + dialogProps: { width: 780 }, + onAccept: () => { + this.$emit('remove-redaction', redaction); + }, + }); + }, copyToClipboard(text) { navigator.clipboard.writeText(text) .then(() => this.$root.showSnack('Copied to clipboard')) @@ -270,8 +309,8 @@ const MediaCard = Vue.defineComponent({ - {{ translations.extractedText_ || 'Extracted Text' }} - {{ media.extraction?.word_count }} {{ translations.words_ || 'words' }} + {{ translations.extractedText_ }} + {{ media.extraction?.word_count }} {{ translations.words_ }} @@ -285,6 +324,68 @@ const MediaCard = Vue.defineComponent({ + + @@ -292,4 +393,4 @@ const MediaCard = Vue.defineComponent({ ` -}); \ No newline at end of file +}); diff --git a/enferno/static/js/components/MediaGrid.js b/enferno/static/js/components/MediaGrid.js index 0e7cd9baa..0e9bb972f 100644 --- a/enferno/static/js/components/MediaGrid.js +++ b/enferno/static/js/components/MediaGrid.js @@ -6,42 +6,57 @@ const MediaGrid = Vue.defineComponent({ prioritizeVideos: Boolean, miniMode: Boolean, }, - emits: ['remove-media', 'media-click'], + emits: ['remove-media', 'remove-redaction', 'media-click'], computed: { - sortedMedia() { - if (this.prioritizeVideos) return this.sortMediaByFileType(this.medias); - - return this.medias; - } + primaryMedia() { + const list = this.prioritizeVideos ? this.sortMediaByFileType(this.medias) : (this.medias || []); + return list.filter(media => !this.isRedaction(media)); + }, + redactionsBySource() { + const map = {}; + for (const media of (this.medias || [])) { + if (!this.isRedaction(media)) continue; + const srcId = media.originalMediaId; + if (srcId == null) continue; + if (!map[srcId]) map[srcId] = []; + map[srcId].push(media); + } + return map; + }, }, methods: { + isRedaction(media) { + return media?.originalMediaId != null; + }, + mediaIndex(media) { + return this.medias.indexOf(media); + }, sortMediaByFileType(mediaList) { if (!mediaList) return []; - // Sort media list by fileType (video first) - const sortedMediaList = [...mediaList].sort((a, b) => { - if (a?.fileType?.includes('video')) return -1; // Video should come first - if (b?.fileType?.includes('video')) return 1; // Then images - return 0; // Leave unchanged if neither is a video + return [...mediaList].sort((a, b) => { + if (a?.fileType?.includes('video')) return -1; + if (b?.fileType?.includes('video')) return 1; + return 0; }); - - return sortedMediaList; }, }, template: /*html*/` -
- - - - - -
+
+ + + + + +
`, -}); \ No newline at end of file +}); diff --git a/enferno/static/js/components/MediaRedactor.js b/enferno/static/js/components/MediaRedactor.js new file mode 100644 index 000000000..24848bf0e --- /dev/null +++ b/enferno/static/js/components/MediaRedactor.js @@ -0,0 +1,608 @@ +const REDACTOR_MAX_ZOOM = 6; + +const MediaRedactor = Vue.defineComponent({ + props: { + modelValue: { type: Boolean, default: false }, + media: { type: Object, default: null }, + }, + emits: ['update:modelValue', 'redacted'], + + data() { + return { + REDACTOR_MAX_ZOOM, + loading: false, + saving: false, + error: null, + label: '', + pages: [], + boxes: {}, + draft: null, + drag: null, + resize: null, + hoveredBox: null, + activeBox: null, + spaceDown: false, + panning: null, + zoom: 1, + baseWidth: 0, + pinchStartDistance: null, + pinchStartZoom: 1, + }; + }, + + computed: { + show: { + get() { + return this.modelValue; + }, + set(value) { + this.$emit('update:modelValue', value); + }, + }, + src() { + if (!this.media?.id) return null; + // Bust the browser cache when a copy is re-edited in place (filename stays, bytes change). + const v = this.media.etag ? `?v=${this.media.etag}` : ''; + return `/admin/api/media/${this.media.id}/proxy${v}`; + }, + isRedactedCopy() { + return !!(this.media?.isRedaction || this.media?.originalMediaId); + }, + mediaKind() { + const fileType = this.media?.fileType || ''; + if (fileType.includes('pdf')) return 'pdf'; + if (fileType.includes('image')) return 'image'; + const filename = this.media?.filename || ''; + if (filename.toLowerCase().endsWith('.pdf')) return 'pdf'; + if (/\.(jpe?g|png)$/i.test(filename)) return 'image'; + return 'unsupported'; + }, + canSubmit() { + return Object.values(this.boxes).some(pageBoxes => pageBoxes.length); + }, + orientation() { + return this.media?.orientation || 0; + }, + imageStyle() { + return (page) => { + const o = this.orientation; + if ((o === 90 || o === 270) && page.width && page.height) { + // Container: width=CW, height=CW*(W/H) (swapped aspect-ratio H/W). + // Image pre-rotation must be W×H to visually fill CW×(CW*W/H) after rotating. + // width as % of CW: W/H * 100% + // height as % of containerHeight (CW*W/H): need CW → CW/(CW*W/H) = H/W → (H/W)*100% + return { + position: 'absolute', + top: '50%', + left: '50%', + width: `${(page.width / page.height) * 100}%`, + height: `${(page.height / page.width) * 100}%`, + objectFit: 'fill', + transform: `translate(-50%, -50%) rotate(${o}deg)`, + }; + } + return { transform: `rotate(${o}deg)`, width: '100%' }; + }; + }, + }, + + watch: { + async show(value) { + if (value) { + this.load(); + await this.$nextTick(); + const el = this.$refs.scrollPane?.$el ?? this.$refs.scrollPane; + if (el) { + this._scrollPane = el; + el.addEventListener('touchmove', this._touchMoveHandler, { passive: false }); + el.addEventListener('wheel', this._wheelHandler, { passive: false }); + } + window.addEventListener('keydown', this._keydownHandler); + window.addEventListener('keyup', this._keyupHandler); + } else { + this._scrollPane?.removeEventListener('touchmove', this._touchMoveHandler); + this._scrollPane?.removeEventListener('wheel', this._wheelHandler); + this._scrollPane = null; + window.removeEventListener('keydown', this._keydownHandler); + window.removeEventListener('keyup', this._keyupHandler); + this.reset(); + } + }, + media() { + if (this.show) this.load(); + }, + }, + + created() { + this._pdf = null; + this._loadId = 0; + this._touchMoveHandler = (e) => this.onTouchMove(e); + this._wheelHandler = (e) => this.onWheel(e); + this._keydownHandler = (e) => this.onKeydown(e); + this._keyupHandler = (e) => this.onKeyup(e); + }, + + beforeUnmount() { + this._scrollPane?.removeEventListener('touchmove', this._touchMoveHandler); + this._scrollPane?.removeEventListener('wheel', this._wheelHandler); + window.removeEventListener('keydown', this._keydownHandler); + window.removeEventListener('keyup', this._keyupHandler); + this._pdf?.destroy?.(); + }, + + methods: { + reset() { + this._loadId++; + this.loading = false; + this.saving = false; + this.error = null; + this.label = ''; + this.pages = []; + this.boxes = {}; + this.draft = null; + this.drag = null; + this.resize = null; + this.hoveredBox = null; + this.activeBox = null; + this.spaceDown = false; + this.panning = null; + this.zoom = 1; + this.baseWidth = 0; + this.pinchStartDistance = null; + this.pinchStartZoom = 1; + this._pdf?.destroy?.(); + this._pdf = null; + }, + async load() { + this.reset(); + if (!this.src) return; + if (this.mediaKind === 'pdf') return this.loadPdf(); + if (this.mediaKind === 'image') { + this.pages = [{ index: 0, width: 1, height: 1, rendered: true }]; + this.boxes = { 0: [] }; + return; + } + this.error = 'Unsupported file type'; + }, + async loadPdf() { + const loadId = this._loadId; + this.loading = true; + try { + await loadScript('/static/js/pdf.js/pdf.min.mjs'); + pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.js/pdf.worker.min.mjs'; + const pdf = await pdfjsLib.getDocument({ url: this.src, disableStream: true, disableRange: true }).promise; + if (loadId !== this._loadId) { pdf.destroy(); return; } + this._pdf = pdf; + const pages = []; + for (let index = 0; index < pdf.numPages; index++) { + const page = await pdf.getPage(index + 1); + if (loadId !== this._loadId) return; + const viewport = page.getViewport({ scale: 1 }); + pages.push({ index, width: viewport.width, height: viewport.height }); + this.boxes[index] = []; + await page.cleanup(); + } + if (loadId !== this._loadId) return; + this.pages = pages; + this.loading = false; + await this.$nextTick(); + for (const page of pages) { + if (loadId !== this._loadId) return; + await this.renderPdfPage(page.index); + } + } catch (e) { + if (loadId !== this._loadId) return; + console.error('PDF redactor load failed:', e); + this.error = 'Failed to load document'; + this.loading = false; + } + }, + canvasRef(index) { + const ref = this.$refs[`redactCanvas-${index}`]; + return Array.isArray(ref) ? ref[0] : ref; + }, + async renderPdfPage(index) { + const canvas = this.canvasRef(index); + if (!canvas || !this._pdf) return; + const pdfPage = await this._pdf.getPage(index + 1); + const viewport = pdfPage.getViewport({ scale: 1.5 }); + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.floor(viewport.width * dpr); + canvas.height = Math.floor(viewport.height * dpr); + canvas.style.width = '100%'; + canvas.style.height = 'auto'; + const ctx = canvas.getContext('2d', { alpha: false }); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.fillStyle = '#fff'; + ctx.fillRect(0, 0, viewport.width, viewport.height); + await pdfPage.render({ canvasContext: ctx, viewport }).promise; + await pdfPage.cleanup(); + this.pages = this.pages.map(page => + page.index === index ? { ...page, width: viewport.width, height: viewport.height } : page + ); + }, + pageRect(index) { + return this.$refs[`page-${index}`]?.[0]?.getBoundingClientRect?.() + || this.$refs[`page-${index}`]?.getBoundingClientRect?.(); + }, + normalizedPoint(index, event) { + const rect = this.pageRect(index); + const x = Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1); + const y = Math.min(Math.max((event.clientY - rect.top) / rect.height, 0), 1); + return { x, y }; + }, + startBox(index, event) { + if (event.button !== 0 || this.spaceDown) return; + const point = this.normalizedPoint(index, event); + this.draft = { page: index, startX: point.x, startY: point.y, x: point.x, y: point.y, w: 0, h: 0 }; + }, + startDrag(pageIndex, boxIndex, event) { + if (event.button !== 0) return; + event.stopPropagation(); + this.activeBox = { page: pageIndex, box: boxIndex }; + const point = this.normalizedPoint(pageIndex, event); + const box = this.boxes[pageIndex][boxIndex]; + this.drag = { page: pageIndex, boxIndex, startMouseX: point.x, startMouseY: point.y, origX: box.x, origY: box.y }; + }, + // corner: 'tl' | 'tr' | 'bl' | 'br' — the corner being dragged; opposite corner is the anchor + startResize(pageIndex, boxIndex, corner, event) { + if (event.button !== 0) return; + event.stopPropagation(); + this.activeBox = { page: pageIndex, box: boxIndex }; + const box = this.boxes[pageIndex][boxIndex]; + const anchorX = corner.includes('l') ? box.x + box.w : box.x; + const anchorY = corner.includes('t') ? box.y + box.h : box.y; + this.resize = { page: pageIndex, boxIndex, anchorX, anchorY }; + }, + moveBox(index, event) { + if (this.resize && this.resize.page === index) { + const point = this.normalizedPoint(index, event); + const { anchorX, anchorY, boxIndex } = this.resize; + const x = Math.min(Math.max(Math.min(point.x, anchorX), 0), 1); + const y = Math.min(Math.max(Math.min(point.y, anchorY), 0), 1); + const w = Math.min(Math.abs(point.x - anchorX), 1 - x); + const h = Math.min(Math.abs(point.y - anchorY), 1 - y); + const pageBoxes = [...(this.boxes[index] || [])]; + pageBoxes[boxIndex] = { x, y, w, h }; + this.boxes = { ...this.boxes, [index]: pageBoxes }; + return; + } + if (this.drag && this.drag.page === index) { + const point = this.normalizedPoint(index, event); + const dx = point.x - this.drag.startMouseX; + const dy = point.y - this.drag.startMouseY; + const pageBoxes = [...(this.boxes[index] || [])]; + const box = pageBoxes[this.drag.boxIndex]; + pageBoxes[this.drag.boxIndex] = { + ...box, + x: Math.min(Math.max(this.drag.origX + dx, 0), 1 - box.w), + y: Math.min(Math.max(this.drag.origY + dy, 0), 1 - box.h), + }; + this.boxes = { ...this.boxes, [index]: pageBoxes }; + return; + } + if (!this.draft || this.draft.page !== index) return; + const point = this.normalizedPoint(index, event); + const x0 = Math.min(this.draft.startX, point.x); + const y0 = Math.min(this.draft.startY, point.y); + const x1 = Math.max(this.draft.startX, point.x); + const y1 = Math.max(this.draft.startY, point.y); + this.draft = { ...this.draft, x: x0, y: y0, w: x1 - x0, h: y1 - y0 }; + }, + finishBox() { + if (this.resize) { this.resize = null; return; } + if (this.drag) { this.drag = null; return; } + if (!this.draft) return; + if (this.draft.w > 0.005 && this.draft.h > 0.005) { + const pageBoxes = this.boxes[this.draft.page] || []; + pageBoxes.push({ x: this.draft.x, y: this.draft.y, w: this.draft.w, h: this.draft.h }); + this.boxes = { ...this.boxes, [this.draft.page]: pageBoxes }; + } + this.draft = null; + }, + deleteBox(pageIndex, boxIndex) { + const pageBoxes = [...(this.boxes[pageIndex] || [])]; + pageBoxes.splice(boxIndex, 1); + this.boxes = { ...this.boxes, [pageIndex]: pageBoxes }; + if (this.hoveredBox?.page === pageIndex && this.hoveredBox?.box === boxIndex) this.hoveredBox = null; + if (this.activeBox?.page === pageIndex && this.activeBox?.box === boxIndex) this.activeBox = null; + }, + onKeydown(event) { + if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') return; + if (event.code === 'Space' && !this.spaceDown) { + event.preventDefault(); + this.spaceDown = true; + return; + } + if (event.key !== 'Backspace' && event.key !== 'Delete') return; + const target = this.activeBox || this.hoveredBox; + if (!target) return; + event.preventDefault(); + this.deleteBox(target.page, target.box); + }, + onKeyup(event) { + if (event.code === 'Space') { + this.spaceDown = false; + this.panning = null; + } + }, + onPanStart(event) { + if (!this.spaceDown || event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + const pane = this._scrollPane; + if (!pane) return; + this.panning = { startX: event.clientX, startY: event.clientY, scrollLeft: pane.scrollLeft, scrollTop: pane.scrollTop }; + }, + onPanMove(event) { + if (!this.panning) return; + event.preventDefault(); + const pane = this._scrollPane; + if (!pane) return; + pane.scrollLeft = this.panning.scrollLeft - (event.clientX - this.panning.startX); + pane.scrollTop = this.panning.scrollTop - (event.clientY - this.panning.startY); + }, + onPanEnd() { + this.panning = null; + }, + visibleBoxes(pageIndex) { + const boxes = [...(this.boxes[pageIndex] || [])]; + if (this.draft?.page === pageIndex) boxes.push(this.draft); + return boxes; + }, + zoomIn() { + this.zoom = Math.min(+(this.zoom + 0.25).toFixed(2), REDACTOR_MAX_ZOOM); + }, + zoomOut() { + this.zoom = Math.max(+(this.zoom - 0.25).toFixed(2), 1); + }, + zoomFit() { + this.zoom = 1; + }, + pinchDistance(touches) { + const dx = touches[0].clientX - touches[1].clientX; + const dy = touches[0].clientY - touches[1].clientY; + return Math.sqrt(dx * dx + dy * dy); + }, + onTouchStart(event) { + if (event.touches.length === 2) { + this.pinchStartDistance = this.pinchDistance(event.touches); + this.pinchStartZoom = this.zoom; + } + }, + onTouchMove(event) { + if (event.touches.length === 2 && this.pinchStartDistance) { + event.preventDefault(); + const scale = this.pinchDistance(event.touches) / this.pinchStartDistance; + const next = Math.min(Math.max(this.pinchStartZoom * scale, 1), REDACTOR_MAX_ZOOM); + const midX = (event.touches[0].clientX + event.touches[1].clientX) / 2; + const midY = (event.touches[0].clientY + event.touches[1].clientY) / 2; + this.applyZoom(next, midX, midY); + } + }, + onTouchEnd() { + this.pinchStartDistance = null; + }, + applyZoom(nextZoom, anchorX, anchorY) { + const pane = this._scrollPane; + if (!pane) { this.zoom = nextZoom; return; } + if (!this.baseWidth) { + const wrapper = pane.querySelector('.redactor-pages'); + this.baseWidth = wrapper ? wrapper.getBoundingClientRect().width : pane.clientWidth; + } + const rect = pane.getBoundingClientRect(); + const localX = anchorX !== undefined ? anchorX - rect.left : pane.clientWidth / 2; + const localY = anchorY !== undefined ? anchorY - rect.top : pane.clientHeight / 2; + const worldX = pane.scrollLeft + localX; + const worldY = pane.scrollTop + localY; + const ratio = nextZoom / this.zoom; + this.zoom = nextZoom; + this.$nextTick(() => { + pane.scrollLeft = worldX * ratio - localX; + pane.scrollTop = worldY * ratio - localY; + }); + }, + onWheel(event) { + if (!event.ctrlKey) return; + event.preventDefault(); + const factor = Math.exp(-event.deltaY * 0.005); + const next = Math.min(Math.max(this.zoom * factor, 1), REDACTOR_MAX_ZOOM); + this.applyZoom(next, event.clientX, event.clientY); + }, + async submit(overwrite = false) { + if (!this.canSubmit) return; + this.saving = true; + const pages = Object.entries(this.boxes) + .filter(([, rects]) => rects.length) + .map(([page, rects]) => ({ page: Number(page), rects })); + try { + const response = await api.post(`/admin/api/media/${this.media.id}/redact`, { pages, title: this.label.trim(), overwrite }); + this.$emit('redacted', response.data.data); + this.show = false; + } catch (e) { + this.error = e.response?.data?.message || 'Failed to save redaction'; + } finally { + this.saving = false; + } + }, + }, + + template: /*html*/` + + + + + + Redaction Tool + — {{ media?.title || media?.filename || 'document' }} + + + + + + Draw at least one black box on the document to save + + + + + + Click & drag to draw a black box + Drag box to reposition + Click box then Delete to remove + Hold Space + drag to pan + + + {{ Math.round(zoom * 100) }}% + + Fit + + + {{ error }} + +
+ +
+
+
+ + + + +
+
+
+
+
+ `, +}); diff --git a/enferno/static/js/components/MediaThumbnail.js b/enferno/static/js/components/MediaThumbnail.js index 5ac7ba626..e1ec3eecf 100644 --- a/enferno/static/js/components/MediaThumbnail.js +++ b/enferno/static/js/components/MediaThumbnail.js @@ -62,15 +62,24 @@ const MediaThumbnail = Vue.defineComponent({ immediate: true, handler(newUrl) { if (!newUrl) return; - + const hasExistingThumbnail = this.thumbnailUrl || this.imageLoaded; const isGenerating = this.isGeneratingThumbnail; - + if (!hasExistingThumbnail && !isGenerating) { this.initThumbnail(); } } - } + }, + media(newMedia, oldMedia) { + if (newMedia?.etag && newMedia.etag !== oldMedia?.etag) { + this.s3url = ''; + this.imageLoaded = false; + this.thumbnailUrl = null; + this.hasError = false; + this.init().catch(() => {}); + } + }, }, methods: { setupIntersectionObserver() { @@ -99,8 +108,9 @@ const MediaThumbnail = Vue.defineComponent({ try { const response = await api.get(`/admin/api/media/${this.media.filename}`); - this.s3url = response.data.url; - this.media.s3url = response.data.url; + const etag = this.media.etag; + this.s3url = etag ? `${response.data.url}?v=${etag}` : response.data.url; + this.media.s3url = this.s3url; this.initThumbnail(); } catch (error) { console.error('Error fetching media:', error); @@ -263,28 +273,23 @@ const MediaThumbnail = Vue.defineComponent({ return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; }, getImageStyle(orientation) { - const baseStyle = { + const base = { objectFit: 'cover', - transform: `rotate(${orientation}deg)`, - maxWidth: '100%', - maxHeight: '100%', - transition: 'opacity 0.4s ease-in-out' + transition: 'opacity 0.4s ease-in-out', + position: 'absolute', + top: '50%', + left: '50%', + transform: `translate(-50%, -50%) rotate(${orientation}deg)`, }; - - // For 90 or 270 degree rotations, we need to swap dimensions if (orientation === 90 || orientation === 270) { - return { - ...baseStyle, - width: 'auto', - height: '100%' - }; + // Pre-rotation width becomes visual height after 90°/270° rotation, and vice versa. + // To fill a W×H container: pre-rotation box must be H×W (swapped). + // CSS can express this with container query units: 100cqw = containerW, 100cqh = containerH. + // So: pre-rotation width = containerH (100cqh), pre-rotation height = containerW (100cqw). + // The parent needs container-type set; the root div already has overflow:hidden. + return { ...base, width: '100cqh', height: '100cqw' }; } - - return { - ...baseStyle, - width: '100%', - height: 'auto' - }; + return { ...base, width: '100%', height: '100%' }; }, getRandomGradient() { if (this.randomGradient) return this.randomGradient; @@ -334,7 +339,7 @@ const MediaThumbnail = Vue.defineComponent({ }, }, template: /*html*/` -
+
mdi-magnify-plus @@ -360,12 +365,11 @@ const MediaThumbnail = Vue.defineComponent({ style="z-index: 2;"> - 1 or y + h > 1: + raise RedactionError("Redaction coordinates must be normalized within the page") + + +def _absolute_rect(rect: dict, width: float, height: float) -> fitz.Rect: + _validate_rect(rect) + return fitz.Rect( + rect["x"] * width, + rect["y"] * height, + (rect["x"] + rect["w"]) * width, + (rect["y"] + rect["h"]) * height, + ) + + +def _page_specs_by_index(doc: fitz.Document, pages: list[dict]) -> dict[int, list[dict]]: + specs = {} + for spec in pages: + page_index = spec.get("page") + if not isinstance(page_index, int) or page_index < 0 or page_index >= doc.page_count: + raise RedactionError("Redaction page index is invalid") + rects = spec.get("rects", []) + if not isinstance(rects, list) or not rects: + raise RedactionError("Redaction page must contain at least one rectangle") + specs.setdefault(page_index, []).extend(rects) + if not specs: + raise RedactionError("No redaction regions provided") + return specs + + +def redact_pdf_bytes(src: bytes, pages: list[dict]) -> bytes: + doc = fitz.open(stream=src, filetype="pdf") + try: + for page_index, rects in _page_specs_by_index(doc, pages).items(): + page = doc[page_index] + for rect in rects: + page.add_redact_annot( + _absolute_rect(rect, page.rect.width, page.rect.height), + fill=(0, 0, 0), + ) + page.apply_redactions() + + doc.scrub() + return doc.tobytes(garbage=4, deflate=True) + finally: + doc.close() + + +def rotate_rect_to_original(rect: dict, orientation: int) -> dict: + """Map a normalized rect drawn on a rotated image back to the original image's coordinate space.""" + x, y, w, h = rect["x"], rect["y"], rect["w"], rect["h"] + if orientation == 90: + return {"x": y, "y": 1 - x - w, "w": h, "h": w} + if orientation == 180: + return {"x": 1 - x - w, "y": 1 - y - h, "w": w, "h": h} + if orientation == 270: + return {"x": 1 - y - h, "y": x, "w": h, "h": w} + return rect + + +def redact_image_bytes(src: bytes, rects: list[dict]) -> bytes: + if not rects: + raise RedactionError("No redaction regions provided") + + img = Image.open(io.BytesIO(src)).convert("RGB") + draw = ImageDraw.Draw(img) + width, height = img.size + for rect in rects: + box = _absolute_rect(rect, width, height) + draw.rectangle([box.x0, box.y0, box.x1, box.y1], fill=(0, 0, 0)) + + out = io.BytesIO() + img.save(out, format="JPEG", quality=90) + return out.getvalue() diff --git a/enferno/utils/soft_delete.py b/enferno/utils/soft_delete.py new file mode 100644 index 000000000..2dd189aae --- /dev/null +++ b/enferno/utils/soft_delete.py @@ -0,0 +1,35 @@ +"""Global soft-delete filter for Media. + +`deleted` lives on BaseMixin so every model carries it, but only Media is +actually soft-deleted in practice. Rather than remembering a manual +`.filter(deleted == False)` at every read site (the source of recurring +"deleted media still shows up" bugs), this injects the predicate into every +ORM SELECT that touches Media, including lazy relationship loads such as +`bulletin.medias`. + +Opt out for the rare route that must reach an already-deleted row by id: + + Media.query.execution_options(include_deleted=True).get(id) +""" + +from sqlalchemy import event +from sqlalchemy.orm import Session, with_loader_criteria + +from enferno.admin.models import Media + + +def _filter_soft_deleted_media(state): + if ( + state.is_select + and not state.is_column_load + and not state.execution_options.get("include_deleted", False) + ): + state.statement = state.statement.options( + with_loader_criteria(Media, Media.deleted.is_(False), include_aliases=True) + ) + + +def register_soft_delete(db): + """Attach the soft-delete filter to the session. Idempotent.""" + if not event.contains(Session, "do_orm_execute", _filter_soft_deleted_media): + event.listen(Session, "do_orm_execute", _filter_soft_deleted_media) diff --git a/migrations/versions/68396035f041_add_original_media_id_to_media_redaction.py b/migrations/versions/68396035f041_add_original_media_id_to_media_redaction.py new file mode 100644 index 000000000..d2d09c244 --- /dev/null +++ b/migrations/versions/68396035f041_add_original_media_id_to_media_redaction.py @@ -0,0 +1,36 @@ +"""add original_media_id to media_redaction + +Revision ID: 68396035f041 +Revises: e4f2a9c8d7b1 +Create Date: 2026-06-10 14:16:17.368157 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '68396035f041' +down_revision = 'e4f2a9c8d7b1' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('media_redaction', schema=None) as batch_op: + batch_op.add_column(sa.Column('original_media_id', sa.Integer(), nullable=True)) + batch_op.create_index(batch_op.f('ix_media_redaction_original_media_id'), ['original_media_id'], unique=False) + batch_op.create_foreign_key(None, 'media', ['original_media_id'], ['id']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('media_redaction', schema=None) as batch_op: + batch_op.drop_constraint('media_redaction_original_media_id_fkey', type_='foreignkey') + batch_op.drop_index(batch_op.f('ix_media_redaction_original_media_id')) + batch_op.drop_column('original_media_id') + + # ### end Alembic commands ### diff --git a/migrations/versions/e4f2a9c8d7b1_add_media_redaction_table.py b/migrations/versions/e4f2a9c8d7b1_add_media_redaction_table.py new file mode 100644 index 000000000..9bdd232ea --- /dev/null +++ b/migrations/versions/e4f2a9c8d7b1_add_media_redaction_table.py @@ -0,0 +1,58 @@ +"""add media redaction table + +Revision ID: e4f2a9c8d7b1 +Revises: b3f1c2a4d5e6 +Create Date: 2026-06-05 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +revision = "e4f2a9c8d7b1" +down_revision = "b3f1c2a4d5e6" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "media_redaction", + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.Column("deleted", sa.Boolean(), server_default="false", nullable=False), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("source_media_id", sa.Integer(), nullable=False), + sa.Column("result_media_id", sa.Integer(), nullable=False), + sa.Column("regions", sa.JSON(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["result_media_id"], ["media.id"]), + sa.ForeignKeyConstraint(["source_media_id"], ["media.id"]), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_media_redaction_result_media_id"), + "media_redaction", + ["result_media_id"], + unique=False, + ) + op.create_index( + op.f("ix_media_redaction_source_media_id"), + "media_redaction", + ["source_media_id"], + unique=False, + ) + op.create_index( + op.f("ix_media_redaction_user_id"), + "media_redaction", + ["user_id"], + unique=False, + ) + + +def downgrade(): + op.drop_index(op.f("ix_media_redaction_user_id"), table_name="media_redaction") + op.drop_index(op.f("ix_media_redaction_source_media_id"), table_name="media_redaction") + op.drop_index(op.f("ix_media_redaction_result_media_id"), table_name="media_redaction") + op.drop_table("media_redaction") diff --git a/tests/admin/test_redaction.py b/tests/admin/test_redaction.py new file mode 100644 index 000000000..b8451988e --- /dev/null +++ b/tests/admin/test_redaction.py @@ -0,0 +1,229 @@ +import io + +import fitz +import pytest +from PIL import Image + +from enferno.admin.models import Bulletin, Media +from enferno.utils.redaction_utils import RedactionError, redact_image_bytes, redact_pdf_bytes + + +def _one_page_pdf_with_text(text="SECRET NAME"): + doc = fitz.open() + page = doc.new_page(width=200, height=200) + page.insert_text((10, 100), text, fontsize=12) + data = doc.tobytes() + doc.close() + return data + + +def test_redact_pdf_removes_text_under_box(): + src = _one_page_pdf_with_text("SECRET NAME") + pages = [{"page": 0, "rects": [{"x": 0.0, "y": 0.45, "w": 1.0, "h": 0.10}]}] + + out = redact_pdf_bytes(src, pages) + + doc = fitz.open(stream=out, filetype="pdf") + remaining = doc[0].get_text() + doc.close() + assert "SECRET" not in remaining + assert "NAME" not in remaining + + +def test_redact_pdf_blanks_image_pixels_not_whole_page(): + img = Image.new("RGB", (200, 200), "white") + buf = io.BytesIO() + img.save(buf, format="PNG") + doc = fitz.open() + page = doc.new_page(width=200, height=200) + page.insert_image(page.rect, stream=buf.getvalue()) + src = doc.tobytes() + doc.close() + + pages = [{"page": 0, "rects": [{"x": 0.25, "y": 0.25, "w": 0.5, "h": 0.5}]}] + out = redact_pdf_bytes(src, pages) + + doc = fitz.open(stream=out, filetype="pdf") + pix = doc[0].get_pixmap() + center = pix.pixel(100, 100) + corner = pix.pixel(5, 5) + doc.close() + assert center == (0, 0, 0) + assert corner == (255, 255, 255) + + +def test_redact_image_bytes_burns_black_box(): + img = Image.new("RGB", (100, 100), "white") + buf = io.BytesIO() + img.save(buf, format="JPEG") + + out = redact_image_bytes(buf.getvalue(), [{"x": 0.0, "y": 0.0, "w": 0.5, "h": 0.5}]) + + result = Image.open(io.BytesIO(out)).convert("RGB") + assert result.getpixel((10, 10)) == (0, 0, 0) + assert result.getpixel((90, 90)) == (255, 255, 255) + + +def test_redaction_rejects_out_of_bounds_coordinates(): + src = _one_page_pdf_with_text() + pages = [{"page": 0, "rects": [{"x": -0.1, "y": 0, "w": 0.2, "h": 0.2}]}] + + with pytest.raises(RedactionError): + redact_pdf_bytes(src, pages) + + +def test_redact_endpoint_creates_new_media_and_audit_row(admin_client, session): + from enferno.admin.models import MediaRedaction + + src = _one_page_pdf_with_text("SECRET NAME") + filename = Media.generate_file_name("source.pdf") + path = Media.media_dir / filename + path.write_bytes(src) + + bulletin = Bulletin(title="Redaction test") + session.add(bulletin) + session.flush() + media = Media( + media_file=filename, + media_file_type="application/pdf", + etag="source-etag", + title="Source document", + bulletin_id=bulletin.id, + ) + session.add(media) + session.commit() + media_id = media.id + redacted = None + + try: + resp = admin_client.post( + f"/admin/api/media/{media_id}/redact", + json={"pages": [{"page": 0, "rects": [{"x": 0, "y": 0.45, "w": 1, "h": 0.1}]}]}, + ) + + assert resp.status_code == 200 + redacted = resp.get_json()["data"] + assert redacted["id"] != media_id + assert redacted["title"] == "Source document (redacted)" + audit = MediaRedaction.query.filter_by( + source_media_id=media_id, + result_media_id=redacted["id"], + ).one() + assert audit.user_id is not None + finally: + MediaRedaction.query.filter_by(source_media_id=media_id).delete() + session.commit() + path.unlink(missing_ok=True) + if redacted: + (Media.media_dir / redacted["filename"]).unlink(missing_ok=True) + + +def test_delete_endpoint_soft_deletes_redacted_copy_only(admin_client, session): + from enferno.admin.models import MediaRedaction + + bulletin = Bulletin(title="Soft-delete test") + session.add(bulletin) + session.flush() + original = Media( + media_file="orig.pdf", + media_file_type="application/pdf", + etag="orig", + bulletin_id=bulletin.id, + ) + redacted = Media( + media_file="red.pdf", media_file_type="application/pdf", etag="red", bulletin_id=bulletin.id + ) + session.add_all([original, redacted]) + session.flush() + audit = MediaRedaction( + source_media_id=original.id, + original_media_id=original.id, + result_media_id=redacted.id, + regions=[], + ) + session.add(audit) + session.commit() + original_id, redacted_id = original.id, redacted.id + + try: + # Original (no redaction backref) is rejected. + resp = admin_client.delete(f"/admin/api/media/{original_id}/redact") + assert resp.status_code == 400 + assert Media.query.get(original_id).deleted is False + + # Redacted copy is soft-deleted: hidden from normal queries, still + # reachable via the include_deleted opt-out with deleted set. + resp = admin_client.delete(f"/admin/api/media/{redacted_id}/redact") + assert resp.status_code == 200 + assert resp.get_json()["data"] == {"id": redacted_id, "deleted": True} + assert Media.query.get(redacted_id) is None + row = Media.query.execution_options(include_deleted=True).get(redacted_id) + assert row.deleted is True + finally: + MediaRedaction.query.filter_by(source_media_id=original_id).delete() + Media.query.filter(Media.id.in_([original_id, redacted_id])).delete( + synchronize_session=False + ) + session.commit() + + +def test_bulletin_to_dict_excludes_deleted_media(session): + bulletin = Bulletin(title="Deleted media exclusion") + session.add(bulletin) + session.flush() + live = Media( + media_file="live.pdf", + media_file_type="application/pdf", + etag="live", + bulletin_id=bulletin.id, + ) + gone = Media( + media_file="gone.pdf", + media_file_type="application/pdf", + etag="gone", + bulletin_id=bulletin.id, + deleted=True, + ) + session.add_all([live, gone]) + session.commit() + live_id, gone_id = live.id, gone.id + + try: + media_ids = {m["id"] for m in bulletin.to_dict()["medias"]} + assert live_id in media_ids + assert gone_id not in media_ids + finally: + Media.query.filter(Media.id.in_([live_id, gone_id])).delete(synchronize_session=False) + session.commit() + + +def test_media_dashboard_excludes_deleted(admin_client, session): + bulletin = Bulletin(title="Dashboard exclusion") + session.add(bulletin) + session.flush() + live = Media( + media_file="dlive.pdf", + media_file_type="application/pdf", + etag="dlive", + bulletin_id=bulletin.id, + ) + gone = Media( + media_file="dgone.pdf", + media_file_type="application/pdf", + etag="dgone", + bulletin_id=bulletin.id, + deleted=True, + ) + session.add_all([live, gone]) + session.commit() + live_id, gone_id = live.id, gone.id + + try: + resp = admin_client.get(f"/admin/api/media/dashboard?bulletin_id={bulletin.id}") + assert resp.status_code == 200 + ids = {m["id"] for m in resp.get_json()["items"]} + assert live_id in ids + assert gone_id not in ids + finally: + Media.query.filter(Media.id.in_([live_id, gone_id])).delete(synchronize_session=False) + session.commit()