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.
- platzky/attachment/__init__.py +39 -0
- platzky/attachment/constants.py +139 -0
- platzky/attachment/core.py +268 -0
- platzky/attachment/mime_validation.py +82 -0
- platzky/config.py +96 -0
- platzky/engine.py +96 -47
- platzky/notifier.py +43 -0
- {platzky-1.2.2.dist-info → platzky-1.3.0.dist-info}/METADATA +2 -1
- {platzky-1.2.2.dist-info → platzky-1.3.0.dist-info}/RECORD +11 -6
- {platzky-1.2.2.dist-info → platzky-1.3.0.dist-info}/WHEEL +0 -0
- {platzky-1.2.2.dist-info → platzky-1.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
|
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__(
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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.
|
|
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=
|
|
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=
|
|
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.
|
|
45
|
-
platzky-1.
|
|
46
|
-
platzky-1.
|
|
47
|
-
platzky-1.
|
|
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,,
|
|
File without changes
|
|
File without changes
|