platzky 1.2.2__py3-none-any.whl → 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ """Attachment package for file attachments in notifications.
2
+
3
+ This package provides the Attachment class factory and related utilities for
4
+ handling file attachments in the notification system.
5
+
6
+ Usage via Engine (recommended):
7
+ >>> # Engine exposes a configured Attachment class
8
+ >>> attachment = engine.Attachment("report.pdf", pdf_bytes, "application/pdf")
9
+
10
+ Direct usage with factory:
11
+ >>> from platzky.attachment import create_attachment_class
12
+ >>> from platzky.config import AttachmentConfig
13
+ >>> config = AttachmentConfig(max_size=5 * 1024 * 1024) # 5MB limit
14
+ >>> Attachment = create_attachment_class(config)
15
+ >>> attachment = Attachment("report.pdf", pdf_bytes, "application/pdf")
16
+ """
17
+
18
+ from platzky.attachment.constants import (
19
+ BLOCKED_EXTENSIONS,
20
+ DEFAULT_MAX_ATTACHMENT_SIZE,
21
+ AttachmentSizeError,
22
+ BlockedExtensionError,
23
+ ExtensionNotAllowedError,
24
+ InvalidMimeTypeError,
25
+ )
26
+ from platzky.attachment.core import AttachmentProtocol, create_attachment_class
27
+ from platzky.attachment.mime_validation import ContentMismatchError
28
+
29
+ __all__ = [
30
+ "BLOCKED_EXTENSIONS",
31
+ "DEFAULT_MAX_ATTACHMENT_SIZE",
32
+ "AttachmentProtocol",
33
+ "AttachmentSizeError",
34
+ "BlockedExtensionError",
35
+ "ContentMismatchError",
36
+ "ExtensionNotAllowedError",
37
+ "InvalidMimeTypeError",
38
+ "create_attachment_class",
39
+ ]
@@ -0,0 +1,139 @@
1
+ """Attachment size limits and related constants."""
2
+
3
+ # Default maximum attachment size: 10MB
4
+ DEFAULT_MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024
5
+
6
+ # Blocked file extensions - dangerous executable and script formats.
7
+ # SECURITY: These extensions are PERMANENTLY blocked and cannot be overridden
8
+ # via allowed_extensions. This includes .js which can execute via Windows
9
+ # Script Host, Node.js, or browsers.
10
+ BLOCKED_EXTENSIONS: frozenset[str] = frozenset(
11
+ {
12
+ # Windows executables
13
+ "exe",
14
+ "dll",
15
+ "scr",
16
+ "msi",
17
+ "com",
18
+ "pif",
19
+ # Windows scripts
20
+ "bat",
21
+ "cmd",
22
+ "vbs",
23
+ "vbe",
24
+ "js",
25
+ "jse",
26
+ "ws",
27
+ "wsf",
28
+ "wsc",
29
+ "wsh",
30
+ "ps1",
31
+ "psm1",
32
+ "psd1",
33
+ # Shortcuts and links
34
+ "lnk",
35
+ "url",
36
+ "hta",
37
+ # macOS executables
38
+ "app",
39
+ "dmg",
40
+ "pkg",
41
+ # Linux packages
42
+ "deb",
43
+ "rpm",
44
+ "appimage",
45
+ # Shell scripts
46
+ "sh",
47
+ "bash",
48
+ "zsh",
49
+ "ksh",
50
+ "csh",
51
+ # Scripting languages
52
+ "py",
53
+ "pyc",
54
+ "pyo",
55
+ "pyw",
56
+ "rb",
57
+ "pl",
58
+ "php",
59
+ "php3",
60
+ "php4",
61
+ "php5",
62
+ "phtml",
63
+ # Java
64
+ "jar",
65
+ "class",
66
+ "war",
67
+ # Other dangerous formats
68
+ "reg", # Windows registry
69
+ "inf", # Windows setup information
70
+ "scf", # Windows Explorer command
71
+ "cpl", # Control panel extension
72
+ "msc", # Microsoft Management Console
73
+ "gadget", # Windows gadget
74
+ }
75
+ )
76
+
77
+
78
+ class BlockedExtensionError(ValueError):
79
+ """Raised when attachment has a blocked file extension."""
80
+
81
+ def __init__(self, filename: str, extension: str) -> None:
82
+ self.filename = filename
83
+ self.extension = extension
84
+ message = (
85
+ f"Attachment '{filename}' has blocked extension '.{extension}'. "
86
+ f"Executable and script file types are not allowed for security reasons."
87
+ )
88
+ super().__init__(message)
89
+
90
+
91
+ class ExtensionNotAllowedError(ValueError):
92
+ """Raised when attachment extension is not in the allow-list."""
93
+
94
+ def __init__(self, filename: str, extension: str | None) -> None:
95
+ self.filename = filename
96
+ self.extension = extension
97
+ if extension is None:
98
+ message = (
99
+ f"Attachment '{filename}' has no file extension. "
100
+ f"Files without extensions are not allowed."
101
+ )
102
+ else:
103
+ message = (
104
+ f"Attachment '{filename}' has extension '.{extension}' which is not "
105
+ f"in the allowed extensions list."
106
+ )
107
+ super().__init__(message)
108
+
109
+
110
+ class AttachmentSizeError(ValueError):
111
+ """Raised when attachment content exceeds the maximum allowed size."""
112
+
113
+ def __init__(self, filename: str, actual_size: int, max_size: int | None = None) -> None:
114
+ self.filename = filename
115
+ self.actual_size = actual_size
116
+ self.max_size = max_size if max_size is not None else DEFAULT_MAX_ATTACHMENT_SIZE
117
+ message = (
118
+ f"Attachment '{filename}' exceeds maximum size of "
119
+ f"{self.max_size / (1024 * 1024):.2f}MB "
120
+ f"(size: {actual_size / (1024 * 1024):.2f}MB)"
121
+ )
122
+ super().__init__(message)
123
+
124
+
125
+ class InvalidMimeTypeError(ValueError):
126
+ """Raised when MIME type format is invalid or not in the allowlist."""
127
+
128
+ def __init__(self, filename: str, mime_type: str, *, invalid_format: bool = False) -> None:
129
+ self.filename = filename
130
+ self.mime_type = mime_type
131
+ self.invalid_format = invalid_format
132
+ if invalid_format:
133
+ message = (
134
+ f"Invalid MIME type format '{mime_type}' for attachment '{filename}'. "
135
+ f"MIME type must be in 'type/subtype' format (e.g., 'text/plain', 'image/png')."
136
+ )
137
+ else:
138
+ message = f"MIME type '{mime_type}' is not allowed for attachment '{filename}'."
139
+ super().__init__(message)
@@ -0,0 +1,268 @@
1
+ """Core Attachment class factory for file attachments in notifications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import mimetypes
7
+ import ntpath
8
+ import os
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
12
+
13
+ from platzky.attachment.constants import (
14
+ AttachmentSizeError,
15
+ BlockedExtensionError,
16
+ ExtensionNotAllowedError,
17
+ InvalidMimeTypeError,
18
+ )
19
+ from platzky.attachment.mime_validation import validate_content_mime_type
20
+
21
+ if TYPE_CHECKING:
22
+ from platzky.config import AttachmentConfig
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ @runtime_checkable
28
+ class AttachmentProtocol(Protocol):
29
+ """Protocol defining the interface for Attachment classes.
30
+
31
+ This protocol allows type-safe usage of dynamically created Attachment classes.
32
+ """
33
+
34
+ filename: str
35
+ content: bytes
36
+ mime_type: str
37
+
38
+ def __init__(
39
+ self,
40
+ filename: str,
41
+ content: bytes,
42
+ mime_type: str,
43
+ _max_size: int | None = None,
44
+ ) -> None: ...
45
+
46
+ @classmethod
47
+ def from_bytes(
48
+ cls,
49
+ content: bytes,
50
+ filename: str,
51
+ mime_type: str,
52
+ max_size_override: int | None = None,
53
+ ) -> AttachmentProtocol: ...
54
+
55
+ @classmethod
56
+ def from_file(
57
+ cls,
58
+ file_path: str | Path,
59
+ filename: str | None = None,
60
+ mime_type: str | None = None,
61
+ max_size_override: int | None = None,
62
+ ) -> AttachmentProtocol: ...
63
+
64
+
65
+ def _sanitize_filename(filename: str) -> str:
66
+ """Remove path components from filename, returning just the basename.
67
+
68
+ Strips trailing separators first to handle path-only inputs like "/" or "dir/",
69
+ then extracts basename. Returns empty string for invalid inputs rather than
70
+ preserving path separators, allowing validation to reject them.
71
+ """
72
+ # Strip trailing separators to handle inputs like "/" or "dir/"
73
+ stripped = filename.rstrip("/\\")
74
+ return os.path.basename(ntpath.basename(stripped))
75
+
76
+
77
+ def _get_extension(filename: str) -> str | None:
78
+ """Extract the file extension from a filename, lowercased.
79
+
80
+ Returns None if no extension is found or if extension is empty (e.g., "file.").
81
+ """
82
+ if "." not in filename:
83
+ return None
84
+ ext = filename.rsplit(".", 1)[-1]
85
+ return ext.lower() or None
86
+
87
+
88
+ def _guess_mime_type(filename: str) -> str:
89
+ """Guess MIME type from filename, defaulting to application/octet-stream."""
90
+ guessed_type, _ = mimetypes.guess_type(filename)
91
+ return guessed_type or "application/octet-stream"
92
+
93
+
94
+ def _validate_extension(
95
+ filename: str,
96
+ ext: str | None,
97
+ blocked_extensions: frozenset[str],
98
+ allowed_extensions: frozenset[str] | None,
99
+ ) -> None:
100
+ """Validate filename extension against block-list and allow-list.
101
+
102
+ Validation order:
103
+ 1. If extension is in blocked_extensions → REJECT (BlockedExtensionError)
104
+ 2. If allowed_extensions is None → REJECT (ExtensionNotAllowedError)
105
+ 3. If no extension → REJECT (ExtensionNotAllowedError)
106
+ 4. If extension not in allowed_extensions → REJECT (ExtensionNotAllowedError)
107
+ 5. Otherwise → ALLOW
108
+ """
109
+ if ext is not None and ext in blocked_extensions:
110
+ raise BlockedExtensionError(filename, ext)
111
+
112
+ if allowed_extensions is None or ext is None or ext not in allowed_extensions:
113
+ raise ExtensionNotAllowedError(filename, ext)
114
+
115
+
116
+ def _validate_mime_type(mime_type: str, filename: str, allowed_mime_types: frozenset[str]) -> None:
117
+ """Validate MIME type format and against allowlist."""
118
+ if not mime_type or "/" not in mime_type:
119
+ raise InvalidMimeTypeError(filename, mime_type, invalid_format=True)
120
+
121
+ if mime_type not in allowed_mime_types:
122
+ raise InvalidMimeTypeError(filename, mime_type)
123
+
124
+
125
+ def _do_sanitize_filename(filename: str) -> str:
126
+ """Sanitize filename and return result. Raises if empty or invalid after sanitization."""
127
+ sanitized = _sanitize_filename(filename)
128
+ if not sanitized or sanitized in (".", ".."):
129
+ raise ValueError("Attachment filename cannot be empty")
130
+ if sanitized != filename:
131
+ logger.warning(
132
+ "Attachment filename contained path components, sanitized from '%s' to '%s'",
133
+ filename,
134
+ sanitized,
135
+ )
136
+ return sanitized
137
+
138
+
139
+ def create_attachment_class(config: AttachmentConfig) -> type:
140
+ """Create an Attachment class with configuration captured via closure.
141
+
142
+ Args:
143
+ config: Attachment configuration containing allowed_mime_types,
144
+ validate_content, allow_unrecognized_content, max_size,
145
+ and blocked_extensions.
146
+
147
+ Returns:
148
+ A configured Attachment class that validates attachments according
149
+ to the provided configuration.
150
+
151
+ Example:
152
+ >>> from platzky.config import AttachmentConfig
153
+ >>> config = AttachmentConfig()
154
+ >>> Attachment = create_attachment_class(config)
155
+ >>> attachment = Attachment("report.pdf", pdf_bytes, "application/pdf")
156
+ """
157
+ # Capture config values in closure
158
+ allowed_mime_types = config.allowed_mime_types
159
+ validate_content = config.validate_content
160
+ allow_unrecognized_content = config.allow_unrecognized_content
161
+ max_size = config.max_size
162
+ blocked_extensions = config.blocked_extensions
163
+ allowed_extensions = config.allowed_extensions
164
+
165
+ @dataclass(frozen=True)
166
+ class Attachment:
167
+ """Represents a file attachment for notifications.
168
+
169
+ Attributes:
170
+ filename: Name of the file (without path components).
171
+ content: Binary content of the file.
172
+ mime_type: MIME type of the file (e.g., 'image/png', 'application/pdf').
173
+
174
+ Raises:
175
+ ValueError: If filename is empty or MIME type is invalid/not allowed.
176
+ AttachmentSizeError: If content exceeds configured max_size.
177
+ ContentMismatchError: If content does not match declared MIME type.
178
+
179
+ Example:
180
+ >>> attachment = Attachment("report.pdf", pdf_bytes, "application/pdf")
181
+ """
182
+
183
+ filename: str
184
+ content: bytes
185
+ mime_type: str
186
+ _max_size: int | None = field(default=None, repr=False, compare=False)
187
+
188
+ def __post_init__(self) -> None:
189
+ """Validate attachment data using config from closure."""
190
+ sanitized = _do_sanitize_filename(self.filename)
191
+ if sanitized != self.filename:
192
+ object.__setattr__(self, "filename", sanitized)
193
+
194
+ _validate_extension(
195
+ self.filename, _get_extension(self.filename), blocked_extensions, allowed_extensions
196
+ )
197
+
198
+ effective_max_size = self._max_size if self._max_size is not None else max_size
199
+ if len(self.content) > effective_max_size:
200
+ raise AttachmentSizeError(self.filename, len(self.content), effective_max_size)
201
+
202
+ _validate_mime_type(self.mime_type, self.filename, allowed_mime_types)
203
+
204
+ if validate_content:
205
+ validate_content_mime_type(
206
+ self.content,
207
+ self.mime_type,
208
+ self.filename,
209
+ allow_unrecognized=allow_unrecognized_content,
210
+ )
211
+
212
+ @classmethod
213
+ def from_bytes(
214
+ cls,
215
+ content: bytes,
216
+ filename: str,
217
+ mime_type: str,
218
+ max_size_override: int | None = None,
219
+ ) -> Attachment:
220
+ """Create an Attachment from bytes with size validation before object creation.
221
+
222
+ Note: The bytes must already be in memory. This method validates size before
223
+ creating the Attachment object. For memory-safe loading from disk, use from_file().
224
+ """
225
+ limit = max_size if max_size_override is None else max_size_override
226
+ if len(content) > limit:
227
+ raise AttachmentSizeError(_sanitize_filename(filename), len(content), limit)
228
+ return cls(filename=filename, content=content, mime_type=mime_type, _max_size=limit)
229
+
230
+ @classmethod
231
+ def from_file(
232
+ cls,
233
+ file_path: str | Path,
234
+ filename: str | None = None,
235
+ mime_type: str | None = None,
236
+ max_size_override: int | None = None,
237
+ ) -> Attachment:
238
+ """Create an Attachment from a file path with bounded read for size safety.
239
+
240
+ Uses a bounded read to prevent loading oversized files into memory,
241
+ avoiding TOCTOU issues where a file could grow between size check and read.
242
+ """
243
+ path = Path(file_path)
244
+ limit = max_size if max_size_override is None else max_size_override
245
+
246
+ # Early check to reject obviously oversized files without opening them
247
+ file_size = path.stat().st_size
248
+ if file_size > limit:
249
+ raise AttachmentSizeError(path.name, file_size, limit)
250
+
251
+ # Bounded read to prevent TOCTOU: even if file grows after stat(),
252
+ # we never load more than limit + 1 bytes
253
+ with path.open("rb") as f:
254
+ content = f.read(limit + 1)
255
+
256
+ if len(content) > limit:
257
+ # Report actual bytes read (not stat size) for TOCTOU consistency
258
+ raise AttachmentSizeError(path.name, len(content), limit)
259
+
260
+ effective_filename = filename or path.name
261
+ return cls(
262
+ filename=effective_filename,
263
+ content=content,
264
+ mime_type=mime_type or _guess_mime_type(effective_filename),
265
+ _max_size=limit,
266
+ )
267
+
268
+ return Attachment
@@ -0,0 +1,82 @@
1
+ """MIME type validation utilities using puremagic for content detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import puremagic
6
+
7
+
8
+ class ContentMismatchError(ValueError):
9
+ """Raised when attachment content does not match the declared MIME type."""
10
+
11
+
12
+ # MIME type equivalences - puremagic may return alternative names for the same format
13
+ # Maps canonical types to their aliases
14
+ _MIME_EQUIVALENCES: dict[str, set[str]] = {
15
+ "image/bmp": {"image/x-ms-bmp"},
16
+ "application/gzip": {"application/x-gzip"},
17
+ "application/zip": {"application/x-zip-compressed"},
18
+ }
19
+
20
+ # Reverse mapping: alias -> canonical type
21
+ _ALIAS_TO_CANONICAL: dict[str, str] = {
22
+ alias: canonical for canonical, aliases in _MIME_EQUIVALENCES.items() for alias in aliases
23
+ }
24
+
25
+ # Text-based formats that don't have reliable magic bytes.
26
+ # Note: These types are NOT in the default allowed MIME types for security reasons.
27
+ # If users explicitly allow these types, content validation will be skipped.
28
+ _SKIP_VALIDATION_TYPES = {"application/json", "application/xml", "application/rtf", "image/svg+xml"}
29
+
30
+
31
+ def validate_content_mime_type(
32
+ content: bytes,
33
+ mime_type: str,
34
+ filename: str,
35
+ *,
36
+ allow_unrecognized: bool = False,
37
+ ) -> None:
38
+ """Validate that content matches the declared MIME type using magic bytes.
39
+
40
+ Args:
41
+ content: The binary content to validate.
42
+ mime_type: The declared MIME type.
43
+ filename: The filename (used for error messages).
44
+ allow_unrecognized: If True, allow content that cannot be identified.
45
+
46
+ Raises:
47
+ ContentMismatchError: If content does not match the declared MIME type.
48
+ """
49
+ # Skip validation for empty content, text types, and text-based formats
50
+ if not content or mime_type.startswith("text/") or mime_type in _SKIP_VALIDATION_TYPES:
51
+ return
52
+
53
+ try:
54
+ detected = puremagic.magic_string(content)
55
+ except puremagic.PureError as err:
56
+ if allow_unrecognized:
57
+ return
58
+ raise ContentMismatchError(
59
+ f"Content of '{filename}' does not match declared MIME type '{mime_type}'. "
60
+ "Could not identify file type from content."
61
+ ) from err
62
+
63
+ detected_mimes = {m.mime_type for m in detected if m.mime_type}
64
+
65
+ # Direct match
66
+ if mime_type in detected_mimes:
67
+ return
68
+
69
+ # Check if declared type is canonical and detected includes an alias
70
+ equivalent_aliases = _MIME_EQUIVALENCES.get(mime_type, set())
71
+ if detected_mimes & equivalent_aliases:
72
+ return
73
+
74
+ # Check if declared type is an alias and detected includes the canonical
75
+ canonical = _ALIAS_TO_CANONICAL.get(mime_type)
76
+ if canonical and canonical in detected_mimes:
77
+ return
78
+
79
+ raise ContentMismatchError(
80
+ f"Content of '{filename}' does not match declared MIME type '{mime_type}'. "
81
+ f"Detected types: {detected_mimes}"
82
+ )
platzky/config.py CHANGED
@@ -9,6 +9,7 @@ import typing as t
9
9
  import yaml
