Skip to content

Commit 40a98e5

Browse files
authored
feat(file): report upload failures clearly and verify before storing
Merge pull request #153 from OpenSemanticLab/fix/file-upload-extension-error
2 parents c037bce + 3a7b6eb commit 40a98e5

7 files changed

Lines changed: 571 additions & 18 deletions

File tree

‎src/osw/controller/file/wiki.py‎

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import os
44
from typing import IO, Any, Dict, List, Optional
55

6+
import mwclient.errors
7+
68
from osw.controller.file.base import FileController
79
from osw.controller.file.remote import RemoteFileController
810
from osw.core import OSW, model
@@ -12,6 +14,71 @@
1214
_logger = logging.getLogger(__name__)
1315

1416

17+
def format_allowed_extensions(allowed: WtSite.AllowedFileExtensionsResult) -> str:
18+
"""Renders the allowed-extensions hint for a rejected-upload error message"""
19+
if not allowed.extensions or allowed.fetched_at is None:
20+
return ""
21+
ttl_hours = int(WtSite.ALLOWED_FILE_EXTENSIONS_TTL.total_seconds() // 3600)
22+
return (
23+
f" Extensions allowed there, read {allowed.fetched_at:%Y-%m-%d %H:%M:%S} and "
24+
f"cached for {ttl_hours} h: {', '.join(sorted(allowed.extensions))}. "
25+
"Call osw.site.clear_allowed_file_extensions_cache() to re-read the list "
26+
"if the wiki configuration changed since."
27+
)
28+
29+
30+
def reraise_upload_error(
31+
error: mwclient.errors.APIError, site: WtSite, title: str, suffix: Optional[str]
32+
) -> None:
33+
"""Turns a rejected file extension into a readable error, re-raises the rest
34+
35+
MediaWiki reports a rejected extension as filetype-banned,
36+
filetype-banned-type or filetype-badtype, depending on the version.
37+
"""
38+
if "filetype" not in str(getattr(error, "code", "")):
39+
raise error
40+
allowed = site.get_allowed_file_extensions()
41+
hint = format_allowed_extensions(allowed)
42+
raise ValueError(
43+
f"Upload of '{title}' was rejected because the file extension "
44+
f"'{suffix}' is not allowed on {site.mw_site.host}.{hint}"
45+
) from error
46+
47+
48+
def assert_upload_success(result: Any, title: str, host: str) -> None:
49+
"""Raises unless the upload API reports that it stored the file
50+
51+
mwclient raises only when the response carries an 'error' key. A file that
52+
MediaWiki declined for any other reason comes back as a normal return value
53+
with a result other than 'Success', so without this check the upload would
54+
fail silently.
55+
"""
56+
status = result.get("result") if isinstance(result, dict) else None
57+
if status == "Success":
58+
return
59+
warnings = result.get("warnings") if isinstance(result, dict) else None
60+
detail = f" Warnings: {warnings}." if warnings else ""
61+
raise ValueError(
62+
f"Upload of '{title}' to {host} did not succeed (result: {status}).{detail}"
63+
)
64+
65+
66+
def store_params_for_upload(
67+
se_params: Dict[str, Any], page_existed: bool
68+
) -> Dict[str, Any]:
69+
"""Picks the overwrite policy for the entity that accompanies an upload
70+
71+
An upload creates the file page when it was not there yet. store_entity
72+
would then see an existing page and, under the default 'keep existing',
73+
leave the metadata unwritten. So the entity has to replace what the upload
74+
put there. A page that was already there keeps whatever the caller asked
75+
for.
76+
"""
77+
if page_existed:
78+
return se_params
79+
return {**se_params, "overwrite": "replace remote"}
80+
81+
1582
class WikiFileController(model.WikiFile, RemoteFileController):
1683
"""File controller for wiki files"""
1784

@@ -133,20 +200,29 @@ def put(self, file: IO, **kwargs: Dict[str, Any]):
133200
}
134201
for key in ["entities", "namespace"]:
135202
se_params.pop(key, None) # avoid duplicated kwargs
203+
# Upload before storing the entity: MediaWiki offers no transaction
204+
# across the two writes, so one of them can be left standing. A file
205+
# page without metadata is visibly incomplete, while metadata without a
206+
# file looks like a valid entity until someone tries to download it.
207+
page_existed = self.osw.mw_site.pages[f"{self.namespace}:{self.title}"].exists
208+
try:
209+
result = self.osw.mw_site.upload(
210+
file=file,
211+
filename=self.title,
212+
# comment="",
213+
# description="",
214+
ignore=True,
215+
)
216+
except mwclient.errors.APIError as e:
217+
reraise_upload_error(e, self.osw.site, self.title, self.suffix)
218+
assert_upload_success(result, self.title, self.osw.mw_site.host)
136219
self.osw.store_entity(
137220
OSW.StoreEntityParam(
138221
entities=[self.cast(model.WikiFile, **wf_params)],
139222
namespace=self.namespace,
140-
**se_params,
223+
**store_params_for_upload(se_params, page_existed),
141224
)
142225
)
143-
self.osw.mw_site.upload(
144-
file=file,
145-
filename=self.title,
146-
# comment="",
147-
# description="",
148-
ignore=True,
149-
)
150226

