homesec 0.1.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.
- homesec/__init__.py +20 -0
- homesec/app.py +393 -0
- homesec/cli.py +159 -0
- homesec/config/__init__.py +18 -0
- homesec/config/loader.py +109 -0
- homesec/config/validation.py +82 -0
- homesec/errors.py +71 -0
- homesec/health/__init__.py +5 -0
- homesec/health/server.py +226 -0
- homesec/interfaces.py +249 -0
- homesec/logging_setup.py +176 -0
- homesec/maintenance/__init__.py +1 -0
- homesec/maintenance/cleanup_clips.py +632 -0
- homesec/models/__init__.py +79 -0
- homesec/models/alert.py +32 -0
- homesec/models/clip.py +71 -0
- homesec/models/config.py +362 -0
- homesec/models/events.py +184 -0
- homesec/models/filter.py +62 -0
- homesec/models/source.py +77 -0
- homesec/models/storage.py +12 -0
- homesec/models/vlm.py +99 -0
- homesec/pipeline/__init__.py +6 -0
- homesec/pipeline/alert_policy.py +5 -0
- homesec/pipeline/core.py +639 -0
- homesec/plugins/__init__.py +62 -0
- homesec/plugins/alert_policies/__init__.py +80 -0
- homesec/plugins/alert_policies/default.py +111 -0
- homesec/plugins/alert_policies/noop.py +60 -0
- homesec/plugins/analyzers/__init__.py +126 -0
- homesec/plugins/analyzers/openai.py +446 -0
- homesec/plugins/filters/__init__.py +124 -0
- homesec/plugins/filters/yolo.py +317 -0
- homesec/plugins/notifiers/__init__.py +80 -0
- homesec/plugins/notifiers/mqtt.py +189 -0
- homesec/plugins/notifiers/multiplex.py +106 -0
- homesec/plugins/notifiers/sendgrid_email.py +228 -0
- homesec/plugins/storage/__init__.py +116 -0
- homesec/plugins/storage/dropbox.py +272 -0
- homesec/plugins/storage/local.py +108 -0
- homesec/plugins/utils.py +63 -0
- homesec/py.typed +0 -0
- homesec/repository/__init__.py +5 -0
- homesec/repository/clip_repository.py +552 -0
- homesec/sources/__init__.py +17 -0
- homesec/sources/base.py +224 -0
- homesec/sources/ftp.py +209 -0
- homesec/sources/local_folder.py +238 -0
- homesec/sources/rtsp.py +1251 -0
- homesec/state/__init__.py +10 -0
- homesec/state/postgres.py +501 -0
- homesec/storage_paths.py +46 -0
- homesec/telemetry/__init__.py +0 -0
- homesec/telemetry/db/__init__.py +1 -0
- homesec/telemetry/db/log_table.py +16 -0
- homesec/telemetry/db_log_handler.py +246 -0
- homesec/telemetry/postgres_settings.py +42 -0
- homesec-0.1.0.dist-info/METADATA +446 -0
- homesec-0.1.0.dist-info/RECORD +62 -0
- homesec-0.1.0.dist-info/WHEEL +4 -0
- homesec-0.1.0.dist-info/entry_points.txt +2 -0
- homesec-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""SendGrid email notifier plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import html
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
|
|
11
|
+
import aiohttp
|
|
12
|
+
|
|
13
|
+
from homesec.models.alert import Alert
|
|
14
|
+
from homesec.models.config import SendGridEmailConfig
|
|
15
|
+
from homesec.models.vlm import SequenceAnalysis
|
|
16
|
+
from homesec.interfaces import Notifier
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SendGridEmailNotifier(Notifier):
|
|
22
|
+
"""SendGrid email notifier for HomeSec alerts."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, config: SendGridEmailConfig) -> None:
|
|
25
|
+
self._api_key_env = config.api_key_env
|
|
26
|
+
self._from_email = config.from_email
|
|
27
|
+
self._from_name = config.from_name
|
|
28
|
+
self._to_emails = list(config.to_emails)
|
|
29
|
+
self._cc_emails = list(config.cc_emails)
|
|
30
|
+
self._bcc_emails = list(config.bcc_emails)
|
|
31
|
+
self._subject_template = config.subject_template
|
|
32
|
+
self._text_template = config.text_template
|
|
33
|
+
self._html_template = config.html_template
|
|
34
|
+
self._timeout_s = float(config.request_timeout_s)
|
|
35
|
+
self._api_base = config.api_base.rstrip("/")
|
|
36
|
+
|
|
37
|
+
self._api_key = os.getenv(self._api_key_env)
|
|
38
|
+
if not self._api_key:
|
|
39
|
+
logger.warning("SendGrid API key not found in env: %s", self._api_key_env)
|
|
40
|
+
|
|
41
|
+
self._session: aiohttp.ClientSession | None = None
|
|
42
|
+
self._shutdown_called = False
|
|
43
|
+
|
|
44
|
+
async def send(self, alert: Alert) -> None:
|
|
45
|
+
"""Send alert notification via SendGrid."""
|
|
46
|
+
if self._shutdown_called:
|
|
47
|
+
raise RuntimeError("Notifier has been shut down")
|
|
48
|
+
if not self._api_key:
|
|
49
|
+
raise RuntimeError("SendGrid API key missing from environment")
|
|
50
|
+
|
|
51
|
+
subject = self._render_subject(alert)
|
|
52
|
+
text_body = self._render_text(alert) if self._text_template else ""
|
|
53
|
+
html_body = self._render_html(alert) if self._html_template else ""
|
|
54
|
+
if not text_body and not html_body:
|
|
55
|
+
raise RuntimeError("SendGrid email requires text or html content")
|
|
56
|
+
|
|
57
|
+
payload = self._build_payload(subject, text_body, html_body)
|
|
58
|
+
headers = {"Authorization": f"Bearer {self._api_key}"}
|
|
59
|
+
url = f"{self._api_base}/mail/send"
|
|
60
|
+
session = await self._get_session()
|
|
61
|
+
|
|
62
|
+
async with session.post(url, json=payload, headers=headers) as response:
|
|
63
|
+
if response.status >= 400:
|
|
64
|
+
details = await response.text()
|
|
65
|
+
raise RuntimeError(
|
|
66
|
+
f"SendGrid email send failed ({response.status}): {details}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
logger.info(
|
|
70
|
+
"Sent SendGrid email alert: to=%s clip_id=%s",
|
|
71
|
+
",".join(self._to_emails),
|
|
72
|
+
alert.clip_id,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def ping(self) -> bool:
|
|
76
|
+
"""Health check - verify SendGrid credentials and connectivity."""
|
|
77
|
+
if self._shutdown_called or not self._api_key:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
url = f"{self._api_base}/user/profile"
|
|
81
|
+
headers = {"Authorization": f"Bearer {self._api_key}"}
|
|
82
|
+
session = await self._get_session()
|
|
83
|
+
try:
|
|
84
|
+
async with session.get(url, headers=headers) as response:
|
|
85
|
+
if response.status >= 400:
|
|
86
|
+
return False
|
|
87
|
+
await response.read()
|
|
88
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
89
|
+
logger.warning("SendGrid ping failed: %s", e)
|
|
90
|
+
return False
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
async def shutdown(self, timeout: float | None = None) -> None:
|
|
94
|
+
"""Cleanup resources - close HTTP session."""
|
|
95
|
+
_ = timeout
|
|
96
|
+
if self._shutdown_called:
|
|
97
|
+
return
|
|
98
|
+
self._shutdown_called = True
|
|
99
|
+
|
|
100
|
+
if self._session and not self._session.closed:
|
|
101
|
+
await self._session.close()
|
|
102
|
+
|
|
103
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
104
|
+
if self._session is None or self._session.closed:
|
|
105
|
+
timeout = aiohttp.ClientTimeout(total=self._timeout_s)
|
|
106
|
+
self._session = aiohttp.ClientSession(timeout=timeout)
|
|
107
|
+
return self._session
|
|
108
|
+
|
|
109
|
+
def _build_payload(self, subject: str, text_body: str, html_body: str) -> dict[str, object]:
|
|
110
|
+
personalization: dict[str, object] = {
|
|
111
|
+
"to": [{"email": email} for email in self._to_emails],
|
|
112
|
+
"subject": subject,
|
|
113
|
+
}
|
|
114
|
+
if self._cc_emails:
|
|
115
|
+
personalization["cc"] = [{"email": email} for email in self._cc_emails]
|
|
116
|
+
if self._bcc_emails:
|
|
117
|
+
personalization["bcc"] = [{"email": email} for email in self._bcc_emails]
|
|
118
|
+
|
|
119
|
+
sender: dict[str, str] = {"email": self._from_email}
|
|
120
|
+
if self._from_name:
|
|
121
|
+
sender["name"] = self._from_name
|
|
122
|
+
|
|
123
|
+
content: list[dict[str, str]] = []
|
|
124
|
+
if text_body:
|
|
125
|
+
content.append({"type": "text/plain", "value": text_body})
|
|
126
|
+
if html_body:
|
|
127
|
+
content.append({"type": "text/html", "value": html_body})
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
"personalizations": [personalization],
|
|
131
|
+
"from": sender,
|
|
132
|
+
"content": content,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
def _build_context(self, alert: Alert) -> defaultdict[str, str]:
|
|
136
|
+
view_url = alert.view_url or alert.storage_uri or "n/a"
|
|
137
|
+
analysis_html = self._render_analysis_html(alert.analysis)
|
|
138
|
+
return defaultdict(
|
|
139
|
+
str,
|
|
140
|
+
{
|
|
141
|
+
"camera_name": alert.camera_name,
|
|
142
|
+
"clip_id": alert.clip_id,
|
|
143
|
+
"risk_level": alert.risk_level or "unknown",
|
|
144
|
+
"activity_type": alert.activity_type or "unknown",
|
|
145
|
+
"notify_reason": alert.notify_reason,
|
|
146
|
+
"summary": alert.summary or "",
|
|
147
|
+
"view_url": view_url,
|
|
148
|
+
"storage_uri": alert.storage_uri or "",
|
|
149
|
+
"ts": alert.ts.isoformat(),
|
|
150
|
+
"upload_failed": str(alert.upload_failed),
|
|
151
|
+
"analysis_html": analysis_html,
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def _render_subject(self, alert: Alert) -> str:
|
|
156
|
+
return self._subject_template.format_map(self._build_context(alert)).strip()
|
|
157
|
+
|
|
158
|
+
def _render_text(self, alert: Alert) -> str:
|
|
159
|
+
return self._text_template.format_map(self._build_context(alert)).strip()
|
|
160
|
+
|
|
161
|
+
def _render_html(self, alert: Alert) -> str:
|
|
162
|
+
return self._html_template.format_map(self._build_context(alert)).strip()
|
|
163
|
+
|
|
164
|
+
def _render_analysis_html(self, analysis: SequenceAnalysis | None) -> str:
|
|
165
|
+
if analysis is None:
|
|
166
|
+
return ""
|
|
167
|
+
return self._render_value_html(analysis.model_dump())
|
|
168
|
+
|
|
169
|
+
def _render_value_html(self, value: object) -> str:
|
|
170
|
+
match value:
|
|
171
|
+
case None:
|
|
172
|
+
return "<em>n/a</em>"
|
|
173
|
+
case dict() as mapping:
|
|
174
|
+
return self._render_dict_html(mapping)
|
|
175
|
+
case list() as items:
|
|
176
|
+
return self._render_list_html(items)
|
|
177
|
+
case _:
|
|
178
|
+
return html.escape(str(value))
|
|
179
|
+
|
|
180
|
+
def _render_dict_html(self, mapping: dict[str, object]) -> str:
|
|
181
|
+
items = []
|
|
182
|
+
for key, value in mapping.items():
|
|
183
|
+
rendered_value = self._render_value_html(value)
|
|
184
|
+
items.append(
|
|
185
|
+
f"<li><strong>{html.escape(str(key))}:</strong> {rendered_value}</li>"
|
|
186
|
+
)
|
|
187
|
+
return "<ul>" + "".join(items) + "</ul>"
|
|
188
|
+
|
|
189
|
+
def _render_list_html(self, items: list[object]) -> str:
|
|
190
|
+
if not items:
|
|
191
|
+
return "<ul><li>none</li></ul>"
|
|
192
|
+
|
|
193
|
+
rendered_items = []
|
|
194
|
+
for idx, item in enumerate(items, start=1):
|
|
195
|
+
rendered_value = self._render_value_html(item)
|
|
196
|
+
label = f"Item {idx}"
|
|
197
|
+
if isinstance(item, dict):
|
|
198
|
+
rendered_items.append(f"<li>{html.escape(label)}{rendered_value}</li>")
|
|
199
|
+
else:
|
|
200
|
+
rendered_items.append(f"<li>{rendered_value}</li>")
|
|
201
|
+
return "<ul>" + "".join(rendered_items) + "</ul>"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# Plugin registration
|
|
205
|
+
from typing import cast
|
|
206
|
+
from pydantic import BaseModel
|
|
207
|
+
from homesec.plugins.notifiers import NotifierPlugin, notifier_plugin
|
|
208
|
+
from homesec.interfaces import Notifier
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@notifier_plugin(name="sendgrid_email")
|
|
212
|
+
def sendgrid_email_plugin() -> NotifierPlugin:
|
|
213
|
+
"""SendGrid email notifier plugin factory.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
NotifierPlugin for SendGrid email notifications
|
|
217
|
+
"""
|
|
218
|
+
from homesec.models.config import SendGridEmailConfig
|
|
219
|
+
|
|
220
|
+
def factory(cfg: BaseModel) -> Notifier:
|
|
221
|
+
# Config is already validated by app.py, just cast
|
|
222
|
+
return SendGridEmailNotifier(cast(SendGridEmailConfig, cfg))
|
|
223
|
+
|
|
224
|
+
return NotifierPlugin(
|
|
225
|
+
name="sendgrid_email",
|
|
226
|
+
config_model=SendGridEmailConfig,
|
|
227
|
+
factory=factory,
|
|
228
|
+
)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Storage backend plugins and registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Callable, TypeVar
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from homesec.interfaces import StorageBackend
|
|
12
|
+
from homesec.models.config import StorageConfig
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Type alias for clarity
|
|
17
|
+
StorageFactory = Callable[[BaseModel], StorageBackend]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class StoragePlugin:
|
|
22
|
+
"""Metadata for a storage backend plugin."""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
config_model: type[BaseModel]
|
|
26
|
+
factory: StorageFactory
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
STORAGE_REGISTRY: dict[str, StoragePlugin] = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def register_storage(plugin: StoragePlugin) -> None:
|
|
33
|
+
"""Register a storage plugin with collision detection.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
plugin: Storage plugin to register
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ValueError: If a plugin with the same name is already registered
|
|
40
|
+
"""
|
|
41
|
+
if plugin.name in STORAGE_REGISTRY:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"Storage plugin '{plugin.name}' is already registered. "
|
|
44
|
+
f"Plugin names must be unique across all storage plugins."
|
|
45
|
+
)
|
|
46
|
+
STORAGE_REGISTRY[plugin.name] = plugin
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
T = TypeVar("T", bound=Callable[[], StoragePlugin])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def storage_plugin(name: str) -> Callable[[T], T]:
|
|
53
|
+
"""Decorator to register a storage backend plugin.
|
|
54
|
+
|
|
55
|
+
Usage:
|
|
56
|
+
@storage_plugin(name="my_storage")
|
|
57
|
+
def my_storage_plugin() -> StoragePlugin:
|
|
58
|
+
return StoragePlugin(...)
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
name: Plugin name (for validation only - must match plugin.name)
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Decorator function that registers the plugin
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def decorator(factory_fn: T) -> T:
|
|
68
|
+
plugin = factory_fn()
|
|
69
|
+
register_storage(plugin)
|
|
70
|
+
return factory_fn
|
|
71
|
+
|
|
72
|
+
return decorator
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def create_storage(config: StorageConfig) -> StorageBackend:
|
|
76
|
+
"""Create storage backend from config using plugin registry.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
config: Storage configuration with backend name and backend-specific settings
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Instantiated storage backend
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
RuntimeError: If backend is unknown or backend-specific config is missing
|
|
86
|
+
"""
|
|
87
|
+
backend_name = config.backend.lower()
|
|
88
|
+
|
|
89
|
+
if backend_name not in STORAGE_REGISTRY:
|
|
90
|
+
available = ", ".join(sorted(STORAGE_REGISTRY.keys()))
|
|
91
|
+
raise RuntimeError(
|
|
92
|
+
f"Unknown storage backend: '{backend_name}'. Available: {available}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
plugin = STORAGE_REGISTRY[backend_name]
|
|
96
|
+
|
|
97
|
+
# Extract backend-specific config using attribute access
|
|
98
|
+
# e.g., config.dropbox, config.local
|
|
99
|
+
specific_config = getattr(config, backend_name, None)
|
|
100
|
+
if specific_config is None:
|
|
101
|
+
raise RuntimeError(
|
|
102
|
+
f"Missing '{backend_name}' config in storage section. "
|
|
103
|
+
f"Add 'storage.{backend_name}' to your config."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return plugin.factory(specific_config)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
__all__ = [
|
|
110
|
+
"StoragePlugin",
|
|
111
|
+
"StorageFactory",
|
|
112
|
+
"STORAGE_REGISTRY",
|
|
113
|
+
"register_storage",
|
|
114
|
+
"storage_plugin",
|
|
115
|
+
"create_storage",
|
|
116
|
+
]
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Dropbox storage backend plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path, PurePosixPath
|
|
9
|
+
from typing import BinaryIO
|
|
10
|
+
import dropbox # type: ignore
|
|
11
|
+
|
|
12
|
+
from homesec.models.config import DropboxStorageConfig
|
|
13
|
+
from homesec.models.storage import StorageUploadResult
|
|
14
|
+
from homesec.interfaces import StorageBackend
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
CHUNK_SIZE = 4 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DropboxStorage(StorageBackend):
|
|
22
|
+
"""Dropbox storage backend.
|
|
23
|
+
|
|
24
|
+
Uses dropbox SDK for file operations.
|
|
25
|
+
Implements idempotent uploads with overwrite mode.
|
|
26
|
+
|
|
27
|
+
Supports two auth modes:
|
|
28
|
+
1. Simple token: Set DROPBOX_TOKEN env var
|
|
29
|
+
2. Refresh token flow: Set DROPBOX_APP_KEY, DROPBOX_APP_SECRET, DROPBOX_REFRESH_TOKEN
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, config: DropboxStorageConfig) -> None:
|
|
33
|
+
"""Initialize Dropbox storage with config validation.
|
|
34
|
+
|
|
35
|
+
Required config:
|
|
36
|
+
root: Root path in Dropbox (e.g., /homecam)
|
|
37
|
+
|
|
38
|
+
Optional config:
|
|
39
|
+
token_env: Env var name for simple token auth (default: DROPBOX_TOKEN)
|
|
40
|
+
app_key_env: Env var name for app key (default: DROPBOX_APP_KEY)
|
|
41
|
+
app_secret_env: Env var name for app secret (default: DROPBOX_APP_SECRET)
|
|
42
|
+
refresh_token_env: Env var name for refresh token (default: DROPBOX_REFRESH_TOKEN)
|
|
43
|
+
web_url_prefix: URL prefix for view links (default: https://www.dropbox.com/home)
|
|
44
|
+
"""
|
|
45
|
+
self.root = str(config.root).rstrip("/")
|
|
46
|
+
self.web_url_prefix = str(config.web_url_prefix)
|
|
47
|
+
|
|
48
|
+
# Initialize Dropbox client using env vars
|
|
49
|
+
self.client = self._create_client(config)
|
|
50
|
+
self._shutdown_called = False
|
|
51
|
+
|
|
52
|
+
logger.info("DropboxStorage initialized: root=%s", self.root)
|
|
53
|
+
|
|
54
|
+
def _create_client(self, config: DropboxStorageConfig) -> dropbox.Dropbox:
|
|
55
|
+
"""Create Dropbox client from env vars.
|
|
56
|
+
|
|
57
|
+
Tries simple token first, then falls back to refresh token flow.
|
|
58
|
+
"""
|
|
59
|
+
# Try simple token auth first
|
|
60
|
+
token_var = str(config.token_env)
|
|
61
|
+
token = os.getenv(token_var)
|
|
62
|
+
if token:
|
|
63
|
+
logger.info("Using Dropbox simple token auth")
|
|
64
|
+
return dropbox.Dropbox(token)
|
|
65
|
+
|
|
66
|
+
# Try refresh token flow
|
|
67
|
+
app_key_var = str(config.app_key_env)
|
|
68
|
+
app_secret_var = str(config.app_secret_env)
|
|
69
|
+
refresh_token_var = str(config.refresh_token_env)
|
|
70
|
+
|
|
71
|
+
app_key = os.getenv(app_key_var)
|
|
72
|
+
app_secret = os.getenv(app_secret_var)
|
|
73
|
+
refresh_token = os.getenv(refresh_token_var)
|
|
74
|
+
|
|
75
|
+
if app_key and app_secret and refresh_token:
|
|
76
|
+
logger.info("Using Dropbox refresh token auth")
|
|
77
|
+
return dropbox.Dropbox(
|
|
78
|
+
app_key=app_key,
|
|
79
|
+
app_secret=app_secret,
|
|
80
|
+
oauth2_refresh_token=refresh_token,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"Missing Dropbox credentials. Set {token_var} or "
|
|
85
|
+
f"({app_key_var}, {app_secret_var}, {refresh_token_var})."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
async def put_file(self, local_path: Path, dest_path: str) -> StorageUploadResult:
|
|
89
|
+
"""Upload file to Dropbox."""
|
|
90
|
+
self._ensure_open()
|
|
91
|
+
|
|
92
|
+
dest_path = self._full_dest_path(dest_path)
|
|
93
|
+
|
|
94
|
+
# Run blocking upload in executor
|
|
95
|
+
await asyncio.to_thread(self._upload_file, local_path, dest_path)
|
|
96
|
+
|
|
97
|
+
storage_uri = f"dropbox:{dest_path}"
|
|
98
|
+
view_url = await self.get_view_url(storage_uri)
|
|
99
|
+
return StorageUploadResult(storage_uri=storage_uri, view_url=view_url)
|
|
100
|
+
|
|
101
|
+
async def get_view_url(self, storage_uri: str) -> str | None:
|
|
102
|
+
"""Compute a web-accessible URL for a Dropbox storage URI."""
|
|
103
|
+
self._ensure_open()
|
|
104
|
+
if not storage_uri.startswith("dropbox:"):
|
|
105
|
+
return None
|
|
106
|
+
path = storage_uri[8:]
|
|
107
|
+
prefix = self.web_url_prefix.rstrip("/")
|
|
108
|
+
return f"{prefix}{path}"
|
|
109
|
+
|
|
110
|
+
def _upload_file(self, local_path: Path, dest_path: str) -> None:
|
|
111
|
+
"""Upload file (blocking operation)."""
|
|
112
|
+
file_size = local_path.stat().st_size
|
|
113
|
+
with open(local_path, "rb") as f:
|
|
114
|
+
if file_size <= CHUNK_SIZE:
|
|
115
|
+
self.client.files_upload(
|
|
116
|
+
f.read(),
|
|
117
|
+
dest_path,
|
|
118
|
+
mode=dropbox.files.WriteMode.overwrite,
|
|
119
|
+
)
|
|
120
|
+
else:
|
|
121
|
+
self._upload_file_chunked(f, dest_path, file_size)
|
|
122
|
+
logger.debug("Uploaded to Dropbox: %s", dest_path)
|
|
123
|
+
|
|
124
|
+
def _upload_file_chunked(self, file_handle: BinaryIO, dest_path: str, file_size: int) -> None:
|
|
125
|
+
"""Upload file in chunks using a Dropbox upload session."""
|
|
126
|
+
chunk = file_handle.read(CHUNK_SIZE)
|
|
127
|
+
if not chunk:
|
|
128
|
+
raise ValueError("Cannot upload empty file")
|
|
129
|
+
|
|
130
|
+
session = self.client.files_upload_session_start(chunk)
|
|
131
|
+
cursor = dropbox.files.UploadSessionCursor(
|
|
132
|
+
session_id=session.session_id,
|
|
133
|
+
offset=file_handle.tell(),
|
|
134
|
+
)
|
|
135
|
+
commit = dropbox.files.CommitInfo(
|
|
136
|
+
path=dest_path,
|
|
137
|
+
mode=dropbox.files.WriteMode.overwrite,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
while file_handle.tell() < file_size:
|
|
141
|
+
chunk = file_handle.read(CHUNK_SIZE)
|
|
142
|
+
if not chunk:
|
|
143
|
+
raise RuntimeError("Unexpected end of file during chunked upload")
|
|
144
|
+
if file_handle.tell() >= file_size:
|
|
145
|
+
self.client.files_upload_session_finish(chunk, cursor, commit)
|
|
146
|
+
return
|
|
147
|
+
self.client.files_upload_session_append_v2(chunk, cursor)
|
|
148
|
+
cursor.offset = file_handle.tell()
|
|
149
|
+
|
|
150
|
+
async def get(self, storage_uri: str, local_path: Path) -> None:
|
|
151
|
+
"""Download file from Dropbox."""
|
|
152
|
+
self._ensure_open()
|
|
153
|
+
|
|
154
|
+
# Parse storage_uri
|
|
155
|
+
if not storage_uri.startswith("dropbox:"):
|
|
156
|
+
raise ValueError(f"Invalid storage_uri: {storage_uri}")
|
|
157
|
+
|
|
158
|
+
remote_path = storage_uri[8:] # Strip "dropbox:"
|
|
159
|
+
|
|
160
|
+
# Run blocking download in executor
|
|
161
|
+
await asyncio.to_thread(self._download_file, remote_path, local_path)
|
|
162
|
+
|
|
163
|
+
def _download_file(self, remote_path: str, local_path: Path) -> None:
|
|
164
|
+
"""Download file (blocking operation)."""
|
|
165
|
+
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
self.client.files_download_to_file(str(local_path), remote_path)
|
|
167
|
+
logger.debug("Downloaded from Dropbox: %s", remote_path)
|
|
168
|
+
|
|
169
|
+
async def exists(self, storage_uri: str) -> bool:
|
|
170
|
+
"""Check if file exists in Dropbox."""
|
|
171
|
+
self._ensure_open()
|
|
172
|
+
|
|
173
|
+
# Parse storage_uri
|
|
174
|
+
if not storage_uri.startswith("dropbox:"):
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
remote_path = storage_uri[8:]
|
|
178
|
+
|
|
179
|
+
# Run blocking check in executor
|
|
180
|
+
return await asyncio.to_thread(self._check_exists, remote_path)
|
|
181
|
+
|
|
182
|
+
async def delete(self, storage_uri: str) -> None:
|
|
183
|
+
"""Delete file from Dropbox.
|
|
184
|
+
|
|
185
|
+
Idempotent: missing files are treated as success.
|
|
186
|
+
"""
|
|
187
|
+
self._ensure_open()
|
|
188
|
+
|
|
189
|
+
if not storage_uri.startswith("dropbox:"):
|
|
190
|
+
raise ValueError(f"Invalid storage_uri: {storage_uri}")
|
|
191
|
+
|
|
192
|
+
remote_path = storage_uri[8:]
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
await asyncio.to_thread(self.client.files_delete_v2, remote_path)
|
|
196
|
+
except dropbox.exceptions.ApiError as exc:
|
|
197
|
+
# Treat missing file as success
|
|
198
|
+
err = getattr(exc, "error", None)
|
|
199
|
+
try:
|
|
200
|
+
if err is not None and getattr(err, "is_path_lookup", lambda: False)():
|
|
201
|
+
lookup = err.get_path_lookup()
|
|
202
|
+
if getattr(lookup, "is_not_found", lambda: False)():
|
|
203
|
+
return
|
|
204
|
+
except Exception:
|
|
205
|
+
pass
|
|
206
|
+
raise
|
|
207
|
+
|
|
208
|
+
def _check_exists(self, remote_path: str) -> bool:
|
|
209
|
+
"""Check if file exists (blocking operation)."""
|
|
210
|
+
try:
|
|
211
|
+
self.client.files_get_metadata(remote_path)
|
|
212
|
+
return True
|
|
213
|
+
except dropbox.exceptions.ApiError:
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
async def ping(self) -> bool:
|
|
217
|
+
"""Health check - verify Dropbox connection."""
|
|
218
|
+
try:
|
|
219
|
+
await asyncio.to_thread(self.client.users_get_current_account)
|
|
220
|
+
return True
|
|
221
|
+
except Exception as e:
|
|
222
|
+
logger.warning("Dropbox ping failed: %s", e, exc_info=True)
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
async def shutdown(self, timeout: float | None = None) -> None:
|
|
226
|
+
"""Cleanup resources."""
|
|
227
|
+
_ = timeout
|
|
228
|
+
if self._shutdown_called:
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
self._shutdown_called = True
|
|
232
|
+
logger.info("DropboxStorage closed")
|
|
233
|
+
|
|
234
|
+
def _ensure_open(self) -> None:
|
|
235
|
+
if self._shutdown_called:
|
|
236
|
+
raise RuntimeError("Storage has been shut down")
|
|
237
|
+
|
|
238
|
+
def _full_dest_path(self, dest_path: str) -> str:
|
|
239
|
+
cleaned = str(dest_path).lstrip("/")
|
|
240
|
+
if not cleaned or "\\" in cleaned:
|
|
241
|
+
raise ValueError(f"Invalid dest_path: {dest_path}")
|
|
242
|
+
path = PurePosixPath(cleaned)
|
|
243
|
+
if path.is_absolute() or ".." in path.parts:
|
|
244
|
+
raise ValueError(f"Invalid dest_path: {dest_path}")
|
|
245
|
+
return f"{self.root}/{path}"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# Plugin registration
|
|
249
|
+
from typing import cast
|
|
250
|
+
from pydantic import BaseModel
|
|
251
|
+
from homesec.plugins.storage import StoragePlugin, storage_plugin
|
|
252
|
+
from homesec.interfaces import StorageBackend
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@storage_plugin(name="dropbox")
|
|
256
|
+
def dropbox_storage_plugin() -> StoragePlugin:
|
|
257
|
+
"""Dropbox storage plugin factory.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
StoragePlugin for Dropbox cloud storage
|
|
261
|
+
"""
|
|
262
|
+
from homesec.models.config import DropboxStorageConfig
|
|
263
|
+
|
|
264
|
+
def factory(cfg: BaseModel) -> StorageBackend:
|
|
265
|
+
# Config is already validated by pydantic when loaded
|
|
266
|
+
return DropboxStorage(cast(DropboxStorageConfig, cfg))
|
|
267
|
+
|
|
268
|
+
return StoragePlugin(
|
|
269
|
+
name="dropbox",
|
|
270
|
+
config_model=DropboxStorageConfig,
|
|
271
|
+
factory=factory,
|
|
272
|
+
)
|