10
10
  from pydantic import BaseModel, ConfigDict, Field, field_validator
11
11
 
12
+ from .attachment.constants import BLOCKED_EXTENSIONS, DEFAULT_MAX_ATTACHMENT_SIZE
12
13
  from .db.db import DBConfig
13
14
  from .db.db_loader import get_db_module
14
15
 
@@ -126,6 +127,99 @@ class TelemetryConfig(StrictBaseModel):
126
127
  return v
127
128
 
128
129
 
130
+ _DEFAULT_ALLOWED_MIME_TYPES: frozenset[str] = frozenset(
131
+ {
132
+ # Image types (binary formats with verifiable magic bytes)
133
+ "image/png",
134
+ "image/jpeg",
135
+ "image/gif",
136
+ "image/webp",
137
+ "image/bmp",
138
+ "image/tiff",
139
+ # Application types (binary formats)
140
+ "application/pdf",
141
+ # Archive types - validated but NEVER auto-extracted (zip bomb protection)
142
+ "application/zip",
143
+ "application/gzip",
144
+ "application/x-tar",
145
+ "application/msword",
146
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
147
+ "application/vnd.ms-excel",
148
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
149
+ "application/vnd.ms-powerpoint",
150
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
151
+ # Audio types
152
+ "audio/mpeg",
153
+ "audio/wav",
154
+ "audio/ogg",
155
+ # Video types
156
+ "video/mp4",
157
+ "video/webm",
158
+ "video/ogg",
159
+ # Note: Text types (text/*, application/json, application/xml, application/rtf,
160
+ # image/svg+xml) are NOT included by default for security reasons. They can
161
+ # bypass content validation and may contain executable code. To allow text
162
+ # types, explicitly add them:
163
+ # AttachmentConfig(allowed_mime_types=_DEFAULT_ALLOWED_MIME_TYPES | {"text/plain"})
164
+ }
165
+ )
166
+
167
+
168
+ class AttachmentConfig(StrictBaseModel):
169
+ """Configuration for attachment handling.
170
+
171
+ Attributes:
172
+ allowed_mime_types: MIME types allowed for attachments.
173
+ validate_content: Whether to validate content matches declared MIME type.
174
+ allow_unrecognized_content: If True, allow content that cannot be identified.
175
+ max_size: Maximum attachment size in bytes (default: 10MB).
176
+ blocked_extensions: File extensions that are PERMANENTLY blocked (executable
177
+ and script formats). These cannot be overridden via allowed_extensions.
178
+ allowed_extensions: File extensions to allow. Defaults to common safe formats
179
+ (images, documents, archives, audio/video). Set to None to block all.
180
+ Note: blocked_extensions takes precedence over allowed_extensions.
181
+ Files without extensions are always blocked when allowed_extensions is set.
182
+ """
183
+
184
+ allowed_mime_types: frozenset[str] = Field(default=_DEFAULT_ALLOWED_MIME_TYPES)
185
+ validate_content: bool = Field(default=True)
186
+ allow_unrecognized_content: bool = Field(default=False)
187
+ max_size: int = Field(default=DEFAULT_MAX_ATTACHMENT_SIZE, gt=0)
188
+ blocked_extensions: frozenset[str] = Field(default=BLOCKED_EXTENSIONS)
189
+ allowed_extensions: frozenset[str] | None = Field(
190
+ default=frozenset(
191
+ {
192
+ # Images
193
+ "png",
194
+ "jpg",
195
+ "jpeg",
196
+ "gif",
197
+ "webp",
198
+ "bmp",
199
+ "tiff",
200
+ # Documents
201
+ "pdf",
202
+ "doc",
203
+ "docx",
204
+ "xls",
205
+ "xlsx",
206
+ "ppt",
207
+ "pptx",
208
+ # Archives
209
+ "zip",
210
+ "gz",
211
+ "tar",
212
+ # Audio/Video
213
+ "mp3",
214
+ "wav",
215
+ "ogg",
216
+ "mp4",
217
+ "webm",
218
+ }
219
+ )
220
+ )
221
+
222
+
129
223
  class Config(StrictBaseModel):