151227
def put_from(self, other: FileController, **kwargs: Dict[str, Any]):
152228
# if isinstance(file, LocalFileController) and self.suffix is None:

‎src/osw/core.py‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,14 @@
5959
get_full_title,
6060
get_namespace,
6161
get_title,
62-
get_uuid,
6362
is_empty,
6463
namespace_from_full_title,
6564
remove_empty,
6665
title_from_full_title,
6766
)
67+
from osw.utils.wiki import (
68+
get_uuid as get_uuid_from_osw_id,
69+
)
6870
from osw.wiki_tools import SearchParam
6971
from osw.wtsite import WtPage, WtSite
7072

@@ -237,18 +239,21 @@ def get_osw_id(uuid: Union[str, UUID]) -> str:
237239

238240
@staticmethod
239241
def get_uuid(osw_id: str) -> UUID:
240-
"""Returns the uuid for a given OSW-ID
242+
"""Returns the uuid for a given OSW-ID. Kept for backwards compatibility,
243+
the implementation lives in osw.utils.wiki.get_uuid()
241244
242245
Parameters
243246
----------
244247
osw_id
245-
OSW-ID string, e.g. OSW2ea5b605c91f4e5a95593dff79fdd4a5
248+
OSW-ID string, e.g. OSW2ea5b605c91f4e5a95593dff79fdd4a5, with or
249+
without file suffixes, e.g.
250+
OSW2ea5b605c91f4e5a95593dff79fdd4a5.drawio.png
246251
247252
Returns
248253
-------
249254
uuid object, e.g. UUID("2ea5b605-c91f-4e5a-9559-3dff79fdd4a5")
250255
"""
251-
return UUID(osw_id.replace("OSW", ""))
256+
return get_uuid_from_osw_id(osw_id)
252257

253258
class SortEntitiesResult(OswBaseModel):
254259
by_name: Dict[str, List[OswBaseModel]]
@@ -1452,7 +1457,7 @@ def validate_entity(cls, entity, values):
14521457
if jsondata is None: # Guard clause
14531458
title = title_from_full_title(page.title)
14541459
try:
1455-
uuid_from_title = get_uuid(title)
1460+
uuid_from_title = get_uuid_from_osw_id(title)
14561461
except ValueError:
14571462
_logger.error(
14581463
f"UUID could not be determined from title: '{title}', "

‎src/osw/utils/wiki.py‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1+
import re
12
from copy import deepcopy
23
from uuid import UUID
34

45
# Legacy imports:
56
from opensemantic.v1 import get_full_title, get_namespace, get_title # noqa: F401
67

8+
OSW_ID_PATTERN = re.compile(
9+
r"^(?:OSW)?" # the prefix, absent when a bare uuid is passed
10+
r"([0-9a-fA-F]{32})" # the uuid, in the hex form an OSW-ID carries it
11+
r"(?:\.[\w-]+)*$" # file suffixes, e.g. '.png' or '.drawio.png'
12+
)
13+
"""Matches an OSW-ID with an optional prefix and any number of file suffixes"""
14+
715

816
def get_osw_id(uuid: UUID) -> str:
917
"""Generates a OSW-ID based on the given uuid by prefixing "OSW" and removing
@@ -21,19 +29,33 @@ def get_osw_id(uuid: UUID) -> str:
2129
return "OSW" + str(uuid).replace("-", "")
2230

2331

