Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [2.10.5] - 2026-10-29

### Added

- CSV and JSON exports mark each row's content duplicate group and filename duplicate group, with the group's file count, so a script can keep one file per group ([#88](https://github.com/ttlequals0/PixelProbe/issues/88)). Exports honor the Duplicates filter and its content or filename mode. They return HTTP 503 with `Retry-After: 30` until the duplicate index is ready, and the duplicate columns stay empty while it initializes.

### Fixed

- The per-file Actions menu did not open on mobile cards or desktop table rows. Since 2.9.3 the toggle called the menu handler on the table renderer instead of the application, which raised a TypeError on every tap.

### Security

- Updated Pillow to 13.0.0. It fixes 28 vulnerabilities reported against 12.3.0, including CVE-2026-112393, a heap buffer overflow when saving TIFF data to a memory buffer.
- Frontend build dependencies: undici 8.12.3, fast-uri 4.2.3, and serialize-javascript 7.1.4 via lockfile overrides. Build-time only; nothing ships to the browser.

## [2.10.4] - 2026-10-10

### Fixed
Expand Down
9 changes: 6 additions & 3 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -758,8 +758,9 @@ Export scan results in multiple formats (CSV, JSON, or PDF).

**Query parameters (GET):**
- `format` (string): Output format - `csv`, `json`, or `pdf` (default: csv)
- `filter` (string): Filter type - `all`, `corrupted`, `healthy`, `pending`, `error` (default: all). `warning` is **not** handled on GET; use the POST form for it.
- `filter` (string): Filter type - `all`, `corrupted`, `healthy`, `warning`, `pending`, `error`, `duplicates` (default: all)
- `search` (string): Search term to filter by file path
- `duplicate_mode` (string): With `filter=duplicates`, `hash` (same recorded content, default) or `name` (same filename)

**Request body (POST):**
```json
Expand All @@ -771,9 +772,11 @@ Export scan results in multiple formats (CSV, JSON, or PDF).
}
```

In the POST form, `filter` accepts `all`, `corrupted`, `healthy`, or `warning`. If `file_ids` is provided, only those specific results are exported and `filter`/`search` are ignored.
The POST form accepts the same `filter`, `search`, and `duplicate_mode` values. If `file_ids` is provided, only those specific results are exported and `filter`/`search` are ignored.

**Response:** File download in requested format
CSV and JSON rows carry four duplicate columns. `Content Duplicate Group` and `Content Duplicate Files` (`content_duplicate_group` and `content_duplicate_group_size` in JSON) mark files whose recorded SHA-256 hash and size match another existing file. `Filename Duplicate Group` and `Filename Duplicate Files` (`filename_duplicate_group` and `filename_duplicate_group_size`) mark files that share a filename. The group value is the lowest scan result ID in the group, so a script can keep one row per group value and remove the rest. The columns are empty for files that are not duplicates, and for every row while the duplicate index is still initializing.

**Response:** File download in requested format. `filter=duplicates` returns `503` with `Retry-After: 30` until the duplicate index is ready, and `400` for an unknown `duplicate_mode`.

### Maintenance endpoints

Expand Down
27 changes: 23 additions & 4 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2699,15 +2699,26 @@ paths:
in: query
schema:
type: string
enum: [all, corrupted, healthy, pending, error]
enum: [all, corrupted, healthy, warning, pending, error, duplicates]
default: all
- name: search
in: query
schema:
type: string
- name: duplicate_mode
in: query
description: With filter=duplicates, hash (same recorded content) or name (same filename)
schema:
type: string
enum: [all, hash, name]
default: all
responses:
'200':
description: Export file generated and returned
description: Export file generated and returned. CSV and JSON rows carry content and filename duplicate group columns.
'400':
description: Unknown duplicate_mode
'503':
description: Duplicate index is initializing; retry after the Retry-After interval

post:
summary: Export scan results (POST)
Expand Down Expand Up @@ -2739,16 +2750,24 @@ paths:
enum: [csv, json, pdf]
filter:
type: string
enum: [all, corrupted, healthy, pending, warning]
enum: [all, corrupted, healthy, warning, pending, error, duplicates]
search:
type: string
duplicate_mode:
type: string
enum: [all, hash, name]
default: all
file_ids:
type: array
items:
type: integer
responses:
'200':
description: Export file generated and returned
description: Export file generated and returned. CSV and JSON rows carry content and filename duplicate group columns.
'400':
description: Unknown duplicate_mode
'503':
description: Duplicate index is initializing; retry after the Retry-After interval

# Maintenance Endpoints
/test-cleanup:
Expand Down
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
},
"overrides": {
"ajv": "^8.20.0",
"fast-uri": "^4.1.5",
"serialize-javascript": "^7.1.2"
"fast-uri": "^4.2.3",
"serialize-javascript": "^7.1.4",
"undici": "^8.12.3"
}
}
97 changes: 73 additions & 24 deletions pixelprobe/api/export_routes.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from flask import Blueprint, request, send_file, Response, make_response, stream_with_context
from sqlalchemy import null
import os
import csv
import io
Expand All @@ -10,6 +11,7 @@

