rightra 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.
- rightra/__init__.py +30 -0
- rightra/client.py +286 -0
- rightra/errors.py +33 -0
- rightra/py.typed +0 -0
- rightra/storage.py +166 -0
- rightra/upload.py +108 -0
- rightra-0.1.0.dist-info/METADATA +130 -0
- rightra-0.1.0.dist-info/RECORD +11 -0
- rightra-0.1.0.dist-info/WHEEL +5 -0
- rightra-0.1.0.dist-info/licenses/LICENSE +16 -0
- rightra-0.1.0.dist-info/top_level.txt +1 -0
rightra/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Rightra server-side helpers for Python.
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
|
|
5
|
+
- Client for the Rightra API.
|
|
6
|
+
- mint_upload_token and verify_upload_token for the PHP upload helper.
|
|
7
|
+
- sign_s3_put and sign_r2_put for direct-to-bucket uploads.
|
|
8
|
+
- RightraError for every failure.
|
|
9
|
+
|
|
10
|
+
No framework imports. Django and Flask users import the same module.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .client import Client
|
|
16
|
+
from .errors import RightraError
|
|
17
|
+
from .storage import sign_r2_put, sign_s3_put
|
|
18
|
+
from .upload import DEFAULT_TTL as UPLOAD_TOKEN_DEFAULT_TTL
|
|
19
|
+
from .upload import mint_upload_token, verify_upload_token
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Client",
|
|
24
|
+
"RightraError",
|
|
25
|
+
"UPLOAD_TOKEN_DEFAULT_TTL",
|
|
26
|
+
"mint_upload_token",
|
|
27
|
+
"verify_upload_token",
|
|
28
|
+
"sign_s3_put",
|
|
29
|
+
"sign_r2_put",
|
|
30
|
+
]
|
rightra/client.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Rightra API client.
|
|
2
|
+
|
|
3
|
+
Pure urllib. No requests. Every failure raises RightraError.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import socket
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
from urllib import error as urlerror
|
|
13
|
+
from urllib import request as urlrequest
|
|
14
|
+
|
|
15
|
+
from .errors import RightraError
|
|
16
|
+
|
|
17
|
+
_CONTENT_ID_RE = re.compile(r"^RTR-[A-F0-9]{4}-[A-F0-9]{4}$")
|
|
18
|
+
_DEFAULT_BASE = "https://api.oliviuus.com/api/rightra"
|
|
19
|
+
_MAX_BODY = 2 * 1024 * 1024
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Client:
|
|
23
|
+
"""Client for the Rightra SDK API.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
api_key: RIGHTRA_API_KEY. Must start with rk_live_ or rk_test_.
|
|
27
|
+
base_url: API base. Defaults to production.
|
|
28
|
+
timeout: Per-request timeout in seconds.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
api_key: str,
|
|
34
|
+
*,
|
|
35
|
+
base_url: str = _DEFAULT_BASE,
|
|
36
|
+
timeout: float = 30.0,
|
|
37
|
+
) -> None:
|
|
38
|
+
if not isinstance(api_key, str) or not api_key.startswith(("rk_live_", "rk_test_")):
|
|
39
|
+
raise ValueError("api_key must start with rk_live_ or rk_test_.")
|
|
40
|
+
if not isinstance(base_url, str) or not base_url.startswith(("http://", "https://")):
|
|
41
|
+
raise ValueError("base_url must start with http:// or https://.")
|
|
42
|
+
if not isinstance(timeout, (int, float)) or timeout <= 0:
|
|
43
|
+
raise ValueError("timeout must be a positive number.")
|
|
44
|
+
|
|
45
|
+
self.api_key = api_key
|
|
46
|
+
self.base_url = base_url.rstrip("/")
|
|
47
|
+
self.timeout = float(timeout)
|
|
48
|
+
|
|
49
|
+
def _request(
|
|
50
|
+
self,
|
|
51
|
+
method: str,
|
|
52
|
+
path: str,
|
|
53
|
+
*,
|
|
54
|
+
body: Optional[Dict[str, Any]] = None,
|
|
55
|
+
stream_to: Optional[str] = None,
|
|
56
|
+
) -> Dict[str, Any]:
|
|
57
|
+
url = f"{self.base_url}{path}"
|
|
58
|
+
data = None
|
|
59
|
+
if body is not None:
|
|
60
|
+
data = json.dumps(body).encode("utf-8")
|
|
61
|
+
if len(data) > _MAX_BODY:
|
|
62
|
+
raise ValueError("Request body exceeds the Rightra 2 MB limit.")
|
|
63
|
+
|
|
64
|
+
req = urlrequest.Request(url=url, data=data, method=method)
|
|
65
|
+
req.add_header("Authorization", f"Bearer {self.api_key}")
|
|
66
|
+
req.add_header("Accept", "application/json")
|
|
67
|
+
req.add_header("User-Agent", "rightra-python/0.1.0")
|
|
68
|
+
if data is not None:
|
|
69
|
+
req.add_header("Content-Type", "application/json")
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
with urlrequest.urlopen(req, timeout=self.timeout) as resp:
|
|
73
|
+
if stream_to:
|
|
74
|
+
self._stream_to(resp, stream_to)
|
|
75
|
+
return {"success": True, "path": stream_to}
|
|
76
|
+
raw = resp.read()
|
|
77
|
+
return self._parse(raw, resp.status)
|
|
78
|
+
except urlerror.HTTPError as exc:
|
|
79
|
+
raw = b""
|
|
80
|
+
try:
|
|
81
|
+
raw = exc.read()
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
parsed = self._safe_json(raw)
|
|
85
|
+
code = parsed.get("code") if isinstance(parsed, dict) else None
|
|
86
|
+
message = (
|
|
87
|
+
parsed.get("message")
|
|
88
|
+
if isinstance(parsed, dict) and isinstance(parsed.get("message"), str)
|
|
89
|
+
else "The Rightra request failed."
|
|
90
|
+
)
|
|
91
|
+
raise RightraError(message, status=exc.code, code=code, body=parsed) from None
|
|
92
|
+
except (urlerror.URLError, socket.timeout, TimeoutError) as exc:
|
|
93
|
+
reason = getattr(exc, "reason", exc)
|
|
94
|
+
raise RightraError(
|
|
95
|
+
"Could not reach the Rightra service.",
|
|
96
|
+
status=0,
|
|
97
|
+
code="NETWORK_ERROR",
|
|
98
|
+
body=str(reason),
|
|
99
|
+
) from None
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _parse(raw: bytes, status: int) -> Dict[str, Any]:
|
|
103
|
+
if not raw:
|
|
104
|
+
return {"success": True, "status": status}
|
|
105
|
+
try:
|
|
106
|
+
parsed = json.loads(raw.decode("utf-8"))
|
|
107
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
108
|
+
raise RightraError(
|
|
109
|
+
"The Rightra service returned an invalid response.",
|
|
110
|
+
status=status,
|
|
111
|
+
code="INVALID_RESPONSE",
|
|
112
|
+
) from None
|
|
113
|
+
if not isinstance(parsed, dict):
|
|
114
|
+
raise RightraError(
|
|
115
|
+
"The Rightra service returned an unexpected response.",
|
|
116
|
+
status=status,
|
|
117
|
+
code="INVALID_RESPONSE",
|
|
118
|
+
)
|
|
119
|
+
return parsed
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _safe_json(raw: bytes) -> Any:
|
|
123
|
+
if not raw:
|
|
124
|
+
return None
|
|
125
|
+
try:
|
|
126
|
+
return json.loads(raw.decode("utf-8"))
|
|
127
|
+
except Exception:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _stream_to(resp, dest_path: str) -> None:
|
|
132
|
+
with open(dest_path, "wb") as handle:
|
|
133
|
+
while True:
|
|
134
|
+
chunk = resp.read(64 * 1024)
|
|
135
|
+
if not chunk:
|
|
136
|
+
break
|
|
137
|
+
handle.write(chunk)
|
|
138
|
+
|
|
139
|
+
def register_content(
|
|
140
|
+
self,
|
|
141
|
+
*,
|
|
142
|
+
title: str,
|
|
143
|
+
type: str,
|
|
144
|
+
description: Optional[str] = None,
|
|
145
|
+
creator_name: Optional[str] = None,
|
|
146
|
+
rights_holder_name: Optional[str] = None,
|
|
147
|
+
storage_reference: Optional[Dict[str, Any]] = None,
|
|
148
|
+
encryption: Optional[Dict[str, Any]] = None,
|
|
149
|
+
) -> Dict[str, Any]:
|
|
150
|
+
body: Dict[str, Any] = {"title": title, "type": type}
|
|
151
|
+
if description is not None:
|
|
152
|
+
body["description"] = description
|
|
153
|
+
if creator_name is not None:
|
|
154
|
+
body["creator_name"] = creator_name
|
|
155
|
+
if rights_holder_name is not None:
|
|
156
|
+
body["rights_holder_name"] = rights_holder_name
|
|
157
|
+
if storage_reference is not None:
|
|
158
|
+
body["storage_reference"] = storage_reference
|
|
159
|
+
if encryption is not None:
|
|
160
|
+
body["encryption"] = encryption
|
|
161
|
+
return self._request("POST", "/sdk/content", body=body)
|
|
162
|
+
|
|
163
|
+
def get_content(self, content_id: str) -> Dict[str, Any]:
|
|
164
|
+
self._require_content_id(content_id)
|
|
165
|
+
return self._request("GET", f"/sdk/content/{content_id}")
|
|
166
|
+
|
|
167
|
+
def set_encryption_key(
|
|
168
|
+
self,
|
|
169
|
+
content_id: str,
|
|
170
|
+
*,
|
|
171
|
+
key_material: str,
|
|
172
|
+
key_algorithm: str = "AES-256-GCM",
|
|
173
|
+
segment_manifest: Optional[Any] = None,
|
|
174
|
+
) -> Dict[str, Any]:
|
|
175
|
+
self._require_content_id(content_id)
|
|
176
|
+
body: Dict[str, Any] = {
|
|
177
|
+
"key_material": key_material,
|
|
178
|
+
"key_algorithm": key_algorithm,
|
|
179
|
+
}
|
|
180
|
+
if segment_manifest is not None:
|
|
181
|
+
body["segment_manifest"] = segment_manifest
|
|
182
|
+
return self._request("POST", f"/sdk/content/{content_id}/key", body=body)
|
|
183
|
+
|
|
184
|
+
def set_manifest(
|
|
185
|
+
self,
|
|
186
|
+
content_id: str,
|
|
187
|
+
*,
|
|
188
|
+
manifest_url: str,
|
|
189
|
+
storage_details: Optional[Dict[str, Any]] = None,
|
|
190
|
+
) -> Dict[str, Any]:
|
|
191
|
+
self._require_content_id(content_id)
|
|
192
|
+
body: Dict[str, Any] = {"manifest_url": manifest_url}
|
|
193
|
+
if storage_details is not None:
|
|
194
|
+
body["storage_reference"] = storage_details
|
|
195
|
+
return self._request("POST", f"/sdk/content/{content_id}/manifest", body=body)
|
|
196
|
+
|
|
197
|
+
def queue_fingerprint(
|
|
198
|
+
self,
|
|
199
|
+
content_id: str,
|
|
200
|
+
*,
|
|
201
|
+
reference_material: str,
|
|
202
|
+
reference_type: str = "audio_sample",
|
|
203
|
+
) -> Dict[str, Any]:
|
|
204
|
+
self._require_content_id(content_id)
|
|
205
|
+
body = {
|
|
206
|
+
"reference_material": reference_material,
|
|
207
|
+
"reference_type": reference_type,
|
|
208
|
+
}
|
|
209
|
+
return self._request("POST", f"/sdk/content/{content_id}/fingerprint", body=body)
|
|
210
|
+
|
|
211
|
+
def create_playback_session(
|
|
212
|
+
self,
|
|
213
|
+
content_id: str,
|
|
214
|
+
*,
|
|
215
|
+
viewer_id: str,
|
|
216
|
+
viewer_label: Optional[str] = None,
|
|
217
|
+
territory: Optional[str] = None,
|
|
218
|
+
client_public_key: Optional[str] = None,
|
|
219
|
+
device: Optional[Dict[str, Any]] = None,
|
|
220
|
+
) -> Dict[str, Any]:
|
|
221
|
+
self._require_content_id(content_id)
|
|
222
|
+
if not isinstance(viewer_id, str) or not viewer_id.strip():
|
|
223
|
+
raise ValueError("viewer_id is required. Rightra fails closed without it.")
|
|
224
|
+
viewer: Dict[str, Any] = {"id": viewer_id.strip()}
|
|
225
|
+
if viewer_label:
|
|
226
|
+
viewer["label"] = viewer_label
|
|
227
|
+
body: Dict[str, Any] = {"content_id": content_id, "viewer": viewer}
|
|
228
|
+
if territory:
|
|
229
|
+
body["requested_territory"] = territory.upper()[:2]
|
|
230
|
+
if client_public_key:
|
|
231
|
+
body["client_public_key"] = client_public_key
|
|
232
|
+
if device:
|
|
233
|
+
body["device"] = device
|
|
234
|
+
return self._request("POST", "/sdk/playback-session", body=body)
|
|
235
|
+
|
|
236
|
+
def playback_event(
|
|
237
|
+
self,
|
|
238
|
+
*,
|
|
239
|
+
session_id: str,
|
|
240
|
+
event: str,
|
|
241
|
+
seconds_watched: int = 0,
|
|
242
|
+
) -> Dict[str, Any]:
|
|
243
|
+
if not session_id:
|
|
244
|
+
raise ValueError("session_id is required.")
|
|
245
|
+
body = {
|
|
246
|
+
"session_id": session_id,
|
|
247
|
+
"event": event,
|
|
248
|
+
"seconds_watched": int(seconds_watched),
|
|
249
|
+
}
|
|
250
|
+
return self._request("POST", "/sdk/playback-event", body=body)
|
|
251
|
+
|
|
252
|
+
def record_download_attempt(
|
|
253
|
+
self,
|
|
254
|
+
*,
|
|
255
|
+
session_id: str,
|
|
256
|
+
attempt_type: str = "unknown",
|
|
257
|
+
details: Optional[Dict[str, Any]] = None,
|
|
258
|
+
) -> Dict[str, Any]:
|
|
259
|
+
if not session_id:
|
|
260
|
+
raise ValueError("session_id is required.")
|
|
261
|
+
body: Dict[str, Any] = {"session_id": session_id, "attempt_type": attempt_type}
|
|
262
|
+
if details is not None:
|
|
263
|
+
body["details"] = details
|
|
264
|
+
return self._request("POST", "/sdk/download-attempt", body=body)
|
|
265
|
+
|
|
266
|
+
def request_download(self, session_id: str) -> Dict[str, Any]:
|
|
267
|
+
if not session_id:
|
|
268
|
+
raise ValueError("session_id is required.")
|
|
269
|
+
return self._request("POST", "/sdk/download-request", body={"session_id": session_id})
|
|
270
|
+
|
|
271
|
+
def download_status(self, job_id: str) -> Dict[str, Any]:
|
|
272
|
+
if not job_id:
|
|
273
|
+
raise ValueError("job_id is required.")
|
|
274
|
+
return self._request("GET", f"/sdk/download-status/{job_id}")
|
|
275
|
+
|
|
276
|
+
def download_file(self, job_id: str, dest_path: str) -> Dict[str, Any]:
|
|
277
|
+
if not job_id:
|
|
278
|
+
raise ValueError("job_id is required.")
|
|
279
|
+
if not dest_path:
|
|
280
|
+
raise ValueError("dest_path is required.")
|
|
281
|
+
return self._request("GET", f"/sdk/download-file/{job_id}", stream_to=dest_path)
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def _require_content_id(content_id: str) -> None:
|
|
285
|
+
if not isinstance(content_id, str) or not _CONTENT_ID_RE.match(content_id):
|
|
286
|
+
raise ValueError("content_id must look like RTR-XXXX-XXXX (uppercase hex).")
|
rightra/errors.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Exception types for the Rightra Python package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RightraError(Exception):
|
|
9
|
+
"""Raised for every Rightra failure.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
status: HTTP status code. 0 for network or transport failures.
|
|
13
|
+
code: Rightra error code when the server provided one, else None.
|
|
14
|
+
message: A sentence safe to show a user.
|
|
15
|
+
body: Parsed response body when available, else None.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
message: str,
|
|
21
|
+
*,
|
|
22
|
+
status: int = 0,
|
|
23
|
+
code: Optional[str] = None,
|
|
24
|
+
body: Optional[Any] = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.status = status
|
|
28
|
+
self.code = code
|
|
29
|
+
self.message = message
|
|
30
|
+
self.body = body
|
|
31
|
+
|
|
32
|
+
def __repr__(self) -> str:
|
|
33
|
+
return f"RightraError(status={self.status!r}, code={self.code!r}, message={self.message!r})"
|
rightra/py.typed
ADDED
|
File without changes
|
rightra/storage.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Presigned PUT URL helpers for S3 and Cloudflare R2.
|
|
2
|
+
|
|
3
|
+
Pure Python. No boto3, no botocore, no aws-sdk. Signs with SigV4 using
|
|
4
|
+
hmac and hashlib from the standard library.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import hmac
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Dict, Optional
|
|
13
|
+
from urllib.parse import quote, urlencode
|
|
14
|
+
|
|
15
|
+
_ALGO = "AWS4-HMAC-SHA256"
|
|
16
|
+
_SERVICE = "s3"
|
|
17
|
+
_MAX_EXPIRES = 604800
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _hmac(key: bytes, msg: str) -> bytes:
|
|
21
|
+
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _sha256_hex(data: bytes) -> str:
|
|
25
|
+
return hashlib.sha256(data).hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _validate_expires(expires: int) -> None:
|
|
29
|
+
if not isinstance(expires, int) or isinstance(expires, bool):
|
|
30
|
+
raise ValueError("expires must be an integer number of seconds.")
|
|
31
|
+
if expires <= 0 or expires > _MAX_EXPIRES:
|
|
32
|
+
raise ValueError(f"expires must be between 1 and {_MAX_EXPIRES} seconds.")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _encode_key(key: str) -> str:
|
|
36
|
+
return "/".join(quote(segment, safe="-_.~") for segment in key.split("/"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _signing_key(secret_key: str, date_stamp: str, region: str) -> bytes:
|
|
40
|
+
k_date = _hmac(("AWS4" + secret_key).encode("utf-8"), date_stamp)
|
|
41
|
+
k_region = _hmac(k_date, region)
|
|
42
|
+
k_service = _hmac(k_region, _SERVICE)
|
|
43
|
+
return _hmac(k_service, "aws4_request")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_presigned_url(
|
|
47
|
+
*,
|
|
48
|
+
scheme: str,
|
|
49
|
+
host: str,
|
|
50
|
+
bucket: str,
|
|
51
|
+
key: str,
|
|
52
|
+
access_key: str,
|
|
53
|
+
secret_key: str,
|
|
54
|
+
region: str,
|
|
55
|
+
expires: int,
|
|
56
|
+
content_type: Optional[str],
|
|
57
|
+
now: Optional[datetime],
|
|
58
|
+
) -> str:
|
|
59
|
+
_validate_expires(expires)
|
|
60
|
+
if not access_key or not secret_key:
|
|
61
|
+
raise ValueError("access_key and secret_key are required.")
|
|
62
|
+
if not bucket or not key:
|
|
63
|
+
raise ValueError("bucket and key are required.")
|
|
64
|
+
|
|
65
|
+
moment = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
|
66
|
+
amz_date = moment.strftime("%Y%m%dT%H%M%SZ")
|
|
67
|
+
date_stamp = moment.strftime("%Y%m%d")
|
|
68
|
+
|
|
69
|
+
canonical_uri = "/" + _encode_key(key)
|
|
70
|
+
canonical_headers = f"host:{host}\n"
|
|
71
|
+
signed_headers = "host"
|
|
72
|
+
|
|
73
|
+
query: Dict[str, str] = {
|
|
74
|
+
"X-Amz-Algorithm": _ALGO,
|
|
75
|
+
"X-Amz-Credential": f"{access_key}/{date_stamp}/{region}/{_SERVICE}/aws4_request",
|
|
76
|
+
"X-Amz-Date": amz_date,
|
|
77
|
+
"X-Amz-Expires": str(expires),
|
|
78
|
+
"X-Amz-SignedHeaders": signed_headers,
|
|
79
|
+
}
|
|
80
|
+
if content_type:
|
|
81
|
+
query["response-content-type"] = content_type
|
|
82
|
+
|
|
83
|
+
canonical_query = urlencode(sorted(query.items()), quote_via=quote, safe="-_.~")
|
|
84
|
+
|
|
85
|
+
canonical_request = "\n".join(
|
|
86
|
+
[
|
|
87
|
+
"PUT",
|
|
88
|
+
canonical_uri,
|
|
89
|
+
canonical_query,
|
|
90
|
+
canonical_headers,
|
|
91
|
+
signed_headers,
|
|
92
|
+
"UNSIGNED-PAYLOAD",
|
|
93
|
+
]
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
credential_scope = f"{date_stamp}/{region}/{_SERVICE}/aws4_request"
|
|
97
|
+
string_to_sign = "\n".join(
|
|
98
|
+
[
|
|
99
|
+
_ALGO,
|
|
100
|
+
amz_date,
|
|
101
|
+
credential_scope,
|
|
102
|
+
_sha256_hex(canonical_request.encode("utf-8")),
|
|
103
|
+
]
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
key_b = _signing_key(secret_key, date_stamp, region)
|
|
107
|
+
signature = hmac.new(key_b, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
108
|
+
|
|
109
|
+
final_query = canonical_query + "&X-Amz-Signature=" + signature
|
|
110
|
+
return f"{scheme}://{host}{canonical_uri}?{final_query}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def sign_s3_put(
|
|
114
|
+
*,
|
|
115
|
+
access_key: str,
|
|
116
|
+
secret_key: str,
|
|
117
|
+
bucket: str,
|
|
118
|
+
key: str,
|
|
119
|
+
region: str = "us-east-1",
|
|
120
|
+
expires: int = 900,
|
|
121
|
+
content_type: Optional[str] = None,
|
|
122
|
+
now: Optional[datetime] = None,
|
|
123
|
+
) -> str:
|
|
124
|
+
"""Return a presigned S3 PUT URL."""
|
|
125
|
+
host = f"{bucket}.s3.{region}.amazonaws.com"
|
|
126
|
+
return _build_presigned_url(
|
|
127
|
+
scheme="https",
|
|
128
|
+
host=host,
|
|
129
|
+
bucket=bucket,
|
|
130
|
+
key=key,
|
|
131
|
+
access_key=access_key,
|
|
132
|
+
secret_key=secret_key,
|
|
133
|
+
region=region,
|
|
134
|
+
expires=expires,
|
|
135
|
+
content_type=content_type,
|
|
136
|
+
now=now,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def sign_r2_put(
|
|
141
|
+
*,
|
|
142
|
+
account_id: str,
|
|
143
|
+
access_key: str,
|
|
144
|
+
secret_key: str,
|
|
145
|
+
bucket: str,
|
|
146
|
+
key: str,
|
|
147
|
+
expires: int = 900,
|
|
148
|
+
content_type: Optional[str] = None,
|
|
149
|
+
now: Optional[datetime] = None,
|
|
150
|
+
) -> str:
|
|
151
|
+
"""Return a presigned Cloudflare R2 PUT URL."""
|
|
152
|
+
if not account_id:
|
|
153
|
+
raise ValueError("account_id is required for R2.")
|
|
154
|
+
host = f"{account_id}.r2.cloudflarestorage.com"
|
|
155
|
+
return _build_presigned_url(
|
|
156
|
+
scheme="https",
|
|
157
|
+
host=host,
|
|
158
|
+
bucket=bucket,
|
|
159
|
+
key=f"{bucket}/{key}",
|
|
160
|
+
access_key=access_key,
|
|
161
|
+
secret_key=secret_key,
|
|
162
|
+
region="auto",
|
|
163
|
+
expires=expires,
|
|
164
|
+
content_type=content_type,
|
|
165
|
+
now=now,
|
|
166
|
+
)
|
rightra/upload.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Upload token helpers.
|
|
2
|
+
|
|
3
|
+
The token format matches the PHP helper (``rightra-upload.php``) and the
|
|
4
|
+
Laravel package (``UploadToken::mint``):
|
|
5
|
+
|
|
6
|
+
X-Rightra-Token: <expiry>.<hmac>
|
|
7
|
+
hmac = HMAC-SHA256("<contentId>|<expiry>", RIGHTRA_UPLOAD_SECRET)
|
|
8
|
+
|
|
9
|
+
``expiry`` is a unix timestamp. The token is bound to one content id and is
|
|
10
|
+
valid until that timestamp. The master secret never reaches the browser.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import hmac
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
_CONTENT_ID_RE = re.compile(r"^RTR-[A-F0-9]{4}-[A-F0-9]{4}$")
|
|
22
|
+
_TOKEN_RE = re.compile(r"^(\d{9,11})\.([a-f0-9]{64})$")
|
|
23
|
+
|
|
24
|
+
DEFAULT_TTL = 900
|
|
25
|
+
_MAX_TTL = 86400 # 24 hours. Anything longer is a bug, not a feature.
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _require_content_id(content_id: str) -> str:
|
|
29
|
+
if not isinstance(content_id, str) or not _CONTENT_ID_RE.match(content_id):
|
|
30
|
+
raise ValueError("content_id must look like RTR-XXXX-XXXX (uppercase hex).")
|
|
31
|
+
return content_id
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _require_secret(secret: str) -> bytes:
|
|
35
|
+
if not isinstance(secret, str) or not secret:
|
|
36
|
+
raise ValueError("secret is required to mint or verify upload tokens.")
|
|
37
|
+
return secret.encode("utf-8")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _sign(content_id: str, expiry: int, secret: bytes) -> str:
|
|
41
|
+
payload = f"{content_id}|{expiry}".encode("utf-8")
|
|
42
|
+
return hmac.new(secret, payload, hashlib.sha256).hexdigest()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def mint_upload_token(
|
|
46
|
+
content_id: str,
|
|
47
|
+
secret: str,
|
|
48
|
+
*,
|
|
49
|
+
ttl: int = DEFAULT_TTL,
|
|
50
|
+
now: Optional[int] = None,
|
|
51
|
+
) -> str:
|
|
52
|
+
"""Return ``"<expiry>.<hmac>"`` for the given content id.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
content_id: A Rightra content id, ``RTR-XXXX-XXXX``.
|
|
56
|
+
secret: ``RIGHTRA_UPLOAD_SECRET``.
|
|
57
|
+
ttl: Seconds the token stays valid. Defaults to 900.
|
|
58
|
+
now: Override the current unix time. Useful in tests.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ValueError: If inputs are invalid.
|
|
62
|
+
"""
|
|
63
|
+
_require_content_id(content_id)
|
|
64
|
+
secret_b = _require_secret(secret)
|
|
65
|
+
|
|
66
|
+
if not isinstance(ttl, int) or isinstance(ttl, bool):
|
|
67
|
+
raise ValueError("ttl must be an integer.")
|
|
68
|
+
if ttl <= 0 or ttl > _MAX_TTL:
|
|
69
|
+
raise ValueError(f"ttl must be between 1 and {_MAX_TTL} seconds.")
|
|
70
|
+
|
|
71
|
+
current = int(now if now is not None else time.time())
|
|
72
|
+
expiry = current + ttl
|
|
73
|
+
signature = _sign(content_id, expiry, secret_b)
|
|
74
|
+
return f"{expiry}.{signature}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def verify_upload_token(
|
|
78
|
+
token: str,
|
|
79
|
+
content_id: str,
|
|
80
|
+
secret: str,
|
|
81
|
+
*,
|
|
82
|
+
now: Optional[int] = None,
|
|
83
|
+
) -> bool:
|
|
84
|
+
"""Return ``True`` if the token is well formed, unexpired, and signed correctly.
|
|
85
|
+
|
|
86
|
+
Uses a constant-time comparison so timing does not leak the expected signature.
|
|
87
|
+
"""
|
|
88
|
+
if not isinstance(token, str):
|
|
89
|
+
return False
|
|
90
|
+
try:
|
|
91
|
+
_require_content_id(content_id)
|
|
92
|
+
secret_b = _require_secret(secret)
|
|
93
|
+
except ValueError:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
match = _TOKEN_RE.match(token)
|
|
97
|
+
if not match:
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
expiry = int(match.group(1))
|
|
101
|
+
provided = match.group(2)
|
|
102
|
+
|
|
103
|
+
current = int(now if now is not None else time.time())
|
|
104
|
+
if expiry < current:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
expected = _sign(content_id, expiry, secret_b)
|
|
108
|
+
return hmac.compare_digest(expected, provided)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rightra
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Server-side helpers for Rightra: upload tokens, presigned storage URLs, and the Rightra API client for Django, Flask, and any Python backend.
|
|
5
|
+
Author-email: OLIVIUUS Ltd <contact@oliviuus.com>
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Project-URL: Homepage, https://docs.oliviuus.com/rightra/python
|
|
8
|
+
Project-URL: Source, https://github.com/hagenimanasamuel/rightra-python
|
|
9
|
+
Project-URL: Repository, https://github.com/hagenimanasamuel/rightra-python
|
|
10
|
+
Keywords: rightra,oliviuus,drm,watermark,video,django,flask
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Framework :: Django
|
|
18
|
+
Classifier: Framework :: Flask
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# Rightra for Python
|
|
26
|
+
|
|
27
|
+
Server-side helpers for Rightra, the digital content protection and rights intelligence product by Oliviuus.
|
|
28
|
+
|
|
29
|
+
This package does the backend side only: mint short-lived upload tokens, sign S3 and R2 uploads, and call the Rightra API from a Django, Flask, or any Python backend. The browser player is loaded from a script tag on the page. It is not part of this package.
|
|
30
|
+
|
|
31
|
+
Contact: contact@oliviuus.com
|
|
32
|
+
|
|
33
|
+
## Requirements
|
|
34
|
+
|
|
35
|
+
- Python 3.9 or newer
|
|
36
|
+
- No runtime dependencies
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
pip install rightra
|
|
41
|
+
|
|
42
|
+
## Configure
|
|
43
|
+
|
|
44
|
+
Store these values as environment variables or in your framework settings:
|
|
45
|
+
|
|
46
|
+
RIGHTRA_API_KEY=rk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
47
|
+
RIGHTRA_UPLOAD_SECRET=your-upload-secret
|
|
48
|
+
RIGHTRA_UPLOAD_TOKEN_TTL=900
|
|
49
|
+
|
|
50
|
+
RIGHTRA_API_KEY is required. Without it every API call fails closed.
|
|
51
|
+
|
|
52
|
+
## Upload tokens
|
|
53
|
+
|
|
54
|
+
The PHP upload helper and the Laravel UploadToken::mint both expect a token in the form <expiry>.<hmac>, where hmac = HMAC-SHA256("<contentId>|<expiry>", RIGHTRA_UPLOAD_SECRET) and expiry is a unix timestamp. The Python helper produces exactly the same string.
|
|
55
|
+
|
|
56
|
+
import os
|
|
57
|
+
import rightra
|
|
58
|
+
|
|
59
|
+
token = rightra.mint_upload_token("RTR-853D-52D0", secret=os.environ["RIGHTRA_UPLOAD_SECRET"])
|
|
60
|
+
|
|
61
|
+
Send the token to your frontend and let the browser send it as the X-Rightra-Token header when it calls rightra-upload.php. The master secret never reaches the browser.
|
|
62
|
+
|
|
63
|
+
rightra.verify_upload_token(token, content_id, secret) performs the same check the PHP helper does. Use it if you proxy uploads through your own Python backend instead of the PHP helper.
|
|
64
|
+
|
|
65
|
+
## API client
|
|
66
|
+
|
|
67
|
+
import os
|
|
68
|
+
from rightra import Client
|
|
69
|
+
|
|
70
|
+
client = Client(api_key=os.environ["RIGHTRA_API_KEY"])
|
|
71
|
+
|
|
72
|
+
created = client.register_content(
|
|
73
|
+
title="My Film",
|
|
74
|
+
type="film",
|
|
75
|
+
description="Feature length film.",
|
|
76
|
+
creator_name="Example Studio",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
content_id = created["content"]["content_id"]
|
|
80
|
+
|
|
81
|
+
client.set_manifest(
|
|
82
|
+
content_id,
|
|
83
|
+
manifest_url="https://your-storage.example/manifest.json",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
session = client.create_playback_session(
|
|
87
|
+
content_id,
|
|
88
|
+
viewer_id=str(request.user.id),
|
|
89
|
+
viewer_label=request.user.get_full_name(),
|
|
90
|
+
territory="RW",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
## Presigned uploads
|
|
94
|
+
|
|
95
|
+
Sign PUT URLs for S3 or R2 without pulling in boto3.
|
|
96
|
+
|
|
97
|
+
import os
|
|
98
|
+
from rightra import sign_s3_put, sign_r2_put
|
|
99
|
+
|
|
100
|
+
put_url = sign_s3_put(
|
|
101
|
+
access_key=os.environ["AWS_ACCESS_KEY_ID"],
|
|
102
|
+
secret_key=os.environ["AWS_SECRET_ACCESS_KEY"],
|
|
103
|
+
bucket="my-rightra-bucket",
|
|
104
|
+
key=f"uploads/{content_id}/seg-0.enc",
|
|
105
|
+
region="us-east-1",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
r2_url = sign_r2_put(
|
|
109
|
+
account_id=os.environ["R2_ACCOUNT_ID"],
|
|
110
|
+
access_key=os.environ["R2_ACCESS_KEY_ID"],
|
|
111
|
+
secret_key=os.environ["R2_SECRET_ACCESS_KEY"],
|
|
112
|
+
bucket="rightra",
|
|
113
|
+
key=f"uploads/{content_id}/seg-0.enc",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
## Errors
|
|
117
|
+
|
|
118
|
+
Every failure raises rightra.RightraError. The exception carries .status, .code, and .message. Network errors are wrapped in the same type with .status == 0.
|
|
119
|
+
|
|
120
|
+
## Framework notes
|
|
121
|
+
|
|
122
|
+
Django and Flask users import the same module. There is no app context and no settings module lookup. Pass configuration explicitly from your own settings layer so secrets stay in one place.
|
|
123
|
+
|
|
124
|
+
## Limits
|
|
125
|
+
|
|
126
|
+
This package does not encrypt or decrypt media. It does not render a player. Those are the browser SDK's job. The Python package is the server side of the same workflow.
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
Proprietary. Copyright OLIVIUUS Ltd. Contact: contact@oliviuus.com
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
rightra/__init__.py,sha256=EWC_uF7Veq1qPCovELLkukpY6raDiWgTnY03fkYZNKE,813
|
|
2
|
+
rightra/client.py,sha256=speqVu55OZlRYR_sxqxe1HAmneEj4Rzgxyv_muwIJnY,10607
|
|
3
|
+
rightra/errors.py,sha256=FJKbpeQXampmpWmbXZyZcvB9z-_0ydPjFZtXTQtWwQE,965
|
|
4
|
+
rightra/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
rightra/storage.py,sha256=tiK7t8OFSd0nTYHYTN_I_vO5ixhOZEcN93ROzAikvgc,4777
|
|
6
|
+
rightra/upload.py,sha256=ABQ3JFfAliQue24Oyo-5vhC9cJXEkbQdIlDQa2iw3LQ,3284
|
|
7
|
+
rightra-0.1.0.dist-info/licenses/LICENSE,sha256=edfpeFBqVonXQAC2-Iz8S-yiCSNlS6d2QemRzCw8LcI,796
|
|
8
|
+
rightra-0.1.0.dist-info/METADATA,sha256=iQOE-Mz33Qc-lG_KS6PxAB2CFcl-0cgL7at9IJp1FrU,4776
|
|
9
|
+
rightra-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
rightra-0.1.0.dist-info/top_level.txt,sha256=bBFhs6j0mpTp40ZuNCyUr4MecTvH2nbSdekzbzag6ds,8
|
|
11
|
+
rightra-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Copyright (c) OLIVIUUS Ltd. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This software and its source code are proprietary and confidential.
|
|
4
|
+
|
|
5
|
+
No permission is granted to use, copy, modify, merge, publish, distribute,
|
|
6
|
+
sublicense, or sell copies of this software, in whole or in part, without
|
|
7
|
+
prior written consent from OLIVIUUS Ltd.
|
|
8
|
+
|
|
9
|
+
Contact: contact@oliviuus.com
|
|
10
|
+
|
|
11
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
12
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
13
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
|
14
|
+
OLIVIUUS LTD BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
15
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
16
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rightra
|