24-
def get_uuid(osw_id) -> UUID:
25-
"""Returns the uuid for a given OSW-ID. Duplicate of OSW.get_uuid() from src/sw/core/osw.py
32+
def get_uuid(osw_id: str) -> UUID:
33+
"""Returns the uuid for a given OSW-ID. The single implementation, wrapped by
34+
OSW.get_uuid() from src/osw/core.py
35+
36+
A file page keeps its file extension in the title, so the OSW-ID of a file is
37+
followed by one or more suffixes that are not part of the uuid. These are
38+
ignored, as is the OSW prefix.
2639
2740
Parameters
2841
----------
2942
osw_id
30-
OSW-ID string, e.g., OSW2ea5b605c91f4e5a95593dff79fdd4a5
43+
OSW-ID string, e.g., OSW2ea5b605c91f4e5a95593dff79fdd4a5, with or without
44+
file suffixes, e.g., OSW2ea5b605c91f4e5a95593dff79fdd4a5.drawio.png
3145
3246
Returns
3347
-------
3448
uuid object, e.g., UUID("2ea5b605-c91f-4e5a-9559-3dff79fdd4a5")
49+
50+
Raises
51+
------
52+
ValueError
53+
If no OSW-ID can be read from the given string.
3554
"""
36-
return UUID(osw_id.replace("OSW", ""))
55+
match = OSW_ID_PATTERN.match(osw_id)
56+
if match is None:
57+
raise ValueError(f"No OSW-ID could be read from '{osw_id}'")
58+
return UUID(match.group(1))
3759

3860

3961
def namespace_from_full_title(full_title: str) -> str:

‎src/osw/wtsite.py‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import urllib
1111
import xml.etree.ElementTree as et
1212
from copy import deepcopy
13-
from datetime import datetime
13+
from datetime import datetime, timedelta
1414
from io import StringIO
1515
from pathlib import Path
1616
from pprint import pformat
@@ -105,6 +105,9 @@ class WtSiteLegacyConfig(OswBaseModel):
105105
class Config:
106106
arbitrary_types_allowed = True
107107

108+
ALLOWED_FILE_EXTENSIONS_TTL = timedelta(hours=24)
109+
"""how long a fetched list of accepted file extensions stays valid"""
110+
108111
def __init__(self, config: Union[WtSiteConfig, WtSiteLegacyConfig]):
109112
"""creates a new WtSite instance from a WtSiteConfig
110113
@@ -187,6 +190,10 @@ def __init__(self, config: Union[WtSiteConfig, WtSiteLegacyConfig]):
187190
self._page_cache = {}
188191
self._cache_enabled = False
189192

193+
# The file extensions the wiki accepts, read on demand and reused for
194+
# ALLOWED_FILE_EXTENSIONS_TTL
195+
self._allowed_file_extensions = None
196+
190197
def _get_session_lock(self) -> threading.RLock:
191198
"""Return the session lock, lazily creating it if absent.
192199
@@ -518,6 +525,68 @@ def clear_cache(self):
518525
del self._page_cache
519526
self._page_cache = {}
520527

528+
class AllowedFileExtensionsResult(OswBaseModel):
529+
"""The file extensions a wiki accepts and when that list was read"""
530+
531+
extensions: Optional[List[str]]
532+
"""the accepted extensions, None if the lookup failed"""
533+
fetched_at: Optional[datetime]
534+
"""when the list was read from the wiki, None if the lookup failed"""
535+
536+
def get_allowed_file_extensions(
537+
self, refresh: bool = False
538+
) -> AllowedFileExtensionsResult:
539+
"""Returns the file extensions the wiki accepts, from cache if possible
540+
541+
The list only changes when the wiki configuration changes, so it is
542+
cached for ALLOWED_FILE_EXTENSIONS_TTL instead of being read on every
543+
call. A failed lookup is never cached, so the next call retries; a
544+
failed refresh leaves any previously cached list in place.
545+
546+
Parameters
547+
----------
548+
refresh:
549+
if True, bypasses the cache and re-reads the list from the wiki
550+
551+
Returns
552+
-------
553+
an AllowedFileExtensionsResult with the accepted extensions and
554+
when they were read, or with both fields None if the lookup failed
555+
"""
556+
cached = getattr(self, "_allowed_file_extensions", None)
557+
if (
558+
cached is not None
559+
and not refresh
560+
and cached.fetched_at is not None
561+
and datetime.now() - cached.fetched_at < self.ALLOWED_FILE_EXTENSIONS_TTL
562+
):
563+
return cached
564+
try:
565+
result = self.mw_site.api(
566+
"query", meta="siteinfo", siprop="fileextensions", formatversion=2
567+
)
568+
extensions = [
569+
entry["ext"]
570+
for entry in result.get("query", {}).get("fileextensions", [])
571+
if "ext" in entry
572+
]
573+
except Exception:
574+
# only used to enrich an error message, never worth failing over
575+
return WtSite.AllowedFileExtensionsResult(extensions=None, fetched_at=None)
576+
self._allowed_file_extensions = WtSite.AllowedFileExtensionsResult(
577+
extensions=extensions, fetched_at=datetime.now()
578+
)
579+
return self._allowed_file_extensions
580+
581+
def clear_allowed_file_extensions_cache(self):
582+
"""Clears the cached list of file extensions the wiki accepts
583+
584+
The next get_allowed_file_extensions() call reads the list from the
585+
wiki again. Use this when the wiki's upload configuration changed
586+
within ALLOWED_FILE_EXTENSIONS_TTL.
587+
"""
588+
self._allowed_file_extensions = None
589+
521590
def _clear_cookies(self):
522591
# see https://github.com/mwclient/mwclient/issues/221
523592
# Iterate a snapshot (list(...)) so mutating the jar mid-loop is safe, and

0 commit comments

Comments
 (0)