Skip to content

Commit fa2b5f6

Browse files
committed
file system: Added support for file system
1 parent 3a67aae commit fa2b5f6

File tree

10 files changed

+331
-6
lines changed

10 files changed

+331
-6
lines changed

ellar/core/conf/app_settings_models.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import sys
21
import typing as t
32

43
from ellar.common.constants import (
@@ -19,10 +18,6 @@
1918

2019
from .mixins import ConfigDefaultTypesMixin
2120

22-
if sys.version_info >= (3, 8): # pragma: no cover
23-
from typing import Literal
24-
else: # pragma: no cover
25-
from typing_extensions import Literal
2621
if t.TYPE_CHECKING: # pragma: no cover
2722
from ellar.app.main import App
2823

@@ -127,7 +122,7 @@ class ConfigValidationSchema(Serializer, ConfigDefaultTypesMixin):
127122
SESSION_COOKIE_PATH: str = "/"
128123
SESSION_COOKIE_HTTPONLY: bool = True
129124
SESSION_COOKIE_SECURE: bool = False
130-
SESSION_COOKIE_SAME_SITE: Literal["lax", "strict", "none"] = "lax"
125+
SESSION_COOKIE_SAME_SITE: t.Literal["lax", "strict", "none"] = "lax"
131126
SESSION_COOKIE_MAX_AGE: t.Optional[int] = 14 * 24 * 60 * 60 # 14 days, in seconds
132127

133128
@field_validator("MIDDLEWARE", mode="before")

ellar/core/files/__init__.py

Whitespace-only changes.

ellar/core/files/storages/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from .base import BaseStorage
2+
3+
# from .factory import get_file_storage
4+
from .interface import Storage
5+
from .local import FileSystemStorage
6+
7+
__all__ = [
8+
"Storage",
9+
"BaseStorage",
10+
"FileSystemStorage",
11+
# 'get_file_storage',
12+
]

ellar/core/files/storages/aws_s3.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
try:
2+
import boto3
3+
except ImportError as im_ex: # pragma: no cover
4+
raise RuntimeError(
5+
"boto3 must be installed to use the 'S3AWSFileStorage' class."
6+
) from im_ex
7+
import typing as t
8+
import urllib.parse
9+
from io import BytesIO
10+
11+
from .base import BaseStorage
12+
13+
14+
class S3AWSFileStorage(BaseStorage):
15+
def service_name(self) -> str:
16+
return "s3_bucket"
17+
18+
def __init__(
19+
self,
20+
bucket: str,
21+
access_key: str,
22+
secret_key: str,
23+
region: str,
24+
max_age: int = 60 * 60 * 24 * 365,
25+
prefix: t.Optional[str] = None,
26+
endpoint_url: t.Optional[str] = None,
27+
acl: str = "private",
28+
enable_cache_control: bool = False,
29+
public_link_expiration: int = 3600,
30+
) -> None:
31+
self.bucket = self.get_aws_bucket(
32+
bucket=bucket,
33+
secret_key=secret_key,
34+
access_key=access_key,
35+
region=region,
36+
endpoint_url=endpoint_url,
37+
)
38+
self._max_age = max_age
39+
self._prefix = prefix
40+
self._acl = acl
41+
self._enable_cache_control = enable_cache_control
42+
self._public_link_expiration = public_link_expiration
43+
44+
def get_aws_bucket(
45+
self,
46+
bucket: str,
47+
access_key: str,
48+
secret_key: str,
49+
region: str,
50+
endpoint_url: t.Optional[str] = None,
51+
) -> t.Any:
52+
session = boto3.session.Session()
53+
config = boto3.session.Config(
54+
s3={"addressing_style": "path"}, signature_version="s3v4"
55+
)
56+
57+
s3 = session.resource(
58+
"s3",
59+
config=config,
60+
endpoint_url=endpoint_url,
61+
region_name=region,
62+
aws_access_key_id=access_key,
63+
aws_secret_access_key=secret_key,
64+
)
65+
return s3.Bucket(bucket)
66+
67+
def get_s3_path(self, filename: str) -> str:
68+
if self._prefix:
69+
return "{0}/{1}".format(self._prefix, filename)
70+
return filename
71+
72+
def _upload_file(
73+
self, filename: str, data: str, content_type: t.Optional[str], rrs: bool = False
74+
) -> t.Any:
75+
put_object_kwargs = {
76+
"Key": filename,
77+
"Body": data,
78+
"ACL": self._acl,
79+
"StorageClass": "REDUCED_REDUNDANCY" if rrs else "STANDARD",
80+
"ContentType": content_type or "",
81+
}
82+
if self._enable_cache_control:
83+
put_object_kwargs.update(CacheControl="max-age=" + str(self._max_age))
84+
85+
return self.bucket.put_object(**put_object_kwargs)
86+
87+
def put(self, filename: str, stream: t.IO) -> int:
88+
path = self.get_s3_path(filename)
89+
stream.seek(0)
90+
data = stream.read()
91+
92+
content_type = getattr(stream, "content_type", None)
93+
rrs = getattr(stream, "reproducible", False)
94+
95+
self._upload_file(path, data, content_type, rrs=rrs)
96+
97+
return len(data)
98+
99+
def delete(self, filename: str) -> None:
100+
path = self.get_s3_path(filename)
101+
self.bucket.Object(path).delete()
102+
103+
def open(self, filename: str, mode: str = "rb") -> t.IO:
104+
path = self.get_s3_path(filename)
105+
obj = self.bucket.Object(path).get()
106+
107+
return BytesIO(obj["Body"].read())
108+
109+
def _strip_signing_parameters(self, url: str) -> str:
110+
split_url = urllib.parse.urlsplit(url)
111+
qs = urllib.parse.parse_qsl(split_url.query, keep_blank_values=True)
112+
blacklist = {
113+
"x-amz-algorithm",
114+
"x-amz-credential",
115+
"x-amz-date",
116+
"x-amz-expires",
117+
"x-amz-signedheaders",
118+
"x-amz-signature",
119+
"x-amz-security-token",
120+
"awsaccesskeyid",
121+
"expires",
122+
"signature",
123+
}
124+
filtered_qs = ((key, val) for key, val in qs if key.lower() not in blacklist)
125+
joined_qs = ("=".join(keyval) for keyval in filtered_qs)
126+
split_url = split_url._replace(query="&".join(joined_qs))
127+
return split_url.geturl()
128+
129+
def locate(self, filename: str) -> str:
130+
path = self.get_s3_path(filename)
131+
params = {"Key": path, "Bucket": self.bucket.name}
132+
expires = self._public_link_expiration
133+
134+
url = self.bucket.meta.client.generate_presigned_url(
135+
"get_object", Params=params, ExpiresIn=expires
136+
)
137+
138+
if self._acl in ["public-read", "public-read-write"]:
139+
url = self._strip_signing_parameters(url)
140+
141+
return url # type:ignore[no-any-return]

ellar/core/files/storages/base.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import os
2+
import pathlib
3+
from abc import ABC
4+
5+
from .exceptions import SuspiciousFileOperation
6+
from .interface import Storage
7+
from .utils import get_valid_filename, validate_file_name
8+
9+
10+
class BaseStorage(ABC, Storage):
11+
DEFAULT_CHUNK_SIZE = 64 * 2**10
12+
13+
def validate_file_name(self, filename: str) -> None:
14+
"""
15+
Return an alternative filename, by adding an underscore and a random 7-character alphanumeric string
16+
(before the file extension, if one exists) to the filename.
17+
"""
18+
validate_file_name(name=filename)
19+
20+
def generate_filename(self, filename: str) -> str:
21+
"""
22+
Validate the filename by calling get_valid_name() and return a filename
23+
to be passed to the save() method.
24+
"""
25+
filename = str(filename).replace("\\", "/")
26+
# `filename` may include a path as returned by FileField.upload_to.
27+
dirname, filename = os.path.split(filename)
28+
if ".." in pathlib.PurePath(dirname).parts:
29+
raise SuspiciousFileOperation(
30+
"Detected path traversal attempt in '%s'" % dirname
31+
)
32+
return os.path.normpath(os.path.join(dirname, get_valid_filename(filename)))
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
class SuspiciousOperation(Exception):
2+
"""The user did something suspicious"""
3+
4+
5+
class SuspiciousFileOperation(SuspiciousOperation):
6+
"""A Suspicious filesystem operation was attempted"""
7+
8+
pass
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import typing as t
2+
from abc import abstractmethod
3+
4+
5+
class Storage:
6+
"""The abstract base class for all stores."""
7+
8+
@abstractmethod
9+
def service_name(self) -> str:
10+
pass
11+
12+
@abstractmethod
13+
def put(self, filename: str, stream: t.IO) -> int:
14+
"""
15+
Puts the file object as the given filename in the store.
16+
17+
:param filename: target filename.
18+
:param stream: source file-like object
19+
:return: length of the stored file.
20+
"""
21+
22+
@abstractmethod
23+
def delete(self, filename: str) -> None:
24+
"""
25+
deletes a given file.
26+
27+
:param filename: The filename to delete
28+
"""
29+
30+
@abstractmethod
31+
def open(self, filename: str, mode: str = "rb") -> t.IO:
32+
"""
33+
Return a file object representing the file in the store.
34+
:param filename: The filename to open.
35+
:param mode: same as the `mode` in famous :func:`.open` function.
36+
"""
37+
38+
@abstractmethod
39+
def locate(self, filename: str) -> str:
40+
"""
41+
Returns a shareable public link to the filename.
42+
43+
:param filename: The filename to locate.
44+
"""

ellar/core/files/storages/local.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import os
2+
import typing as t
3+
4+
from .base import BaseStorage
5+
from .utils import copy_stream
6+
7+
8+
class FileSystemStorage(BaseStorage):
9+
def service_name(self) -> str:
10+
return "local"
11+
12+
def __init__(
13+
self, location: str, chunk_size: int = BaseStorage.DEFAULT_CHUNK_SIZE
14+
) -> None:
15+
self.root_path = os.path.abspath(location)
16+
self.chunk_size = chunk_size
17+
18+
def _get_physical_path(self, filename: str) -> str:
19+
return os.path.join(self.root_path, filename)
20+
21+
def put(self, filename: str, stream: t.IO) -> int:
22+
physical_path = self._get_physical_path(filename)
23+
physical_directory = os.path.dirname(physical_path)
24+
25+
if not os.path.exists(physical_directory):
26+
os.makedirs(physical_directory, exist_ok=True)
27+
28+
stream.seek(0)
29+
30+
with open(physical_path, mode="wb") as target_file:
31+
return copy_stream(stream, target_file, chunk_size=self.chunk_size)
32+
33+
def delete(self, filename: str) -> None:
34+
physical_path = self._get_physical_path(filename)
35+
os.remove(physical_path)
36+
37+
def open(self, filename: str, mode: str = "rb") -> t.IO:
38+
return open(self._get_physical_path(filename), mode=mode)
39+
40+
def locate(self, filename: str) -> str:
41+
return f"{self.root_path}/{filename}"

ellar/core/files/storages/utils.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import os
2+
import re
3+
import typing
4+
from io import BytesIO
5+
6+
from .exceptions import SuspiciousFileOperation
7+
8+
9+
def copy_stream(
10+
source: typing.IO, target: typing.IO, *, chunk_size: int = 16 * 1024
11+
) -> int:
12+
length = 0
13+
while 1:
14+
buf = source.read(chunk_size)
15+
if not buf:
16+
break
17+
length += len(buf)
18+
target.write(buf)
19+
return length
20+
21+
22+
def get_length(source: typing.IO) -> int:
23+
buffer = BytesIO()
24+
return copy_stream(source, buffer)
25+
26+
27+
def get_valid_filename(name: str) -> str:
28+
"""
29+
Return the given string converted to a string that can be used for a clean
30+
filename. Remove leading and trailing spaces; convert other spaces to
31+
underscores; and remove anything that is not an alphanumeric, dash,
32+
underscore, or dot.
33+
>>> get_valid_filename("john's portrait in 2004.jpg")
34+
'johns_portrait_in_2004.jpg'
35+
"""
36+
s = str(name).strip().replace(" ", "_")
37+
s = re.sub(r"(?u)[^-\w.]", "", s)
38+
if s in {"", ".", ".."}:
39+
raise Exception("Could not derive file name from '%s'" % name)
40+
return s
41+
42+
43+
def validate_file_name(name: str) -> str:
44+
# Remove potentially dangerous names
45+
if os.path.basename(name) in {"", ".", ".."}:
46+
raise SuspiciousFileOperation("Could not derive file name from '%s'" % name)
47+
48+
if name != os.path.basename(name):
49+
raise SuspiciousFileOperation("File name '%s' includes path elements" % name)
50+
51+
return name

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ classifiers = [
3434
"Programming Language :: Python :: 3.10",
3535
"Programming Language :: Python :: 3.11",
3636
"Programming Language :: Python :: 3 :: Only",
37+
"Framework :: Ellar",
3738
"Framework :: AsyncIO",
3839
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
3940
"Topic :: Internet :: WWW/HTTP",

0 commit comments

Comments
 (0)