Compare commits

..

4 Commits

Author SHA1 Message Date
shamoon
1cdd8d9ba8 Clarify repo maintenance rules 2025-09-21 16:32:21 -07:00
shamoon
4449dbadb5 Merge branch 'main' into dev 2025-09-21 16:10:00 -07:00
shamoon
0e35acaef5 Fix: add extra error handling to _consume for file checks (#10897) 2025-09-21 13:21:40 -07:00
shamoon
19ff339804 Fix: show children in tag list when filtering (#10899) 2025-09-21 10:09:05 -07:00
9 changed files with 61 additions and 194 deletions

View File

@@ -241,6 +241,7 @@ jobs:
) {
nodes {
id,
createdAt,
number,
updatedAt,
upvoteCount,

View File

@@ -135,7 +135,7 @@ community members. That said, in an effort to keep the repository organized and
- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity.
- Discussions with a marked answer will be automatically closed.
- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.
- Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity, < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 80 "up-votes" at 2 years.
- Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity with less than 80 "up-votes", < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 40 "up-votes" at 2 years.
In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns.
Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features.

View File

@@ -1759,6 +1759,11 @@ started by the container.
: Path to an image file in the /media/logo directory, must include 'logo', e.g. `/logo/Atari_logo.svg`
!!! note
The logo file will be viewable by anyone with access to the Paperless instance login page,
so consider your choice of logo carefully and removing exif data from images before uploading.
#### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK}
!!! note

View File

@@ -71,4 +71,20 @@ describe('TagListComponent', () => {
'Do you really want to delete the tag "Tag1"?'
)
})
it('should filter out child tags if name filter is empty, otherwise show all', () => {
const tags = [
{ id: 1, name: 'Tag1', parent: null },
{ id: 2, name: 'Tag2', parent: 1 },
{ id: 3, name: 'Tag3', parent: null },
]
component['_nameFilter'] = null // Simulate empty name filter
const filtered = component.filterData(tags as any)
expect(filtered.length).toBe(2)
expect(filtered.find((t) => t.id === 2)).toBeUndefined()
component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter
const filteredWithName = component.filterData(tags as any)
expect(filteredWithName.length).toBe(3)
})
})

View File

@@ -62,6 +62,8 @@ export class TagListComponent extends ManagementListComponent<Tag> {
}
filterData(data: Tag[]) {
return data.filter((tag) => !tag.parent)
return this.nameFilter?.length
? [...data]
: data.filter((tag) => !tag.parent)
}
}

View File

@@ -82,6 +82,13 @@ def _is_ignored(filepath: Path) -> bool:
def _consume(filepath: Path) -> None:
# Check permissions early
try:
filepath.stat()
except (PermissionError, OSError):
logger.warning(f"Not consuming file {filepath}: Permission denied.")
return
if filepath.is_dir() or _is_ignored(filepath):
return
@@ -323,7 +330,12 @@ class Command(BaseCommand):
# Also make sure the file exists still, some scanners might write a
# temporary file first
file_still_exists = filepath.exists() and filepath.is_file()
try:
file_still_exists = filepath.exists() and filepath.is_file()
except (PermissionError, OSError): # pragma: no cover
# If we can't check, let it fail in the _consume function
file_still_exists = True
continue
if waited_long_enough and file_still_exists:
_consume(filepath)

View File

