Guide auto fixing links implemented
This commit is contained in:
@@ -1134,6 +1134,77 @@ class UGCCategoryTests(UGCMediaTestCase, UGCGatedTestCase):
|
|||||||
resp = self.client.get(reverse('library:guide_doc', args=['guide', version.pk, vf.file.uuid]))
|
resp = self.client.get(reverse('library:guide_doc', args=['guide', version.pk, vf.file.uuid]))
|
||||||
self.assertEqual(resp.status_code, 404)
|
self.assertEqual(resp.status_code, 404)
|
||||||
|
|
||||||
|
def test_guide_rewrites_links_to_separately_uploaded_files(self):
|
||||||
|
self.project = Project.objects.create(
|
||||||
|
slug='guide', title='Guide', category='guide', owner=self.alice,
|
||||||
|
)
|
||||||
|
self._upload_version(
|
||||||
|
'README.md', b'# Title\n\n\n', content_type='text/markdown',
|
||||||
|
)
|
||||||
|
self._upload_version('images/logo.png', b'PNGDATA', content_type='image/png')
|
||||||
|
self._upload_version_form()
|
||||||
|
version = self.project.versions.first()
|
||||||
|
md_vf = version.files.get(kind='markdown')
|
||||||
|
self.gate()
|
||||||
|
resp = self.client.get(reverse('library:guide_doc', args=['guide', version.pk, md_vf.file.uuid]))
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertContains(resp, '<img src="/api/files/', count=1)
|
||||||
|
self.assertNotContains(resp, 'images/logo.png')
|
||||||
|
|
||||||
|
def test_guide_zip_docs_listed_and_links_rewritten(self):
|
||||||
|
self.project = Project.objects.create(
|
||||||
|
slug='guide', title='Guide', category='guide', owner=self.alice,
|
||||||
|
)
|
||||||
|
self._upload_version('guide.zip', _zip_bytes({
|
||||||
|
'README.md': '# Read\n\n\n\nNext: [part two](part2.md)\n',
|
||||||
|
'images/a.png': b'PNG2',
|
||||||
|
'part2.md': '# Two\n',
|
||||||
|
}))
|
||||||
|
self._upload_version_form()
|
||||||
|
version = self.project.versions.first()
|
||||||
|
self.gate()
|
||||||
|
# Both .md members appear in the guide switcher.
|
||||||
|
resp = self.client.get(reverse('library:project_detail', args=['guide']))
|
||||||
|
self.assertContains(resp, 'README.md')
|
||||||
|
self.assertContains(resp, 'part2.md')
|
||||||
|
# Zip doc renders with image + cross-doc link rewritten.
|
||||||
|
resp = self.client.get(reverse('library:guide_zip_doc', args=['guide', version.pk, 'README.md']))
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertContains(resp, '<img src="/packs/guide/versions/%d/asset/images/a.png"' % version.pk, count=1)
|
||||||
|
self.assertContains(resp, 'href="/packs/guide/guide/zip/%d/part2.md"' % version.pk, count=1)
|
||||||
|
|
||||||
|
def test_guide_zip_doc_rejects_non_guide_or_missing(self):
|
||||||
|
self.project = Project.objects.create(
|
||||||
|
slug='guide', title='Guide', category='guide', owner=self.alice,
|
||||||
|
)
|
||||||
|
self._upload_version('guide.zip', _zip_bytes({'README.md': '# Read\n'}))
|
||||||
|
self._upload_version_form()
|
||||||
|
version = self.project.versions.first()
|
||||||
|
self.gate()
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.get(reverse('library:guide_zip_doc', args=['guide', version.pk, 'missing.md'])).status_code,
|
||||||
|
404,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.get(reverse('library:guide_zip_doc', args=['guide', version.pk, 'data.json'])).status_code,
|
||||||
|
404,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_guide_links_not_rewritten_outside_guide_category(self):
|
||||||
|
self.project = Project.objects.create(
|
||||||
|
slug='pack', title='Pack', category='non_pack', owner=self.alice,
|
||||||
|
)
|
||||||
|
self._upload_version(
|
||||||
|
'notes.md', b'# Notes\n\n\n', content_type='text/markdown',
|
||||||
|
)
|
||||||
|
self._upload_version_form()
|
||||||
|
version = self.project.versions.first()
|
||||||
|
md_vf = version.files.get(kind='markdown')
|
||||||
|
self.gate()
|
||||||
|
resp = self.client.get(reverse('library:guide_doc', args=['pack', version.pk, md_vf.file.uuid]))
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertContains(resp, 'src="images/x.png"')
|
||||||
|
|
||||||
def test_regular_category_has_no_extra_metadata(self):
|
def test_regular_category_has_no_extra_metadata(self):
|
||||||
self.project = Project.objects.create(
|
self.project = Project.objects.create(
|
||||||
slug='mod', title='Mod', category='mod', owner=self.alice,
|
slug='mod', title='Mod', category='mod', owner=self.alice,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ urlpatterns = [
|
|||||||
path('packs/<slug:slug>/versions/<int:version_id>/files/<uuid:file_uuid>/download/', views.version_file_download, name='version_file_download'),
|
path('packs/<slug:slug>/versions/<int:version_id>/files/<uuid:file_uuid>/download/', views.version_file_download, name='version_file_download'),
|
||||||
path('packs/<slug:slug>/versions/<int:version_id>/asset/<path:asset_path>', views.pack_asset, name='pack_asset'),
|
path('packs/<slug:slug>/versions/<int:version_id>/asset/<path:asset_path>', views.pack_asset, name='pack_asset'),
|
||||||
path('packs/<slug:slug>/guide/<int:version_id>/<uuid:file_uuid>/', views.guide_doc, name='guide_doc'),
|
path('packs/<slug:slug>/guide/<int:version_id>/<uuid:file_uuid>/', views.guide_doc, name='guide_doc'),
|
||||||
|
path('packs/<slug:slug>/guide/zip/<int:version_id>/<path:asset_path>', views.guide_zip_doc, name='guide_zip_doc'),
|
||||||
path('packs/<slug:slug>/gallery/upload/', views.asset_upload, name='asset_upload'),
|
path('packs/<slug:slug>/gallery/upload/', views.asset_upload, name='asset_upload'),
|
||||||
path('packs/<slug:slug>/gallery/<int:asset_id>/thumb/', views.asset_thumbnail, name='asset_thumbnail'),
|
path('packs/<slug:slug>/gallery/<int:asset_id>/thumb/', views.asset_thumbnail, name='asset_thumbnail'),
|
||||||
path('packs/<slug:slug>/gallery/<int:asset_id>/delete/', views.asset_delete, name='asset_delete'),
|
path('packs/<slug:slug>/gallery/<int:asset_id>/delete/', views.asset_delete, name='asset_delete'),
|
||||||
|
|||||||
+175
-1
@@ -347,7 +347,38 @@ def project_detail(request, slug):
|
|||||||
anim_stats = None
|
anim_stats = None
|
||||||
latest = versions.first()
|
latest = versions.first()
|
||||||
if project.category == 'guide' and latest is not None:
|
if project.category == 'guide' and latest is not None:
|
||||||
guide_docs = [vf for vf in latest.files.all() if vf.is_markdown]
|
guide_docs = []
|
||||||
|
seen = set()
|
||||||
|
for vf in latest.files.all():
|
||||||
|
if vf.is_markdown:
|
||||||
|
guide_docs.append({
|
||||||
|
'filename': vf.filename,
|
||||||
|
'url': reverse('library:guide_doc', args=[project.slug, latest.pk, vf.file.uuid]),
|
||||||
|
})
|
||||||
|
seen.add((vf.filename or '').lower())
|
||||||
|
release = latest.files.filter(kind='release').first()
|
||||||
|
if release is not None and (release.filename or '').lower().endswith('.zip'):
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(_stored_path(release.file)) as zf:
|
||||||
|
members = [
|
||||||
|
m for m in zf.namelist()
|
||||||
|
if not m.endswith('/') and m.lower().endswith('.md')
|
||||||
|
]
|
||||||
|
except (zipfile.BadZipFile, OSError, FileNotFoundError):
|
||||||
|
members = []
|
||||||
|
members.sort(key=lambda m: (
|
||||||
|
not _guide_norm_path(m).rsplit('/', 1)[-1].startswith(('readme', 'index')),
|
||||||
|
m,
|
||||||
|
))
|
||||||
|
for m in members:
|
||||||
|
bname = _guide_norm_path(m).rsplit('/', 1)[-1]
|
||||||
|
if bname in seen:
|
||||||
|
continue
|
||||||
|
seen.add(bname)
|
||||||
|
guide_docs.append({
|
||||||
|
'filename': bname,
|
||||||
|
'url': reverse('library:guide_zip_doc', args=[project.slug, latest.pk, m]),
|
||||||
|
})
|
||||||
if latest is not None:
|
if latest is not None:
|
||||||
animation_manifest = latest.animation_manifest or None
|
animation_manifest = latest.animation_manifest or None
|
||||||
models_manifest = latest.models_manifest or None
|
models_manifest = latest.models_manifest or None
|
||||||
@@ -487,6 +518,117 @@ def api_packs_latest(request, namespace, pack_id):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
_GUIDE_LINK_RE = re.compile(r'(!?\[[^\]]*\]\()([^\s)]+)([^)]*)(\))')
|
||||||
|
_GUIDE_REF_RE = re.compile(r'^(\[[^\]]+\]:\s*)(\S+)\s*$', re.MULTILINE)
|
||||||
|
_GUIDE_LINK_SKIP = ('http://', 'https://', '//', 'data:', 'mailto:', '#', 'tel:', 'javascript:')
|
||||||
|
|
||||||
|
|
||||||
|
def _guide_norm_path(path):
|
||||||
|
"""Normalize a link target / stored path for matching (lowercase, no ./ or /)."""
|
||||||
|
p = (path or '').strip().replace('\\', '/')
|
||||||
|
while p.startswith('./'):
|
||||||
|
p = p[2:]
|
||||||
|
p = p.lstrip('/')
|
||||||
|
return p.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _guide_asset_maps(version, base_dir=None, source='zip'):
|
||||||
|
"""Build normalized path/basename -> served URL maps for a guide version.
|
||||||
|
|
||||||
|
``full_map`` keys are normalized full paths, ``base_map`` keys are
|
||||||
|
normalized basenames. First match wins; priority is: members inside the
|
||||||
|
current doc's own zip directory -> standalone uploaded files -> other zip
|
||||||
|
members. Markdown members map to the rendered-doc endpoints, everything
|
||||||
|
else to the raw file/asset endpoints."""
|
||||||
|
slug = version.project.slug
|
||||||
|
full = {}
|
||||||
|
base = {}
|
||||||
|
|
||||||
|
def _add(path, url):
|
||||||
|
norm = _guide_norm_path(path)
|
||||||
|
full.setdefault(norm, url)
|
||||||
|
bname = norm.rsplit('/', 1)[-1]
|
||||||
|
if bname and bname not in base:
|
||||||
|
base[bname] = url
|
||||||
|
|
||||||
|
release = version.files.filter(kind='release').first()
|
||||||
|
zip_members = []
|
||||||
|
if release is not None and (release.filename or '').lower().endswith('.zip'):
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(_stored_path(release.file)) as zf:
|
||||||
|
zip_members = [m for m in zf.namelist() if not m.endswith('/')]
|
||||||
|
except (zipfile.BadZipFile, OSError, FileNotFoundError):
|
||||||
|
zip_members = []
|
||||||
|
|
||||||
|
def _zip_url(member):
|
||||||
|
if member.lower().endswith('.md'):
|
||||||
|
return reverse('library:guide_zip_doc', args=[slug, version.pk, member])
|
||||||
|
return reverse('library:pack_asset', args=[slug, version.pk, member])
|
||||||
|
|
||||||
|
def _under_base(member):
|
||||||
|
if not base_dir:
|
||||||
|
return False
|
||||||
|
norm = _guide_norm_path(member)
|
||||||
|
return norm == base_dir or norm.startswith(base_dir + '/')
|
||||||
|
|
||||||
|
if source == 'zip':
|
||||||
|
for m in zip_members:
|
||||||
|
if _under_base(m):
|
||||||
|
_add(m, _zip_url(m))
|
||||||
|
for vf in version.files.all():
|
||||||
|
name = (vf.filename or '').replace('\\', '/')
|
||||||
|
if not name or (vf.kind == 'release' and name.lower().endswith('.zip')):
|
||||||
|
continue
|
||||||
|
if vf.is_markdown:
|
||||||
|
url = reverse('library:guide_doc', args=[slug, version.pk, vf.file.uuid])
|
||||||
|
else:
|
||||||
|
url = reverse('library:file_request', args=[vf.file.uuid])
|
||||||
|
_add(name, url)
|
||||||
|
for m in zip_members:
|
||||||
|
if not (source == 'zip' and _under_base(m)):
|
||||||
|
_add(m, _zip_url(m))
|
||||||
|
return full, base
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_guide_src(src, full_map, base_map, base_dir=None):
|
||||||
|
"""Rewrite one markdown target to its served URL, or None when unknown."""
|
||||||
|
if not src or src.startswith(_GUIDE_LINK_SKIP):
|
||||||
|
return None
|
||||||
|
path = src.split('#')[0].split('?')[0]
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
norm = _guide_norm_path(path)
|
||||||
|
if base_dir:
|
||||||
|
cand = _guide_norm_path(f'{base_dir}/{path}')
|
||||||
|
if cand in full_map:
|
||||||
|
return full_map[cand]
|
||||||
|
if norm in full_map:
|
||||||
|
return full_map[norm]
|
||||||
|
bname = norm.rsplit('/', 1)[-1]
|
||||||
|
if bname in base_map:
|
||||||
|
return base_map[bname]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_guide_links(md, version, base_dir=None, source='zip'):
|
||||||
|
"""Rewrite markdown link/image/reference targets to served URLs whenever a
|
||||||
|
file with the same name is present in the version's uploads (individual
|
||||||
|
files or the release zip). Guide category only."""
|
||||||
|
full_map, base_map = _guide_asset_maps(version, base_dir=base_dir, source=source)
|
||||||
|
|
||||||
|
def _sub_inline(m):
|
||||||
|
new = _resolve_guide_src(m.group(2), full_map, base_map, base_dir)
|
||||||
|
return f'{m.group(1)}{new or m.group(2)}{m.group(3)}{m.group(4)}'
|
||||||
|
|
||||||
|
def _sub_ref(m):
|
||||||
|
new = _resolve_guide_src(m.group(2), full_map, base_map, base_dir)
|
||||||
|
return f'{m.group(1)}{new or m.group(2)}'
|
||||||
|
|
||||||
|
md = _GUIDE_LINK_RE.sub(_sub_inline, md)
|
||||||
|
md = _GUIDE_REF_RE.sub(_sub_ref, md)
|
||||||
|
return md
|
||||||
|
|
||||||
|
|
||||||
def guide_doc(request, slug, version_id, file_uuid):
|
def guide_doc(request, slug, version_id, file_uuid):
|
||||||
"""Render one markdown guide document server-side (used by the switcher)."""
|
"""Render one markdown guide document server-side (used by the switcher)."""
|
||||||
version = get_object_or_404(
|
version = get_object_or_404(
|
||||||
@@ -503,6 +645,38 @@ def guide_doc(request, slug, version_id, file_uuid):
|
|||||||
text = fh.read().decode('utf-8', 'replace')
|
text = fh.read().decode('utf-8', 'replace')
|
||||||
finally:
|
finally:
|
||||||
fh.close()
|
fh.close()
|
||||||
|
if version.project.category == 'guide':
|
||||||
|
text = _rewrite_guide_links(text, version, source='file')
|
||||||
|
return HttpResponse(render_markdown(text), content_type='text/html; charset=utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def guide_zip_doc(request, slug, version_id, asset_path):
|
||||||
|
"""Render one markdown document stored inside the version's release zip."""
|
||||||
|
version = get_object_or_404(
|
||||||
|
Version.objects.select_related('project'),
|
||||||
|
pk=version_id, project__slug=slug,
|
||||||
|
)
|
||||||
|
if version.project.category != 'guide':
|
||||||
|
raise Http404
|
||||||
|
if not asset_path or asset_path.startswith('/') or '..' in asset_path.split('/'):
|
||||||
|
raise Http404
|
||||||
|
if not asset_path.lower().endswith('.md'):
|
||||||
|
raise Http404
|
||||||
|
vf = (
|
||||||
|
version.files.select_related('file').filter(kind='release').first()
|
||||||
|
or version.files.select_related('file').first()
|
||||||
|
)
|
||||||
|
if vf is None or not (vf.filename or '').lower().endswith('.zip'):
|
||||||
|
raise Http404
|
||||||
|
with zipfile.ZipFile(_stored_path(vf.file)) as zf:
|
||||||
|
if asset_path not in zf.namelist():
|
||||||
|
raise Http404
|
||||||
|
data = zf.read(asset_path)
|
||||||
|
text = data.decode('utf-8', 'replace')
|
||||||
|
text = _rewrite_guide_links(
|
||||||
|
text, version,
|
||||||
|
base_dir=posixpath.dirname(asset_path), source='zip',
|
||||||
|
)
|
||||||
return HttpResponse(render_markdown(text), content_type='text/html; charset=utf-8')
|
return HttpResponse(render_markdown(text), content_type='text/html; charset=utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
<div class="guide-switcher" role="tablist">
|
<div class="guide-switcher" role="tablist">
|
||||||
{% for doc in guide_docs %}
|
{% for doc in guide_docs %}
|
||||||
<button type="button" class="guide-switch-btn{% if forloop.first %} active{% endif %}"
|
<button type="button" class="guide-switch-btn{% if forloop.first %} active{% endif %}"
|
||||||
data-url="{% url 'library:guide_doc' project.slug doc.version.pk doc.file.uuid %}">{{ doc.filename }}</button>
|
data-url="{{ doc.url }}">{{ doc.filename }}</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="markdown-body guide-content" id="guide-content">
|
<div class="markdown-body guide-content" id="guide-content">
|
||||||
|
|||||||
Reference in New Issue
Block a user