Skip to content

Commit 6f6035f

Browse files
authored
Implement people and media quotas (fixes #365) (#366)
* Implement people and media quotas * Fix tests * Add database migration * Check people quota in transactions
1 parent 8992d01 commit 6f6035f

File tree

18 files changed

+536
-58
lines changed

18 files changed

+536
-58
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Add trees table
2+
3+
Revision ID: 66e56620891a
4+
Revises: e176543c72a8
5+
Create Date: 2023-05-05 22:48:14.628117
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
12+
# revision identifiers, used by Alembic.
13+
revision = '66e56620891a'
14+
down_revision = 'e176543c72a8'
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
op.create_table('trees',
22+
sa.Column('id', sa.String(), nullable=False),
23+
sa.Column('quota_media', sa.Integer(), nullable=True),
24+
sa.Column('quota_people', sa.Integer(), nullable=True),
25+
sa.Column('usage_media', sa.Integer(), nullable=True),
26+
sa.Column('usage_people', sa.Integer(), nullable=True),
27+
sa.PrimaryKeyConstraint('id')
28+
)
29+
# ### end Alembic commands ###
30+
31+
32+
def downgrade():
33+
# ### commands auto generated by Alembic - please adjust! ###
34+
op.drop_table('trees')
35+
# ### end Alembic commands ###

gramps_webapi/api/file.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ def get_file_object(self) -> BinaryIO:
6060
"""Return a binary file object."""
6161
raise NotImplementedError
6262

63+
def get_file_size(self) -> int:
64+
"""Return the file size in bytes."""
65+
raise NotImplementedError
66+
6367
def file_exists(self) -> bool:
6468
"""Check if the file exists."""
6569
raise NotImplementedError
@@ -140,6 +144,17 @@ def get_file_object(self) -> BinaryIO:
140144
stream = BytesIO(f.read())
141145
return stream
142146

147+
def get_file_size(self) -> int:
148+
"""Return the file size in bytes.
149+
150+
Assumes the file exists!
151+
"""
152+
try:
153+
self._check_path()
154+
except ValueError:
155+
abort(403)
156+
return os.path.getsize(self.path_abs)
157+
143158
def send_file(
144159
self, etag: Optional[str] = None, download: bool = False, filename: str = ""
145160
):
@@ -215,13 +230,14 @@ def get_checksum(fp) -> str:
215230
return md5sum
216231

217232

218-
def process_file(stream: Union[Any, BinaryIO]) -> Tuple[str, BinaryIO]:
233+
def process_file(stream: Union[Any, BinaryIO]) -> Tuple[str, int, BinaryIO]:
219234
"""Process a file from a stream that has a read method."""
220235
fp = BytesIO()
221236
fp.write(stream.read())
222237
fp.seek(0)
223238
checksum = get_checksum(fp)
239+
size = fp.tell()
224240
if not checksum:
225241
raise IOError("Unable to process file.")
226242
fp.seek(0)
227-
return checksum, fp
243+
return checksum, size, fp

gramps_webapi/api/media.py

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,20 @@
44
from pathlib import Path
55
from typing import BinaryIO, List, Optional, Set
66

7-
from flask import current_app
7+
from flask import abort, current_app
88
from gramps.gen.lib import Media
99
from gramps.gen.utils.file import expand_media_path
1010

11+
from ..auth import get_tree_usage, set_tree_usage
1112
from ..types import FilenameOrPath
1213
from ..util import get_extension
1314
from .file import FileHandler, LocalFileHandler, upload_file_local
14-
from .s3 import ObjectStorageFileHandler, list_object_keys, upload_file_s3
15-
from .util import get_db_handle
15+
from .s3 import (
16+
ObjectStorageFileHandler,
17+
get_object_keys_size,
18+
upload_file_s3,
19+
)
20+
from .util import get_db_handle, get_tree_from_jwt
1621

1722

1823
PREFIX_S3 = "s3://"
@@ -60,6 +65,10 @@ def filter_existing_files(self, objects: List[Media]) -> List[Media]:
6065
"""Given a list of media objects, return the ones with existing files."""
6166
raise NotImplementedError
6267

68+
def get_media_size(self) -> int:
69+
"""Return the total disk space used by all existing media objects."""
70+
raise NotImplementedError
71+
6372

6473
class MediaHandlerLocal(MediaHandlerBase):
6574
"""Handler for local media files."""
@@ -80,7 +89,7 @@ def upload_file(
8089
if Path(path).is_absolute():
8190
# Don't allow absolute paths! This will raise
8291
# if path is not relative to base_dir
83-
rel_path: FilenameOrPath = Path(path).relative_to(base_dir)
92+
rel_path: FilenameOrPath = Path(path).relative_to(self.base_dir)
8493
else:
8594
rel_path = path
8695
upload_file_local(self.base_dir, rel_path, stream)
@@ -94,6 +103,29 @@ def filter_existing_files(self, objects: List[Media]) -> List[Media]:
94103
obj for obj in objects if self.get_file_handler(obj.handle).file_exists()
95104
]
96105

106+
def get_media_size(self) -> int:
107+
"""Return the total disk space used by all existing media objects.
108+
109+
Only works with a request context.
110+
"""
111+
if not os.path.isdir(self.base_dir):
112+
raise ValueError(f"Directory {self.base_dir} does not exist")
113+
size = 0
114+
paths_seen = set()
115+
db_handle = get_db_handle()
116+
for obj in db_handle.iter_media():
117+
path = obj.path
118+
if os.path.isabs(path):
119+
if Path(self.base_dir).resolve() not in Path(path).resolve().parents:
120+
continue # file outside base dir - ignore
121+
else:
122+
path = os.path.join(self.base_dir, path)
123+
if Path(path).is_file() and path not in paths_seen:
124+
file_size = os.path.getsize(path)
125+
size += file_size
126+
paths_seen.add(path)
127+
return size
128+
97129

98130
class MediaHandlerS3(MediaHandlerBase):
99131
"""Generic handler for object storage media files."""
@@ -124,7 +156,9 @@ def prefix(self) -> Optional[str]:
124156

125157
def get_remote_keys(self) -> Set[str]:
126158
"""Return the set of all object keys that are known to exist on remote."""
127-
keys = list_object_keys(self.bucket_name, endpoint_url=self.endpoint_url)
159+
keys = get_object_keys_size(
160+
self.bucket_name, prefix=self.prefix, endpoint_url=self.endpoint_url
161+
)
128162
return set(removeprefix(key, self.prefix or "").lstrip("/") for key in keys)
129163

130164
def get_file_handler(self, handle) -> ObjectStorageFileHandler:
@@ -160,6 +194,17 @@ def filter_existing_files(self, objects: List[Media]) -> List[Media]:
160194
remote_keys = self.get_remote_keys()
161195
return [obj for obj in objects if obj.checksum in remote_keys]
162196

197+
def get_media_size(self) -> int:
198+
"""Return the total disk space used by all existing media objects."""
199+
db_handle = get_db_handle()
200+
keys = set(obj.checksum for obj in db_handle.iter_media())
201+
keys_size = get_object_keys_size(
202+
bucket_name=self.bucket_name,
203+
prefix=self.prefix,
204+
endpoint_url=self.endpoint_url,
205+
)
206+
return sum(keys_size.get(key, 0) for key in keys)
207+
163208

164209
def MediaHandler(base_dir: Optional[str]) -> MediaHandlerBase:
165210
"""Return an appropriate media handler."""
@@ -193,3 +238,27 @@ def get_media_handler(tree: Optional[str] = None) -> MediaHandlerBase:
193238
# construct subdirectory using OS dependent path join
194239
base_dir = os.path.join(base_dir, prefix)
195240
return MediaHandler(base_dir)
241+
242+
243+
def update_usage_media() -> int:
244+
"""Update the usage of media."""
245+
tree = get_tree_from_jwt()
246+
media_handler = get_media_handler(tree=tree)
247+
usage_media = media_handler.get_media_size()
248+
set_tree_usage(tree, usage_media=usage_media)
249+
return usage_media
250+
251+
252+
def check_quota_media(to_add: int) -> None:
253+
"""Check whether the quota allows adding `to_add` bytes and abort if not."""
254+
tree = get_tree_from_jwt()
255+
usage_dict = get_tree_usage(tree)
256+
if not usage_dict or usage_dict.get("usage_media") is None:
257+
update_usage_media()
258+
usage_dict = get_tree_usage(tree)
259+
usage = usage_dict["usage_media"]
260+
quota = usage_dict.get("quota_media")
261+
if quota is None:
262+
return
263+
if usage + to_add > quota:
264+
abort(405)

gramps_webapi/api/resources/base.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,17 @@
3333
from gramps.gen.utils.grampslocale import GrampsLocale
3434
from webargs import fields, validate
3535

36+
from ...auth import get_tree_usage, set_tree_usage
3637
from ...auth.const import PERM_ADD_OBJ, PERM_DEL_OBJ, PERM_EDIT_OBJ
3738
from ..auth import require_permissions
3839
from ..search import SearchIndexer
3940
from ..util import (
41+
check_quota_people,
4042
get_db_handle,
4143
get_locale_for_language,
4244
get_search_indexer,
4345
get_tree_from_jwt,
46+
update_usage_people,
4447
use_args,
4548
)
4649
from . import ProtectedResource, Resource
@@ -250,6 +253,9 @@ def delete(self, handle: str) -> Response:
250253
trans_dict = delete_object(
251254
self.db_handle_writable, handle, self.gramps_class_name
252255
)
256+
# update usage
257+
if self.gramps_class_name == "Person":
258+
update_usage_people()
253259
# update search index
254260
tree = get_tree_from_jwt()
255261
indexer: SearchIndexer = get_search_indexer(tree)
@@ -420,6 +426,9 @@ def get(self, args: Dict) -> Response:
420426
def post(self) -> Response:
421427
"""Post a new object."""
422428
require_permissions([PERM_ADD_OBJ])
429+
# check quota
430+
if self.gramps_class_name == "Person":
431+
check_quota_people(to_add=1)
423432
obj = self._parse_object()
424433
if not obj:
425434
abort(400)
@@ -430,6 +439,9 @@ def post(self) -> Response:
430439
except ValueError:
431440
abort(400)
432441
trans_dict = transaction_to_json(trans)
442+
# update usage
443+
if self.gramps_class_name == "Person":
444+
update_usage_people()
433445
# update search index
434446
tree = get_tree_from_jwt()
435447
indexer: SearchIndexer = get_search_indexer(tree)

gramps_webapi/api/resources/file.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
from ...auth.const import PERM_EDIT_OBJ
3535
from ..auth import require_permissions
3636
from ..file import process_file
37-
from ..media import get_media_handler
37+
from ..media import check_quota_media, get_media_handler, update_usage_media
3838
from ..util import get_db_handle, get_tree_from_jwt, use_args
3939
from . import ProtectedResource
4040
from .util import transaction_to_json, update_object
@@ -86,21 +86,28 @@ def put(self, args: Dict, handle: str) -> Response:
8686
mime = request.content_type
8787
if not mime:
8888
abort(HTTPStatus.NOT_ACCEPTABLE)
89-
checksum, f = process_file(request.stream)
89+
checksum, size, f = process_file(request.stream)
9090
tree = get_tree_from_jwt()
9191
media_handler = get_media_handler(tree)
92+
file_handler = media_handler.get_file_handler(handle)
9293
if checksum == obj.checksum:
93-
file_handler = media_handler.get_file_handler(handle)
9494
if not args.get("uploadmissing") or file_handler.file_exists():
9595
# don't allow PUTting if the file didn't change
9696
abort(HTTPStatus.CONFLICT)
9797
# we're uploading a missing file!
98+
# new size will add to the quota
99+
check_quota_media(to_add=size)
98100
# use existing path
99101
path = obj.get_path()
100102
media_handler.upload_file(f, checksum, mime, path=path)
101103
return Response(status=200)
102104
if args.get("uploadmissing"):
103105
abort(HTTPStatus.CONFLICT)
106+
# we're updating an existing file
107+
size_old = file_handler.get_file_size()
108+
size_delta = size - size_old
109+
if size_delta > 0:
110+
check_quota_media(to_add=size_delta)
104111
media_handler.upload_file(f, checksum, mime)
105112
obj.set_checksum(checksum)
106113
path = media_handler.get_default_filename(checksum, mime)
@@ -114,6 +121,7 @@ def put(self, args: Dict, handle: str) -> Response:
114121
except ValueError:
115122
abort(400)
116123
trans_dict = transaction_to_json(trans)
124+
update_usage_media()
117125
return Response(
118126
response=json.dumps(trans_dict), status=200, mimetype="application/json"
119127
)

gramps_webapi/api/resources/media.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
from ...auth.const import PERM_ADD_OBJ
3434
from ..auth import require_permissions
3535
from ..file import process_file
36-
from ..media import get_media_handler
36+
from ..media import check_quota_media, get_media_handler, update_usage_media
3737
from ..util import get_tree_from_jwt
3838
from .base import (
3939
GrampsObjectProtectedResource,
@@ -79,7 +79,8 @@ def post(self) -> Response:
7979
mime = request.content_type
8080
if not mime:
8181
abort(HTTPStatus.NOT_ACCEPTABLE)
82-
checksum, f = process_file(request.stream)
82+
checksum, size, f = process_file(request.stream)
83+
check_quota_media(to_add=size)
8384
tree = get_tree_from_jwt()
8485
media_handler = get_media_handler(tree)
8586
media_handler.upload_file(f, checksum, mime)
@@ -95,4 +96,5 @@ def post(self) -> Response:
9596
except ValueError:
9697
abort(400)
9798
trans_dict = transaction_to_json(trans)
99+
update_usage_media()
98100
return self.response(201, trans_dict, total_items=len(trans_dict))

gramps_webapi/api/resources/objects.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,19 @@
2424

2525
from flask import Response, abort, current_app, request
2626
from gramps.gen.db import DbTxn
27-
from gramps.gen.lib import Family
27+
from gramps.gen.lib import Family, Person
2828
from gramps.gen.lib.primaryobj import BasicPrimaryObject as GrampsObject
2929
from gramps.gen.lib.serialize import from_json
3030

3131
from ...auth.const import PERM_ADD_OBJ, PERM_EDIT_OBJ
3232
from ..auth import require_permissions
3333
from ..search import SearchIndexer
34-
from ..util import get_db_handle, get_search_indexer, get_tree_from_jwt
34+
from ..util import (
35+
check_quota_people,
36+
get_db_handle,
37+
get_search_indexer,
38+
get_tree_from_jwt,
39+
)
3540
from . import ProtectedResource
3641
from .util import add_object, fix_object_dict, transaction_to_json, validate_object_dict
3742

@@ -66,6 +71,8 @@ def post(self) -> Response:
6671
require_permissions([PERM_EDIT_OBJ])
6772
if not objects:
6873
abort(400)
74+
number_new_people = sum(isinstance(obj, Person) for obj in objects)
75+
check_quota_people(to_add=number_new_people)
6976
db_handle = get_db_handle(readonly=False)
7077
with DbTxn("Add objects", db_handle) as trans:
7178
for obj in objects:

gramps_webapi/api/resources/transactions.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@
3333
from ...auth.const import PERM_ADD_OBJ, PERM_DEL_OBJ, PERM_EDIT_OBJ
3434
from ..auth import require_permissions
3535
from ..search import SearchIndexer
36-
from ..util import get_db_handle, get_search_indexer, get_tree_from_jwt
36+
from ..util import (
37+
check_quota_people,
38+
get_db_handle,
39+
get_search_indexer,
40+
get_tree_from_jwt,
41+
)
3742
from . import ProtectedResource
3843
from .util import transaction_to_json
3944

@@ -50,6 +55,10 @@ def post(self) -> Response:
5055
if not payload:
5156
abort(400) # disallow empty payload
5257
db_handle = get_db_handle(readonly=False)
58+
new_people = sum(
59+
item["type"] == "add" and item["_class"] == "Person" for item in payload
60+
)
61+
check_quota_people(to_add=new_people)
5362
with DbTxn("Raw transaction", db_handle) as trans:
5463
for item in payload:
5564
try:

0 commit comments

Comments
 (0)