from pixelprobe.models import db, ScanResult
from pixelprobe.auth import auth_required
from pixelprobe.services.duplicate_service import duplicate_index_status, duplicate_members
from pixelprobe.utils.security import PathTraversalError, open_authorized_media_file

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -94,6 +96,48 @@
return query


class _InvalidDuplicateMode(ValueError):
pass


class _DuplicateIndexNotReady(Exception):
def __init__(self, status):
super().__init__('Duplicate index is initializing')
self.status = status


def _filtered_export_query(filter_type, search, duplicate_mode):
"""Build the export query; returns (query, export_type)."""
if duplicate_mode not in ('all', 'hash', 'name'):
raise _InvalidDuplicateMode('duplicate_mode must be all, hash, or name')
query = ScanResult.query
if search:
query = query.filter(ScanResult.file_path.contains(search))
if filter_type != 'duplicates':
return _apply_status_filter(query, filter_type), filter_type
mode = 'hash' if duplicate_mode == 'all' else duplicate_mode
status = duplicate_index_status()
if not status['ready']:
raise _DuplicateIndexNotReady(status)
members = duplicate_members(mode)
return query.join(members, ScanResult.id == members.c.file_id), f'duplicates_{mode}'


def _duplicate_export_columns():
"""Outer joins and labeled columns for duplicate annotations; blank until the index is ready."""
labels = ('content_duplicate_group', 'content_duplicate_group_size',
'filename_duplicate_group', 'filename_duplicate_group_size')
if not duplicate_index_status()['ready']:
return [], tuple(null().label(label) for label in labels)
hash_members = duplicate_members('hash')
name_members = duplicate_members('name')
joins = [(hash_members, ScanResult.id == hash_members.c.file_id),
(name_members, ScanResult.id == name_members.c.file_id)]
columns = (hash_members.c.group_id.label(labels[0]), hash_members.c.group_size.label(labels[1]),
name_members.c.group_id.label(labels[2]), name_members.c.group_size.label(labels[3]))
return joins, columns


def _pdf_export_rows(query):
"""Load only the fields rendered by the bounded generic PDF export."""
return (query.with_entities(
Expand Down Expand Up @@ -209,18 +253,10 @@
# Export based on current filter and search
filter_type = data.get('filter', 'all')
search = data.get('search', '')

query = ScanResult.query

# Apply search filter
if search:
query = query.filter(ScanResult.file_path.contains(search))

query = _apply_status_filter(query, filter_type)

duplicate_mode = data.get('duplicate_mode', 'all')
query, export_type = _filtered_export_query(filter_type, search, duplicate_mode)
result_total = query.count()
results = _pdf_export_rows(query) if format_type == 'pdf' else None
export_type = filter_type if filter_type != 'all' else 'all'
logger.info("Exporting %s scan results to %s", result_total, format_type.upper())
else:
# GET request - support format, filter, and search parameters
Expand All @@ -232,31 +268,28 @@
if format_type not in ['csv', 'json', 'pdf']:
format_type = 'csv'

# Build query based on filter
query = ScanResult.query

# Apply search filter if provided
if search:
query = query.filter(ScanResult.file_path.contains(search))

query = _apply_status_filter(query, filter_type)

duplicate_mode = request.args.get('duplicate_mode', 'all')
query, export_type = _filtered_export_query(filter_type, search, duplicate_mode)
result_total = query.count()
results = _pdf_export_rows(query) if format_type == 'pdf' else None
export_type = filter_type if filter_type != 'all' else 'all'
logger.info("Exporting %s scan results to %s", result_total, format_type.upper())

duplicate_joins, duplicate_columns = _duplicate_export_columns()

def result_batches():
columns = (ScanResult.id, ScanResult.file_path, ScanResult.file_size,
ScanResult.file_type, ScanResult.creation_date, ScanResult.is_corrupted,
ScanResult.corruption_details, ScanResult.scan_date, ScanResult.scan_status,
ScanResult.discovered_date, ScanResult.marked_as_good, ScanResult.has_warnings,
ScanResult.warning_details, ScanResult.error_message)
ScanResult.warning_details, ScanResult.error_message) + duplicate_columns
last_id = 0
while True:
session = db.session.session_factory()
try:
batch = (query.with_session(session).filter(
batch_query = query.with_session(session)
for members, on_clause in duplicate_joins:
batch_query = batch_query.outerjoin(members, on_clause)
batch = (batch_query.filter(
ScanResult.id > last_id, ScanResult.id <= export_max_id)
.order_by(ScanResult.id).with_entities(*columns).limit(500).all())
finally:
Expand Down Expand Up @@ -298,6 +331,10 @@
'has_warnings': getattr(result, 'has_warnings', False),
'warning_details': getattr(result, 'warning_details', None),
'error_message': getattr(result, 'error_message', None),
'content_duplicate_group': result.content_duplicate_group,
'content_duplicate_group_size': result.content_duplicate_group_size,
'filename_duplicate_group': result.filename_duplicate_group,
'filename_duplicate_group_size': result.filename_duplicate_group_size,
'details': {
'corruption': result.corruption_details,
'warning': getattr(result, 'warning_details', None),
Expand Down Expand Up @@ -460,7 +497,11 @@
'Scan Date',
'Scan Status',
'Discovered Date',
'Marked as Good'
'Marked as Good',
'Content Duplicate Group',
'Content Duplicate Files',
'Filename Duplicate Group',
'Filename Duplicate Files'
])
yield output.getvalue(); output.seek(0); output.truncate(0)
for result in result_batches():
Expand All @@ -485,13 +526,21 @@
result.scan_date.isoformat() if result.scan_date else '',
getattr(result, 'scan_status', 'completed'), # Default to completed for old records
getattr(result, 'discovered_date', result.scan_date).isoformat() if getattr(result, 'discovered_date', result.scan_date) else '',
'Yes' if result.marked_as_good else 'No'
'Yes' if result.marked_as_good else 'No',
result.content_duplicate_group if result.content_duplicate_group is not None else '',
result.content_duplicate_group_size if result.content_duplicate_group_size is not None else '',
result.filename_duplicate_group if result.filename_duplicate_group is not None else '',
result.filename_duplicate_group_size if result.filename_duplicate_group_size is not None else ''
])
yield output.getvalue(); output.seek(0); output.truncate(0)
filename = f"pixelprobe_{export_type}_{timestamp}.csv"
release_request_session()
return Response(stream_with_context(generate_csv()), mimetype='text/csv', headers={'Content-Disposition': f'attachment; filename={filename}'})