130
224
  """Main application configuration.
131
225
 
@@ -143,6 +237,7 @@ class Config(StrictBaseModel):
143
237
  testing: Enable testing mode
144
238
  feature_flags: Feature flag configuration
145
239
  telemetry: OpenTelemetry configuration
240
+ attachment: Attachment handling configuration
146
241
  """
147
242
 
148
243
  app_name: str = Field(alias="APP_NAME")
@@ -161,6 +256,7 @@ class Config(StrictBaseModel):
161
256
  testing: bool = Field(default=False, alias="TESTING")
162
257
  feature_flags: t.Optional[dict[str, bool]] = Field(default=None, alias="FEATURE_FLAGS")
163
258
  telemetry: TelemetryConfig = Field(default_factory=TelemetryConfig, alias="TELEMETRY")
259
+ attachment: AttachmentConfig = Field(default_factory=AttachmentConfig, alias="ATTACHMENT")
164
260
 
165
261
  @classmethod
166
262
  def model_validate(
platzky/engine.py CHANGED
@@ -1,22 +1,44 @@
1
+ """Flask application engine with notification support."""
2
+
3
+ import logging
1
4
  import os
5
+ import threading
2
6
  from collections.abc import Callable
3
- from concurrent.futures import ThreadPoolExecutor, TimeoutError
7
+ from concurrent.futures import Future, TimeoutError
4
8
  from typing import Any
5
9
 
6
- from flask import Blueprint, Flask, jsonify, make_response, request, session
10
+ from flask import Blueprint, Flask, Response, jsonify, make_response, request, session
7
11
  from flask_babel import Babel
8
12
 
13
+ from platzky.attachment import AttachmentProtocol, create_attachment_class
9
14
  from platzky.config import Config
10
15
  from platzky.db.db import DB
11
16
  from platzky.models import CmsModule
17
+ from platzky.notifier import Notifier, NotifierWithAttachments
18
+
19
+ logger = logging.getLogger(__name__)
12
20
 
13
21
 
14
22
  class Engine(Flask):
15
- def __init__(self, config: Config, db: DB, import_name: str) -> None:
23
+ def __init__(
24
+ self,
25
+ config: Config,
26
+ db: DB,
27
+ import_name: str,
28
+ ) -> None:
29
+ """Initialize the Engine.
30
+
31
+ Args:
32
+ config: Application configuration.
33
+ db: Database instance.
34
+ import_name: Name of the application module.
35
+ """
16
36
  super().__init__(import_name)
17
37
  self.config.from_mapping(config.model_dump(by_alias=True))
18
38
  self.db = db
19
- self.notifiers = []
39
+ self.Attachment: type[AttachmentProtocol] = create_attachment_class(config.attachment)
40
+ self.notifiers: list[Notifier] = []
41
+ self.notifiers_with_attachments: list[NotifierWithAttachments] = []
20
42
  self.login_methods = []
21
43
  self.dynamic_body = ""
22
44
  self.dynamic_head = ""
@@ -37,13 +59,34 @@ class Engine(Flask):
37
59
  # TODO add plugins as CMS Module - all plugins should be visible from
38
60
  # admin page at least as configuration
39
61
 
40
- def notify(self, message: str):
62
+ def notify(self, message: str, attachments: list[AttachmentProtocol] | None = None) -> None:
63
+ """Send a notification to all registered notifiers.
64
+
65
+ Args:
66
+ message: The notification message text.
67
+ attachments: Optional list of Attachment objects created via engine.Attachment().
68
+ """
41
69
  for notifier in self.notifiers:
42
70
  notifier(message)
71
+ for notifier in self.notifiers_with_attachments:
72
+ notifier(message, attachments=attachments)
43
73
 
44
- def add_notifier(self, notifier: Callable[[str], None]) -> None:
74
+ def add_notifier(self, notifier: Notifier) -> None:
75
+ """Register a simple notifier (message only).
76
+
77
+ Args:
78
+ notifier: A callable that accepts a message string.
79
+ """
45
80
  self.notifiers.append(notifier)
46
81
 
82
+ def add_notifier_with_attachments(self, notifier: NotifierWithAttachments) -> None:
83
+ """Register a notifier that supports attachments.
84
+
85
+ Args:
86
+ notifier: A callable that accepts message and optional attachments.
87
+ """
88
+ self.notifiers_with_attachments.append(notifier)
89
+
47
90
  def add_cms_module(self, module: CmsModule):
48
91
  """Add a CMS module to the modules list."""
49
92
  self.cms_modules.append(module)
@@ -82,62 +125,68 @@ class Engine(Flask):
82
125
  raise TypeError(f"check_function must be callable, got {type(check_function)}")
83
126
  self.health_checks.append((name, check_function))
84
127
 
85
- def _register_default_health_endpoints(self):
86
- """Register default health endpoints"""
87
-
128
+ def _register_default_health_endpoints(self) -> None:
129
+ """Register default health endpoints."""
88
130
  health_bp = Blueprint("health", __name__)
89
- HEALTH_CHECK_TIMEOUT = 10 # seconds
131
+ health_check_timeout = 10 # seconds
132
+
133
+ def run_health_check(
134
+ check_func: Callable[[], None],
135
+ timeout: int,
136
+ ) -> str:
137
+ """Run a health check with timeout using a daemon thread.
138
+
139
+ Uses daemon threads so stuck checks don't prevent app shutdown.
140
+ Note: Health checks should implement their own internal timeouts
141
+ for proper resource cleanup - the external timeout only prevents
142
+ blocking the response, but the check continues running.
143
+ """
144
+ future: Future[None] = Future()
145
+
146
+ def run() -> None:
147
+ try:
148
+ check_func()
149
+ future.set_result(None)
150
+ except Exception as e:
151
+ future.set_exception(e)
152
+
153
+ thread = threading.Thread(target=run, daemon=True)
154
+ thread.start()
155
+
156
+ try:
157
+ future.result(timeout=timeout)
158
+ except TimeoutError:
159
+ return "failed: timeout"
160
+ except Exception as e:
161
+ logger.exception("Health check failed")
162
+ return f"failed: {e!s}"
163
+ else:
164
+ return "ok"
90
165
 
91
166
  @health_bp.route("/health/liveness")
92
- def liveness():
167
+ def liveness() -> tuple[Response, int]:
93
168
  """Simple liveness check - is the app running?"""
94
169
  return jsonify({"status": "alive"}), 200
95
170
 
96
171
  @health_bp.route("/health/readiness")
97
- def readiness():
172
+ def readiness() -> Response:
98
173
  """Readiness check - can the app serve traffic?"""
99
174
  health_status: dict[str, Any] = {"status": "ready", "checks": {}}
100
- status_code = 200
101
175
 
102
- executor = ThreadPoolExecutor(max_workers=1)
103
- try:
104
- # Database health check with timeout
105
- future = executor.submit(self.db.health_check)
106
- try:
107
- future.result(timeout=HEALTH_CHECK_TIMEOUT)
108
- health_status["checks"]["database"] = "ok"
109
- except TimeoutError:
110
- health_status["checks"]["database"] = "failed: timeout"
111
- health_status["status"] = "not_ready"
112
- status_code = 503
113
- except Exception as e:
114
- health_status["checks"]["database"] = f"failed: {e!s}"
176
+ all_checks = [("database", self.db.health_check), *self.health_checks]
177
+
178
+ for check_name, check_func in all_checks:
179
+ status = run_health_check(check_func, health_check_timeout)
180
+ health_status["checks"][check_name] = status
181
+ if status != "ok":
115
182
  health_status["status"] = "not_ready"
116
- status_code = 503
117
-
118
- # Run application-registered health checks
119
- for check_name, check_func in self.health_checks:
120
- future = executor.submit(check_func)
121
- try:
122
- future.result(timeout=HEALTH_CHECK_TIMEOUT)
123
- health_status["checks"][check_name] = "ok"
124
- except TimeoutError:
125
- health_status["checks"][check_name] = "failed: timeout"
126
- health_status["status"] = "not_ready"
127
- status_code = 503
128
- except Exception as e:
129
- health_status["checks"][check_name] = f"failed: {e!s}"
130
- health_status["status"] = "not_ready"
131
- status_code = 503
132
- finally:
133
- # Shutdown without waiting if any futures are still running
134
- executor.shutdown(wait=False)
135
183
 
184
+ status_code = 200 if health_status["status"] == "ready" else 503
136
185
  return make_response(jsonify(health_status), status_code)
137
186
 
138
- # Simple /health alias for liveness
139
187
  @health_bp.route("/health")
140
- def health():
188
+ def health() -> tuple[Response, int]:
189
+ """Simple /health alias for liveness."""
141
190
  return liveness()
142
191
 
143
192
  self.register_blueprint(health_bp)
platzky/notifier.py ADDED
@@ -0,0 +1,43 @@
1
+ """Notification system types and protocols.
2
+
3
+ This module provides notifier protocols for the platzky notification system.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING, Protocol
9
+
10
+ if TYPE_CHECKING:
11
+ from platzky.attachment import AttachmentProtocol
12
+
13
+
14
+ class Notifier(Protocol):
15
+ """Protocol for simple notification handlers (message only).
16
+
17
+ Example:
18
+ def slack_notifier(message: str) -> None:
19
+ slack.post(message)
20
+
21
+ engine.add_notifier(slack_notifier)
22
+ """
23
+
24
+ def __call__(self, message: str) -> None: ...
25
+
26
+
27
+ class NotifierWithAttachments(Protocol):
28
+ """Protocol for notification handlers that support attachments.
29
+
30
+ SECURITY: Archive attachments (zip, gzip, tar) are validated but never
31
+ extracted by platzky. Notifier implementations MUST NOT auto-extract
32
+ archives to avoid zip bomb attacks.
33
+
34
+ Example:
35
+ def email_notifier(message: str, attachments: list | None = None) -> None:
36
+ send_email(message, attachments=attachments)
37
+
38
+ engine.add_notifier_with_attachments(email_notifier)
39
+ """
40
+
41
+ def __call__(
42
+ self, message: str, attachments: list[AttachmentProtocol] | None = None
43
+ ) -> None: ...
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: platzky
3
- Version: 1.2.2
3
+ Version: 1.3.0
4
4
  Summary: Not only blog engine
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -30,6 +30,7 @@ Requires-Dist: opentelemetry-exporter-otlp-proto-grpc (>=1.27.0,<2.0.0) ; extra
30
30
  Requires-Dist: opentelemetry-instrumentation-flask (>=0.48b0,<0.49) ; extra == "telemetry"
31
31
  Requires-Dist: opentelemetry-instrumentation-logging (>=0.48b0,<0.49) ; extra == "telemetry"
32
32
  Requires-Dist: opentelemetry-sdk (>=1.27.0,<2.0.0) ; extra == "telemetry"
33
+ Requires-Dist: puremagic (>=1.30,<2.0)
33
34
  Requires-Dist: pydantic (>=2.7.1,<3.0.0)
34
35
  Requires-Dist: pygithub (>=2.6.1,<3.0.0)
35
36
  Requires-Dist: pymongo (>=4.7.0,<5.0.0)
@@ -4,10 +4,14 @@ platzky/admin/fake_login.py,sha256=Z_4M4PLQ73qL-sKh05CmDx_nFy8S30PdsNfPPDeFSmE,3
4
4
  platzky/admin/templates/admin.html,sha256=zgjROhSezayZqnNFezvVa0MEfgmXLvOM8HRRaZemkQw,688
5
5
  platzky/admin/templates/login.html,sha256=oBNuv130iMTwXrtRnDUDcGIGvu0O2VsIbjQxw-Tjd7Y,380
6
6
  platzky/admin/templates/module.html,sha256=WuQZxKQDD4INl-QF2uiKHf9Fmf2h7cEW9RLe1nWKC8k,175
7
+ platzky/attachment/__init__.py,sha256=v60Fk8yrTc4KeQdHzEfJWbsXD7uQp3ZAHQDksrBEuws,1349
8
+ platzky/attachment/constants.py,sha256=9WB8w_sKxsq2DM5hfz2ShRI-nUT2E5v9tfBWIOgrq-U,4135
9
+ platzky/attachment/core.py,sha256=-jrelChnHEeowg5d4-41fY6slFfqnv3fEqiI_sMPFd8,9656
10
+ platzky/attachment/mime_validation.py,sha256=VO272nF7K_Mx_Zefuw-10M-Tapj0ZxX197syh8yy0oQ,2874
7
11
  platzky/blog/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
12
  platzky/blog/blog.py,sha256=n3bsZ1GpVCmvxCFMiF7QUDb_PHbmBiTu0GDu3r_Su24,5490
9
13
  platzky/blog/comment_form.py,sha256=yOuXvX9PZLc6qQLIWZWLFcbwFQD4a849X82PlXKUzdk,805
10
- platzky/config.py,sha256=_TQNZ8w8-xQImtm6Gw2SawBqf-UFxF9okIlZi_DGrGA,7540
14
+ platzky/config.py,sha256=4HPfJ1bY2A1auSR9dbGwDIo65SRolQ6H3imMDyuvH30,11194
11
15
  platzky/db/README.md,sha256=IO-LoDsd4dLBZenaz423EZjvEOQu_8m2OC0G7du170w,1753
12
16
  platzky/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
17
  platzky/db/db.py,sha256=gi5uxvY8Ww8O4y2rxaH1Zj_12Yno8SbILvIaWnQPbYQ,4778
@@ -18,10 +22,11 @@ platzky/db/graph_ql_db.py,sha256=a8LGPJKoNpmTkJ6Bb89eg6m6Q9GFetp5z8A0xuemuSk,145
18
22
  platzky/db/json_db.py,sha256=pANXJZzVPAO890TRy3IvzHjpCaeDgNNgNOR5Uhkk2h4,8078
19
23
  platzky/db/json_file_db.py,sha256=Tl6b67p4hNViXSAjujXZ9vtVHN61QhrzJUgOuvngyMI,2232
20
24
  platzky/db/mongodb_db.py,sha256=nq07j0NldK014qRL1mF-cvBXQF1LKKTTeWxaZdyzjAs,8595
21
- platzky/engine.py,sha256=9Y74nrO4gwr9_CqRTzPs9LtcY2t0fs0XtPyjPiqRlVQ,5573
25
+ platzky/engine.py,sha256=wNVNjLA5UpN5sNF2S5ltCJM0e9rKAbrp6SqInqwbgz8,7079
22
26
  platzky/locale/en/LC_MESSAGES/messages.po,sha256=WaZGlFAegKRq7CSz69dWKic-mKvQFhVvssvExxNmGaU,1400
23
27
  platzky/locale/pl/LC_MESSAGES/messages.po,sha256=sUPxMKDeEOoZ5UIg94rGxZD06YVWiAMWIby2XE51Hrc,1624
24
28
  platzky/models.py,sha256=Ws5ZSWf5EhcpFxl3Yeze2pQiesjnjAA_haBJ90bN6lk,7435
29
+ platzky/notifier.py,sha256=fh6_sdeD9TRFPkBjnuSz9jH6UKoSDkaqqGfZD1dmVZc,1214
25
30
  platzky/platzky.py,sha256=1LKYq8pLm1QBlOcEPhugxWi8W0vuWqjjINIFK8b2Kow,9319
26
31
  platzky/plugin/plugin.py,sha256=KZb6VEph__lx9xrv5Ay4h4XkFFYbodV5OimaG6B9IDc,2812
27
32
  platzky/plugin/plugin_loader.py,sha256=eKG6zodUCkiRLxJ2ZX9zdN4-ZrZ9EwssoY1SDtThaFo,6707
@@ -41,7 +46,7 @@ platzky/templates/post.html,sha256=GSgjIZsOQKtNx3cEbquSjZ5L4whPnG6MzRyoq9k4B8Q,1
41
46
  platzky/templates/robots.txt,sha256=2_j2tiYtYJnzZUrANiX9pvBxyw5Dp27fR_co18BPEJ0,116
42
47
  platzky/templates/sitemap.xml,sha256=iIJZ91_B5ZuNLCHsRtsGKZlBAXojOTP8kffqKLacgvs,578
43
48
  platzky/www_handler.py,sha256=pF6Rmvem1sdVqHD7z3RLrDuG-CwAqfGCti50_NPsB2w,725
44
- platzky-1.2.2.dist-info/METADATA,sha256=X-0JrkrtD11LPZthFnzRinn3fjUXiWYRXnnkEzjgMcg,2556
45
- platzky-1.2.2.dist-info/WHEEL,sha256=3ny-bZhpXrU6vSQ1UPG34FoxZBp3lVcvK0LkgUz6VLk,88
46
- platzky-1.2.2.dist-info/licenses/LICENSE,sha256=wCdfk-qEosi6BDwiBulMfKMi0hxp1UXV0DdjLrRm788,1077
47
- platzky-1.2.2.dist-info/RECORD,,
49
+ platzky-1.3.0.dist-info/METADATA,sha256=tOipcd7zixJPKW2a0fRgnPQzqVyrET55p0MeVxYRNc0,2595
50
+ platzky-1.3.0.dist-info/WHEEL,sha256=3ny-bZhpXrU6vSQ1UPG34FoxZBp3lVcvK0LkgUz6VLk,88
51
+ platzky-1.3.0.dist-info/licenses/LICENSE,sha256=wCdfk-qEosi6BDwiBulMfKMi0hxp1UXV0DdjLrRm788,1077
52
+ platzky-1.3.0.dist-info/RECORD,,