@@ -1,6 +1,4 @@
import json
from fractions import Fraction
from io import BytesIO
from pathlib import Path
from django.contrib.auth.models import User
@@ -8,11 +6,6 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from rest_framework import status
from rest_framework.test import APITestCase
try:
from PIL import Image
except ModuleNotFoundError: # pragma: no cover - Pillow is required in production
Image = None # type: ignore[assignment]
from documents.tests.utils import DirectoriesMixin
from paperless.models import ApplicationConfiguration
from paperless.models import ColorConvertChoices
@@ -197,74 +190,6 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
)
self.assertFalse(Path(old_logo.path).exists())
def test_api_strips_metadata_from_logo_upload(self):
"""
GIVEN:
- An image file containing EXIF metadata including GPS coordinates
WHEN:
- Uploaded via PATCH to app config
THEN:
- Stored logo no longer contains EXIF metadata
"""
if Image is None:
self.skipTest("Pillow is not installed")
if not hasattr(Image, "Exif"):
self.skipTest("Current Pillow version cannot create EXIF metadata")
assert Image is not None
exif = Image.Exif()
exif[0x010E] = "Test description" # ImageDescription
exif[0x8825] = {
1: "N", # GPSLatitudeRef
2: (Fraction(51, 1), Fraction(30, 1), Fraction(0, 1)),
3: "E", # GPSLongitudeRef
4: (Fraction(0, 1), Fraction(7, 1), Fraction(0, 1)),
}
buffer = BytesIO()
Image.new("RGB", (8, 8), "white").save(buffer, format="JPEG", exif=exif)
buffer.seek(0)
with Image.open(BytesIO(buffer.getvalue())) as uploaded_image:
self.assertGreater(len(uploaded_image.getexif()), 0)
response = self.client.patch(
f"{self.ENDPOINT}1/",
{
"app_logo": SimpleUploadedFile(
name="with_exif.jpg",
content=buffer.getvalue(),
content_type="image/jpeg",
),
},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
config = ApplicationConfiguration.objects.first()
stored_logo = Path(config.app_logo.path)
self.assertTrue(stored_logo.exists())
with Image.open(stored_logo) as sanitized:
sanitized_exif = sanitized.getexif()
self.assertNotEqual(sanitized_exif.get(0x010E), "Test description")
gps_ifd = None
if hasattr(sanitized_exif, "get_ifd"):
try:
gps_ifd = sanitized_exif.get_ifd(0x8825)
except KeyError:
gps_ifd = None
else:
gps_ifd = sanitized_exif.get(0x8825)
if gps_ifd is not None:
self.assertEqual(len(gps_ifd), 0, "GPS metadata should be cleared")
self.assertNotIn("exif", sanitized.info)
def test_api_rejects_malicious_svg_logo(self):
"""
GIVEN:

View File

@@ -209,6 +209,26 @@ class TestConsumer(DirectoriesMixin, ConsumerThreadMixin, TransactionTestCase):
# assert that we have an error logged with this invalid file.
error_logger.assert_called_once()
@mock.patch("documents.management.commands.document_consumer.logger.warning")
def test_permission_error_on_prechecks(self, warning_logger):
filepath = Path(self.dirs.consumption_dir) / "selinux.txt"
filepath.touch()
original_stat = Path.stat
def raising_stat(self, *args, **kwargs):
if self == filepath:
raise PermissionError("Permission denied")
return original_stat(self, *args, **kwargs)
with mock.patch("pathlib.Path.stat", new=raising_stat):
document_consumer._consume(filepath)
warning_logger.assert_called_once()
(args, _) = warning_logger.call_args
self.assertIn("Permission denied", args[0])
self.consume_file_mock.assert_not_called()
@override_settings(CONSUMPTION_DIR="does_not_exist")
def test_consumption_directory_invalid(self):
self.assertRaises(CommandError, call_command, "document_consumer", "--oneshot")

View File

@@ -1,5 +1,4 @@
import logging
from io import BytesIO
import magic
from allauth.mfa.adapter import get_adapter as get_mfa_adapter
@@ -10,10 +9,6 @@ from allauth.socialaccount.models import SocialApp
from django.contrib.auth.models import Group
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from django.core.files.uploadedfile import SimpleUploadedFile
from PIL import Image
from PIL import ImageOps
from PIL import UnidentifiedImageError
from rest_framework import serializers
from rest_framework.authtoken.serializers import AuthTokenSerializer
@@ -24,102 +19,6 @@ from paperless_mail.serialisers import ObfuscatedPasswordField
logger = logging.getLogger("paperless.settings")
def strip_image_metadata(uploaded_file, mime_type: str | None):
"""Return a copy of ``uploaded_file`` with EXIF/ICC metadata removed."""
if uploaded_file is None:
return uploaded_file
original_position = uploaded_file.tell() if hasattr(uploaded_file, "tell") else None
image = None
sanitized = None
try:
if hasattr(uploaded_file, "seek"):
uploaded_file.seek(0)
image = Image.open(uploaded_file)
image.load()
except (UnidentifiedImageError, OSError):
if hasattr(uploaded_file, "seek") and original_position is not None:
uploaded_file.seek(original_position)
return uploaded_file
try:
image_format = (image.format or "").upper()
image = ImageOps.exif_transpose(image)
if image_format not in {"JPEG", "JPG", "PNG"}:
if hasattr(uploaded_file, "seek") and original_position is not None:
uploaded_file.seek(original_position)
return uploaded_file
if hasattr(image, "info"):
image.info.pop("exif", None)
image.info.pop("icc_profile", None)
image.info.pop("comment", None)
if image_format in {"JPEG", "JPG"}:
sanitized = image.convert("RGB")
save_kwargs = {
"format": "JPEG",
"quality": 95,
"subsampling": 0,
"optimize": True,
"exif": b"",
}
else: # PNG
target_mode = (
"RGBA"
if ("A" in image.mode or image.info.get("transparency"))
else "RGB"
)
sanitized = image.convert(target_mode)
save_kwargs = {
"format": "PNG",
"optimize": True,
}
buffer = BytesIO()
try:
sanitized.save(buffer, **save_kwargs)
except (OSError, ValueError):
buffer = BytesIO()
if image_format in {"JPEG", "JPG"}:
sanitized.save(
buffer,
format="JPEG",
quality=90,
subsampling=0,
exif=b"",
)
else:
sanitized.save(
buffer,
format="PNG",
)
buffer.seek(0)
if hasattr(uploaded_file, "close"):
try:
uploaded_file.close()
except Exception:
pass
content_type = getattr(uploaded_file, "content_type", None) or mime_type
return SimpleUploadedFile(
name=getattr(uploaded_file, "name", "logo"),
content=buffer.getvalue(),
content_type=content_type,
)
finally:
if sanitized is not None:
sanitized.close()
if image is not None:
image.close()
class PaperlessAuthTokenSerializer(AuthTokenSerializer):
code = serializers.CharField(
label="MFA Code",
@@ -310,22 +209,9 @@ class ApplicationConfigurationSerializer(serializers.ModelSerializer):
return super().update(instance, validated_data)
def validate_app_logo(self, file):
if not file:
return file
if hasattr(file, "seek"):
file.seek(0)
mime_type = magic.from_buffer(file.read(2048), mime=True)
if hasattr(file, "seek"):
file.seek(0)
if mime_type == "image/svg+xml":
if file and magic.from_buffer(file.read(2048), mime=True) == "image/svg+xml":
reject_dangerous_svg(file)
if hasattr(file, "seek"):
file.seek(0)
return file
return strip_image_metadata(file, mime_type)
return file
class Meta:
model = ApplicationConfiguration