except _InvalidDuplicateMode as e:
return {'error': str(e)}, 400
except _DuplicateIndexNotReady as e:
return {'error': str(e), 'duplicate_status': e.status}, 503, {'Retry-After': '30'}
except Exception as e:
logger.error(f"Error exporting: {str(e)}", exc_info=True)
return {'error': 'Export failed'}, 500
2 changes: 1 addition & 1 deletion pixelprobe/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# Default version - this is the single source of truth


_DEFAULT_VERSION = '2.10.4'
_DEFAULT_VERSION = '2.10.5'


# Allow override via environment variable for CI/CD, but default to the hardcoded version
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Flask-CORS==6.0.0
Flask-Limiter==3.9.0
Flask-WTF==1.2.2
flask-restx==1.3.2
Pillow==12.3.0 # 12.3.0 resolves 13 CVEs in 12.2.0 (CVE-2026-59197..59205, 55379, 55380, 55798, 54058..54060)
Pillow==13.0.0 # 13.0.0 resolves 28 CVEs in 12.3.0, including CVE-2026-112393 (TIFF heap overflow)
pillow-heif==1.3.0 # HEIC/HEIF support for Pillow (requires libheif system library); 1.3.0 resolves CVE-2026-28231
python-magic==0.4.27
ffmpeg-python==0.2.0
Expand Down
5 changes: 4 additions & 1 deletion static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1977,7 +1977,7 @@ class TableManager {
actions.appendChild(make('View', 'fa-eye', () => app.viewFile(id), 'btn-primary'));
const dropdown = document.createElement('div'); dropdown.className = 'action-dropdown';
const menu = document.createElement('ul'); menu.className = 'dropdown-menu'; menu.style.display = 'none';
const toggle = make('Actions', 'fa-tasks', (event) => this.toggleActionDropdown(event, menu.id));
const toggle = make('Actions', 'fa-tasks', (event) => app.toggleActionDropdown(event, menu.id));
menu.id = `file-action-menu-${id}-${compact ? 'table' : 'mobile'}`;
[['Rescan', 'fa-sync', () => app.rescanFile(id)], ['Cleanup', 'fa-search', () => app.orphanCheckFile(id)], ['Integrity Check', 'fa-shield-alt', () => app.changeCheckFile(id)]].forEach(([text, icon, handler]) => {
const item = document.createElement('li'); const action = document.createElement('a'); action.href = '#'; action.className = 'dropdown-item'; action.innerHTML = `<i class="fas ${icon}"></i>`; action.append(` ${text}`); action.addEventListener('click', (event) => { event.preventDefault(); handler(); }); item.appendChild(action); menu.appendChild(item);
Expand Down Expand Up @@ -4436,6 +4436,7 @@ class PixelProbeApp {
// Export all files in current filter/search
requestBody.filter = this.table.filter;
requestBody.search = this.table.searchQuery;
if (this.table.filter === 'duplicates') requestBody.duplicate_mode = this.table.duplicateMode;
}

const response = await fetch('/api/export', {
Expand Down Expand Up @@ -4470,6 +4471,8 @@ class PixelProbeApp {
window.URL.revokeObjectURL(url);

this.showNotification(`${formatUpper} export complete`, 'success');
} else if (response.status === 503) {
this.showNotification('Duplicate index is initializing. Try the export again shortly.', 'warning');
} else {
throw new Error('Export failed');
}
Expand Down
Loading
Loading