component file sharing: quotas implementation:

quotas can now be specified using the `quotas_json` option of `component file_sharing`
section in settings. This must be a dict where:
    - `users` key contains default quotas for all users
    - `admins` key contains quotas for administrators (not implemented yet)
    - `jids` contain bare JID to quota mapping, to have user-specific quota

The value can be either a int for quota in bytes, or a case insensitive string with an
optional multiplier symbol (e.g. "500 Mio"). `None` can be used for explicit unlimited
quota (which is the default is `users` is not set).

When a file size is too big for quota, upload is refused with an error message indicating
allowed quota, used space, and the size of the file that user wants to upload.
This commit is contained in:
Goffi 2021-05-05 15:37:33 +02:00
commit ac8f9f7817
3 changed files with 68 additions and 16 deletions

View file

@ -25,7 +25,7 @@ import shortuuid
import unicodedata
from urllib.parse import urljoin, urlparse, quote, unquote
from pathlib import Path
from sat.core.i18n import _
from sat.core.i18n import _, D_
from sat.core.constants import Const as C
from sat.core import exceptions
from sat.core.log import getLogger
@ -34,6 +34,7 @@ from sat.tools import video
from sat.tools.common import regex
from sat.tools.common import uri
from sat.tools.common import files_utils
from sat.tools.common import utils
from sat.tools.common import tls
from twisted.internet import defer, reactor
from twisted.words.protocols.jabber import error
@ -75,6 +76,10 @@ COMMENT_NODE_PREFIX = "org.salut-a-toi.file_comments/"
# Directory used to buffer request body (i.e. file in case of PUT) we use more than one @
# there, to be sure than it's not conflicting with a JID
TMP_BUFFER_DIR = "@@tmp@@"
OVER_QUOTA_TXT = D_(
"You are over quota, your maximum allowed size is {quota} and you are already using "
"{used_space}, you can't upload {file_size} more."
)
server.version = unicodedata.normalize(
'NFKD',
@ -242,7 +247,7 @@ class FileSharingRequest(server.Request):
if self._upload_data is not None:
return self._upload_data
# self.path is not available if we are easly in the request (e.g. when gotLength
# self.path is not available if we are early in the request (e.g. when gotLength
# is called), in which case channel._path must be used. On the other hand, when
# render_[VERB] is called, only self.path is available
path = self.channel._path if self.path is None else self.path
@ -347,7 +352,8 @@ class FileSharing:
self._jf = self.host.plugins["XEP-0234"]
self._h = self.host.plugins["XEP-0300"]
self._t = self.host.plugins["XEP-0264"]
self.host.plugins["XEP-0363"].registerHandler(self._onHTTPUpload)
self._hu = self.host.plugins["XEP-0363"]
self._hu.registerHandler(self._onHTTPUpload)
self.host.trigger.add("FILE_getDestDir", self._getDestDirTrigger)
self.host.trigger.add(
"XEP-0234_fileSendingRequest", self._fileSendingRequestTrigger, priority=1000
@ -400,6 +406,17 @@ class FileSharing:
if not os.path.exists(path):
os.makedirs(path)
def getQuota(self, client, entity):
"""Return maximum size allowed for all files for entity"""
# TODO: handle special entities like admins
quotas = self.host.memory.getConfig("component file_sharing", "quotas_json", {})
entity_bare_s = entity.userhost()
try:
quota = quotas["jids"][entity_bare_s]
except KeyError:
quota = quotas.get("users")
return None if quota is None else utils.parseSize(quota)
async def generate_thumbnails(self, extra: dict, image_path: Path):
thumbnails = extra.setdefault(C.KEY_THUMBNAILS, [])
for max_thumb_size in self._t.SIZES:
@ -485,7 +502,7 @@ class FileSharing:
extra=extra,
)
def _getDestDirTrigger(
async def _getDestDirTrigger(
self, client, peer_jid, transfer_data, file_data, stream_object
):
"""This trigger accept file sending request, and store file locally"""
@ -496,6 +513,19 @@ class FileSharing:
assert C.KEY_PROGRESS_ID in file_data
filename = file_data["name"]
assert filename and not "/" in filename
quota = self.getQuota(client, peer_jid)
if quota is not None:
used_space = await self.host.memory.fileGetUsedSpace(client, peer_jid)
if (used_space + file_data["size"]) > quota:
raise error.StanzaError(
"not-acceptable",
text=OVER_QUOTA_TXT.format(
quota=utils.getHumanSize(quota),
used_space=utils.getHumanSize(used_space),
file_size=utils.getHumanSize(file_data['size'])
)
)
file_tmp_dir = self.host.getLocalPath(
None, C.FILES_TMP_DIR, peer_jid.userhost(), component=True, profile=False
)
@ -597,19 +627,34 @@ class FileSharing:
except KeyError:
log.error(f"trying to purge an inexisting upload slot ({upload_id})")
def _onHTTPUpload(self, client, request):
async def _onHTTPUpload(self, client, request):
# filename should be already cleaned, but it's better to double check
assert '/' not in request.filename
# client._file_sharing_allowed_hosts is set in plugin XEP-0329
if request.from_.host not in client._file_sharing_allowed_hosts:
raise error.StanzaError("forbidden")
quota = self.getQuota(client, request.from_)
if quota is not None:
used_space = await self.host.memory.fileGetUsedSpace(client, request.from_)
if (used_space + request.size) > quota:
raise error.StanzaError(
"not-acceptable",
text=OVER_QUOTA_TXT.format(
quota=utils.getHumanSize(quota),
used_space=utils.getHumanSize(used_space),
file_size=utils.getHumanSize(request.size)
),
appCondition = self._hu.getFileTooLargeElt(max(quota - used_space, 0))
)
upload_id = shortuuid.ShortUUID().random(length=30)
assert '/' not in upload_id
timer = reactor.callLater(30, self._purge_slot, upload_id)
self.expected_uploads[upload_id] = (client, request, timer)
url = urljoin(client._file_sharing_base_url, f"{upload_id}/{request.filename}")
slot = self.host.plugins["XEP-0363"].Slot(
slot = self._hu.Slot(
put=url,
get=url,
headers=[],

View file

@ -266,7 +266,9 @@ class FilePlugin:
)
return True
else:
return self.getDestDir(client, peer_jid, transfer_data, file_data)
return defer.ensureDeferred(
self.getDestDir(client, peer_jid, transfer_data, file_data)
)
exists_d = xml_tools.deferConfirm(
self.host,
@ -286,7 +288,9 @@ class FilePlugin:
self.openFileWrite(client, file_path, transfer_data, file_data, stream_object)
return True
def getDestDir(self, client, peer_jid, transfer_data, file_data, stream_object=False):
async def getDestDir(
self, client, peer_jid, transfer_data, file_data, stream_object=False
):
"""Request confirmation and destination dir to user
Overwrite confirmation is managed.
@ -313,7 +317,7 @@ class FilePlugin:
a stream.FileStreamObject will be used
return (defer.Deferred): True if transfer is accepted
"""
cont, ret_value = self.host.trigger.returnPoint(
cont, ret_value = await self.host.trigger.asyncReturnPoint(
"FILE_getDestDir", client, peer_jid, transfer_data, file_data, stream_object
)
if not cont:

View file

@ -17,18 +17,19 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from sat.core.i18n import _, D_
from sat.core.constants import Const as C
from sat.core.log import getLogger
log = getLogger(__name__)
from sat.core import exceptions
from sat.tools import xml_tools
from sat.tools import stream
import os
from twisted.words.xish import domish
from twisted.words.protocols.jabber import jid
from twisted.words.protocols.jabber import error
import os
from twisted.internet import defer
from sat.core.i18n import _, D_
from sat.core.constants import Const as C
from sat.core.log import getLogger
from sat.core import exceptions
from sat.tools import xml_tools
from sat.tools import stream
log = getLogger(__name__)
NS_SI_FT = "http://jabber.org/protocol/si/profile/file-transfer"
@ -205,7 +206,9 @@ class XEP_0096(object):
"stream_plugin": plugin,
}
d = self._f.getDestDir(client, peer_jid, data, data, stream_object=True)
d = defer.ensureDeferred(
self._f.getDestDir(client, peer_jid, data, data, stream_object=True)
)
d.addCallback(self.confirmationCb, client, iq_elt, data)
def confirmationCb(self, accepted, client, iq_elt, data):