diff --git a/nonpacks/library/tests.py b/nonpacks/library/tests.py index 77eb163..720f5f5 100644 --- a/nonpacks/library/tests.py +++ b/nonpacks/library/tests.py @@ -1134,6 +1134,77 @@ class UGCCategoryTests(UGCMediaTestCase, UGCGatedTestCase): resp = self.client.get(reverse('library:guide_doc', args=['guide', version.pk, vf.file.uuid])) 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![logo](images/logo.png)\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, '/versions//files//download/', views.version_file_download, name='version_file_download'), path('packs//versions//asset/', views.pack_asset, name='pack_asset'), path('packs//guide///', views.guide_doc, name='guide_doc'), + path('packs//guide/zip//', views.guide_zip_doc, name='guide_zip_doc'), path('packs//gallery/upload/', views.asset_upload, name='asset_upload'), path('packs//gallery//thumb/', views.asset_thumbnail, name='asset_thumbnail'), path('packs//gallery//delete/', views.asset_delete, name='asset_delete'), diff --git a/nonpacks/library/views.py b/nonpacks/library/views.py index 11ca718..caf0eb3 100644 --- a/nonpacks/library/views.py +++ b/nonpacks/library/views.py @@ -347,7 +347,38 @@ def project_detail(request, slug): anim_stats = None latest = versions.first() 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: animation_manifest = latest.animation_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): """Render one markdown guide document server-side (used by the switcher).""" 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') finally: 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') diff --git a/nonpacks/templates/library/project_detail.html b/nonpacks/templates/library/project_detail.html index 9155074..015013e 100644 --- a/nonpacks/templates/library/project_detail.html +++ b/nonpacks/templates/library/project_detail.html @@ -93,7 +93,7 @@
{% for doc in guide_docs %} + data-url="{{ doc.url }}">{{ doc.filename }} {% endfor %}