diff --git a/src/backoffice/templates/backoffice/assemblyteam_assembly_detail.html b/src/backoffice/templates/backoffice/assemblyteam_assembly_detail.html
index cc728cd105b16391f5a0ccaae8528e240c8e25c0..06c8b523f80afebface6a12584dbaae35f7131c9 100644
--- a/src/backoffice/templates/backoffice/assemblyteam_assembly_detail.html
+++ b/src/backoffice/templates/backoffice/assemblyteam_assembly_detail.html
@@ -221,13 +221,55 @@
       {% if conference.support_assembly_physical %}
         <div class="card mb-3">
           <div class="card-header">Position</div>
-          <div class="card-body">
-            {{ object.location_point }}
-            <a role="button"
-               class="btn btn-sm btn-{% if assembly.is_public %}primary{% else %}secondary{% endif %}"
-               href="{% url 'backoffice:assemblyteam-editposition' pk=assembly.id %}">
-              <i class="bi bi-pencil"></i>
-            </a>
+          <div class="card-body d-flex flex-row">
+            <div>
+              <label for="pos_public">
+                <i class="bi bi-eye{% if not object.is_placed %}-slash{% endif %}"></i> {% trans "public" %}:
+              </label>
+              <br>
+              <label for="pos_point">
+                <i class="bi bi-pin-map"></i> POI:
+              </label>
+              <br>
+              <label for="pos_boundary">
+                <i class="bi bi-map"></i> Boundary:
+              </label>
+            </div>
+            <div class="flex-grow-1 ps-3">
+              {% if object.is_placed %}
+                <span id="pos_public" class="text-success"><i class="bi bi-check-square"></i> {% trans "yes" %}</span>
+              {% else %}
+                <span id="pos_public" class="text-warning"><i class="bi bi-x-square"></i> {% trans "no" %}</span>
+              {% endif %}
+              <br>
+              {% if object.location_data.point %}
+                <span id="pos_point"
+                      class="text-success"
+                      title="{{ object.location_data.point }}">
+                  <i class="bi bi-check-square"></i> {% trans "yes" %}
+                </span>
+              {% else %}
+                <span id="pos_poi" class="text-danger"><i class="bi bi-x-square"></i> {% trans "no" %}</span>
+              {% endif %}
+              <br>
+              {% if object.location_data.boundaries %}
+                <span id="pos_boundary"
+                      class="text-success"
+                      title="{{ object.location_data.boundaries }}">
+                  <i class="bi bi-check-square"></i> {% trans "yes" %}
+                </span>
+              {% else %}
+                <span id="pos_boundary" class="text-danger"><i class="bi bi-x-square"></i> {% trans "no" %}</span>
+              {% endif %}
+            </div>
+            <div>
+              <a role="button"
+                 class="btn btn-sm btn-{% if assembly.is_public %}primary{% else %}secondary{% endif %}"
+                 href="{% url 'backoffice:assemblyteam-editposition' pk=assembly.id %}">
+                <i class="bi bi-pencil"></i>
+                {% trans "edit" %}
+              </a>
+            </div>
           </div>
         </div>
       {% endif %}
diff --git a/src/core/locale/de/LC_MESSAGES/django.po b/src/core/locale/de/LC_MESSAGES/django.po
index 132a7c3d75cee8685229d149a32de88b100ae0d7..5a321506f61299e167a4741fdac614935b3dcab1 100644
--- a/src/core/locale/de/LC_MESSAGES/django.po
+++ b/src/core/locale/de/LC_MESSAGES/django.po
@@ -1224,18 +1224,15 @@ msgstr "Video-Stream"
 msgid "RoomLink__type-audio"
 msgstr "Audio-Stream"
 
+msgid "RoomLink__type-nav"
+msgstr "Navigation"
+
 msgid "RoomLink__type__help"
 msgstr "Was wird hier verlinkt?"
 
 msgid "RoomLink__type"
 msgstr "Typ"
 
-msgid "RoomLink__conference_internal__help"
-msgstr "Es handelt sich um einen Link der keine Vorschaltseite benötigt."
-
-msgid "RoomLink__conference_internal"
-msgstr "interner Link"
-
 msgid "Link__link__must_be_url"
 msgstr "Das Link-Ziel muss eine gültige URL sein."
 
diff --git a/src/core/locale/en/LC_MESSAGES/django.po b/src/core/locale/en/LC_MESSAGES/django.po
index 8370a3dbb5d6f67610e75f78b319f9552a1609b9..9d671ff7feb9acda6418267b0a0a41ccb74c8373 100644
--- a/src/core/locale/en/LC_MESSAGES/django.po
+++ b/src/core/locale/en/LC_MESSAGES/django.po
@@ -1224,18 +1224,15 @@ msgstr "video stream"
 msgid "RoomLink__type-audio"
 msgstr "audio stream"
 
+msgid "RoomLink__type-nav"
+msgstr "navigation"
+
 msgid "RoomLink__type__help"
 msgstr "What is being linked here?"
 
 msgid "RoomLink__type"
 msgstr "type"
 
-msgid "RoomLink__conference_internal__help"
-msgstr "This is an internal link which does not require a disclaimer page."
-
-msgid "RoomLink__conference_internal"
-msgstr "internal link"
-
 msgid "Link__link__must_be_url"
 msgstr "The link target must be a URL."
 
diff --git a/src/core/markdown.py b/src/core/markdown.py
index 06e3365b4e0f1f3949aed2a5aad7faad5eaba16d..ad3ca64719ad01fef3342754fdabca3b273d799e 100644
--- a/src/core/markdown.py
+++ b/src/core/markdown.py
@@ -1,6 +1,5 @@
 import html
 import re
-from urllib.parse import quote, urlparse
 
 import bleach
 import mistletoe
@@ -8,7 +7,6 @@ from mistletoe.block_token import BlockToken
 from mistletoe.html_renderer import HTMLRenderer
 from mistletoe.span_token import AutoLink, Link, SpanToken, tokenize_inner
 
-from django.conf import settings
 from django.db.models import Model
 from django.urls import NoReverseMatch, reverse
 from django.utils.safestring import mark_safe
@@ -17,24 +15,13 @@ from modeltranslation.fields import build_localized_fieldname
 from modeltranslation.settings import AVAILABLE_LANGUAGES
 
 from .models import conference
-from .utils import scheme_and_netloc_from_url, url_in_allowlist
+from .utils import resolve_link
 
 
 def markdown_header_slugify(value: str, separator: str) -> str:
     return 'md-' + slugify(value)
 
 
-def is_trusted_dst(url: str):
-    url = urlparse(url)
-    is_local_domain = url.netloc in settings.ALLOWED_HOSTS
-    is_external = (url.scheme or url.netloc) and not is_local_domain
-    return not is_external or url.scheme not in {'http', 'https', 'ftp', 'ftps'}
-
-
-def redirect_via_dereferer(url: str):
-    return settings.PLAINUI_DEREFERER_URL.format(quoted_target=quote(url))
-
-
 class PageLink(SpanToken):
     pattern = re.compile(r'\[\[ *([^|\]]+?) *(?:\| *(.*))? *\]\]')
     parse_group = 2
@@ -109,46 +96,17 @@ class MyHtmlRenderer(HTMLRenderer):
         result += '</div>\n'
         return result
 
-    def __init__(self, conf: 'conference.Conference', result: 'RenderResult', *extras, derefer_allowlist: bool = True, **kwargs):
+    def __init__(self, conf: 'conference.Conference', result: 'RenderResult', *extras, use_derefer_allowlist: bool = True, **kwargs):
         self.conf = conf
         self.result = result
-        self.derefer_allowlist = derefer_allowlist
+        self.use_derefer_allowlist = use_derefer_allowlist
         super().__init__(PageLink, ProfileLink, Tag, AlertBlock, *extras, **kwargs)
 
-    def derive_link_target(self, url):
-        """rewrite given URL unless it is trusted or in dereferrer-allowlist while those shall not be dereferred"""
-
-        do_derefer = True
-        if not self.derefer_allowlist:
-            try:
-                scheme_and_netloc = scheme_and_netloc_from_url(url)
-                if url_in_allowlist(scheme_and_netloc, settings.DEREFERRER_GLOBAL_ALLOWLIST):
-                    do_derefer = False
-            except ValueError:
-                # ignore URL parsing error
-                pass
-
-        return redirect_via_dereferer(url) if do_derefer else url
-
-    def handle_link(self, url: str) -> tuple[str, str]:
-        from .utils import resolve_internal_url
-
-        # attempt resolving an internal URL
-        if resolved_internal_url := resolve_internal_url(url, fallback_as_is=False):
-            url = resolved_internal_url
-
-        # derive external link (i.e. apply dereferer), if its not an internal or trusted location
-        if resolved_internal_url is None and not is_trusted_dst(url):
-            return 'external', self.derive_link_target(url)
-
-        # otherwise, it's an internal link
-        return 'internal', url
-
     def render_link(self, token: Link) -> str:
         if token.target.startswith(('javascript:', 'data:')):
             token.target = ''
 
-        link_type, url = self.handle_link(token.target)
+        link_type, url = resolve_link(token.target, self.use_derefer_allowlist)
         self.result.linked_urls.add(url)
 
         template = '<a href="{target}"{title} class="{link_type}">{inner}</a>'
@@ -269,7 +227,7 @@ def render_markdown_ex(
 
     result = RenderResult()
     with renderer(conf, result) as renderer:
-        renderer.derefer_allowlist = not dont_derefer_allowlist
+        renderer.use_derefer_allowlist = dont_derefer_allowlist
         rendered_markup = renderer.render(mistletoe.Document(markup))
 
     if sanitize_html:
diff --git a/src/core/migrations/0167_roomlink_add_type_nav_remove_internal_link.py b/src/core/migrations/0167_roomlink_add_type_nav_remove_internal_link.py
new file mode 100644
index 0000000000000000000000000000000000000000..92d338bd3fe5c7647907b32e0ad44675ab56c2eb
--- /dev/null
+++ b/src/core/migrations/0167_roomlink_add_type_nav_remove_internal_link.py
@@ -0,0 +1,31 @@
+# Generated by Django 5.1.3 on 2024-12-21 01:59
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0166_alter_room_official_room_order'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='link',
+            name='conference_internal',
+        ),
+        migrations.RemoveField(
+            model_name='roomlink',
+            name='conference_internal',
+        ),
+        migrations.AlterField(
+            model_name='link',
+            name='link_type',
+            field=models.CharField(choices=[('website', 'RoomLink__type-website'), ('chat', 'RoomLink__type-chat'), ('bbb', 'RoomLink__type-bbb'), ('jitsi', 'RoomLink__type-jitsi'), ('pad', 'RoomLink__type-pad'), ('media.ccc.de', 'RoomLink__type-media_ccc_de'), ('video', 'RoomLink__type-video'), ('audio', 'RoomLink__type-audio'), ('nav', 'RoomLink__type-nav')], help_text='RoomLink__type__help', max_length=20, verbose_name='RoomLink__type'),
+        ),
+        migrations.AlterField(
+            model_name='roomlink',
+            name='link_type',
+            field=models.CharField(choices=[('website', 'RoomLink__type-website'), ('chat', 'RoomLink__type-chat'), ('bbb', 'RoomLink__type-bbb'), ('jitsi', 'RoomLink__type-jitsi'), ('pad', 'RoomLink__type-pad'), ('media.ccc.de', 'RoomLink__type-media_ccc_de'), ('video', 'RoomLink__type-video'), ('audio', 'RoomLink__type-audio'), ('nav', 'RoomLink__type-nav')], help_text='RoomLink__type__help', max_length=20, verbose_name='RoomLink__type'),
+        ),
+    ]
diff --git a/src/core/models/links.py b/src/core/models/links.py
index 2b7ae3e5fbf87b7a6eb93f93fd579ecfae5b4878..86764e7e409e67adef65819949d2b20d5600766f 100644
--- a/src/core/models/links.py
+++ b/src/core/models/links.py
@@ -5,6 +5,8 @@ from django.core.validators import URLValidator
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 
+from core.utils import resolve_link
+
 
 class Link(models.Model):
     class Meta:
@@ -19,8 +21,9 @@ class Link(models.Model):
         MEDIA_CCC_DE = 'media.ccc.de', _('RoomLink__type-media_ccc_de')
         VIDEO = 'video', _('RoomLink__type-video')
         AUDIO = 'audio', _('RoomLink__type-audio')
+        NAV = 'nav', _('RoomLink__type-nav')
 
-    URL_LINK_TYPES = [LinkType.WEBSITE, LinkType.CHAT, LinkType.BBB, LinkType.JITSI, LinkType.PAD, LinkType.VIDEO, LinkType.AUDIO]
+    URL_LINK_TYPES = [LinkType.WEBSITE, LinkType.CHAT, LinkType.BBB, LinkType.JITSI, LinkType.PAD, LinkType.VIDEO, LinkType.AUDIO, LinkType.NAV]
     """All LinkTypes which require the link to be a valid URL. The notable exception is media_ccc_de where only a global id is required."""
 
     content_types = models.Q(app_label='core', model='project')
@@ -32,9 +35,24 @@ class Link(models.Model):
     link_type = models.CharField(max_length=20, choices=LinkType.choices, help_text=_('RoomLink__type__help'), verbose_name=_('RoomLink__type'))
     link = models.CharField(max_length=255)
 
-    conference_internal = models.BooleanField(
-        default=False, help_text=_('RoomLink__conference_internal__help'), verbose_name=_('RoomLink__conference_internal')
-    )
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._resolved_url = None
+
+    def _cache_resolved_url_if_necessary(self):
+        if self._resolved_url:
+            return
+        self._resolved_url = resolve_link(self.link)
+
+    @property
+    def resolved_url(self) -> str:
+        self._cache_resolved_url_if_necessary()
+        return self._resolved_url[1]
+
+    @property
+    def resolved_url_internal(self) -> bool:
+        self._cache_resolved_url_if_necessary()
+        return self._resolved_url[0] == 'internal'
 
     def clean(self):
         if self.link_type in Link.URL_LINK_TYPES:
@@ -48,7 +66,6 @@ class Link(models.Model):
         return self.name
 
     def __repr__(self) -> str:
-        repr_str = f'<{self.__class__.__name__}: {self.name} ({self.link_type}'
-        repr_str += ':internal)' if self.conference_internal else ')'
+        repr_str = f'<{self.__class__.__name__}: {self.name} ({self.link_type})'
         repr_str += f': {self.link}>' if self.link != self.name else '>'
         return repr_str
diff --git a/src/core/models/rooms.py b/src/core/models/rooms.py
index 21e0c1ee2991d1e131403f2a6a1f902e6f1580d7..b54819c259eb740fe27d5810a9ede9ef16a73cac 100644
--- a/src/core/models/rooms.py
+++ b/src/core/models/rooms.py
@@ -8,7 +8,6 @@ from django.conf import settings
 from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.postgres.fields import DateTimeRangeField
 from django.core.exceptions import ValidationError
-from django.core.validators import URLValidator
 from django.db import models
 from django.db.models import Q, QuerySet
 from django.utils.text import slugify
@@ -21,7 +20,7 @@ from core.models.base_managers import ConferenceManagerMixin
 from core.models.conference import Conference, ConferenceMember
 from core.models.shared import BackendMixin
 from core.models.tags import TagItem
-from core.utils import str2bool
+from core.utils import resolve_internal_url, resolve_link, str2bool
 from core.validators import FileSizeValidator, ImageDimensionValidator
 
 
@@ -465,10 +464,11 @@ class RoomLink(models.Model):
         MEDIA_CCC_DE = 'media.ccc.de', _('RoomLink__type-media_ccc_de')
         VIDEO = 'video', _('RoomLink__type-video')
         AUDIO = 'audio', _('RoomLink__type-audio')
+        NAV = 'nav', _('RoomLink__type-nav')
 
     objects = RoomLinkManager()
 
-    URL_LINKTYPES = [LinkType.WEBSITE, LinkType.CHAT, LinkType.BBB, LinkType.JITSI, LinkType.PAD, LinkType.VIDEO, LinkType.AUDIO]
+    URL_LINKTYPES = [LinkType.WEBSITE, LinkType.CHAT, LinkType.BBB, LinkType.JITSI, LinkType.PAD, LinkType.VIDEO, LinkType.AUDIO, LinkType.NAV]
     """All LinkTypes which require the link to be a valid URL. The notable exception is media_ccc_de where only a global id is required."""
 
     room = models.ForeignKey(Room, related_name='links', on_delete=models.CASCADE)
@@ -477,16 +477,28 @@ class RoomLink(models.Model):
     link_type = models.CharField(max_length=20, choices=LinkType.choices, help_text=_('RoomLink__type__help'), verbose_name=_('RoomLink__type'))
     link = models.CharField(max_length=255)
 
-    conference_internal = models.BooleanField(
-        default=False, help_text=_('RoomLink__conference_internal__help'), verbose_name=_('RoomLink__conference_internal')
-    )
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._resolved_url = None
+
+    def _cache_resolved_url_if_necessary(self):
+        if self._resolved_url:
+            return
+        self._resolved_url = resolve_link(self.link)
+
+    @property
+    def resolved_url(self) -> str:
+        self._cache_resolved_url_if_necessary()
+        return self._resolved_url[1]
+
+    @property
+    def resolved_url_internal(self) -> bool:
+        self._cache_resolved_url_if_necessary()
+        return self._resolved_url[0] == 'internal'
 
     def clean(self):
         if self.link_type in RoomLink.URL_LINKTYPES:
-            validator = URLValidator()
-            try:
-                validator(self.link)
-            except ValidationError:
+            if not resolve_internal_url(self.link, fallback_as_is=False):
                 raise ValidationError({'link': _('RoomLink__link__must_be_url')})
 
     def __str__(self):
diff --git a/src/core/tests/markdown.py b/src/core/tests/markdown.py
index d30892ab8ce3ecd58025077a29198c312c93ca9f..0ab5b990e0ca4172ed7c90e3eb65d1fd24645e7d 100644
--- a/src/core/tests/markdown.py
+++ b/src/core/tests/markdown.py
@@ -19,6 +19,7 @@ class MarkdownTest(TestCase):
         conf = Conference(name='foo', id=TEST_CONF_ID)
         conf.save()
 
+        # TODO: consider moving this test into tests/utils.py as this is basically testing resolve_link() only (except the footnote/anchored links part)
         tests = [
             ('https://localhost/', False),
             ('https://localhost/foo', False),
diff --git a/src/core/tests/utils.py b/src/core/tests/utils.py
index b39677a3372b26cdc7bc28b18ced6fabc03143d1..489713310d72750ffc6b17b852ba942608059780 100644
--- a/src/core/tests/utils.py
+++ b/src/core/tests/utils.py
@@ -2,7 +2,7 @@ import uuid
 from datetime import timedelta
 
 from django.conf import settings
-from django.test import TestCase
+from django.test import TestCase, override_settings
 from django.urls import reverse
 from django.utils.timezone import now
 
@@ -90,6 +90,30 @@ class InternalUrlTests(TestCase):
         for check in checks:
             self.assertEqual(check[1], resolve_internal_url(check[0]))
 
+        # combinations of accept_http_https and fallback_as_is with a https URL
+        self.assertEqual('https://ccc.de/', resolve_internal_url('https://ccc.de/', accept_http_https=True, fallback_as_is=True))
+        self.assertEqual('https://ccc.de/', resolve_internal_url('https://ccc.de/', accept_http_https=True, fallback_as_is=False))
+        self.assertEqual('https://ccc.de/', resolve_internal_url('https://ccc.de/', accept_http_https=False, fallback_as_is=True))
+        self.assertIsNone(resolve_internal_url('https://ccc.de/', accept_http_https=False, fallback_as_is=False))
+
+        # combinations of accept_http_https and fallback_as_is with an invalid URL
+        self.assertEqual('foo', resolve_internal_url('foo', accept_http_https=True, fallback_as_is=True))
+        self.assertIsNone(resolve_internal_url('foo', accept_http_https=True, fallback_as_is=False))
+        self.assertEqual('foo', resolve_internal_url('foo', accept_http_https=False, fallback_as_is=True))
+        self.assertIsNone(resolve_internal_url('foo', accept_http_https=False, fallback_as_is=False))
+
+    @override_settings(ADDITIONAL_LINK_PROTOCOLS={'c3nav': 'https://test.c3nav.de/l/VALUE', 'ccc': 'https://ccc.de/?goto=VALUE'})
+    def test_additional_link_protocols(self):
+        checks = [
+            ('c3nav://unittest', 'https://test.c3nav.de/l/unittest'),
+            ('c3nav://unittest?a=b', 'https://test.c3nav.de/l/unittest?a=b'),
+            ('ccc://unittest', 'https://ccc.de/?goto=unittest'),
+            ('ccc://unittest?foo=bar', 'https://ccc.de/?goto=unittest&foo=bar'),
+        ]
+
+        for check in checks:
+            self.assertEqual(check[1], resolve_internal_url(check[0]))
+
 
 class GitRepoOfflineTests(TestCase):
     def test_invalid_url_local_path(self):
diff --git a/src/core/utils.py b/src/core/utils.py
index 3ec4c560b60f602ff686d5680be68db9a6368dcd..a96d9e4bd5cc717fa8671048608725984ee0587d 100644
--- a/src/core/utils.py
+++ b/src/core/utils.py
@@ -8,10 +8,11 @@ import uuid
 from datetime import UTC, datetime, timedelta
 from pathlib import Path
 from string import ascii_letters, digits
-from urllib.parse import parse_qs, urlparse, urlunparse
+from urllib.parse import parse_qs, quote, urlparse, urlunparse
 
 import requests
 
+from django.conf import settings
 from django.core.exceptions import ValidationError
 from django.core.files.base import ContentFile
 from django.core.validators import validate_slug
@@ -155,7 +156,7 @@ def mask_url(url):
     return urlunparse(masked)
 
 
-def resolve_internal_url(url: str, fallback_as_is: bool = True) -> str | None:
+def resolve_internal_url(url: str, accept_http_https: bool = True, fallback_as_is: bool = True) -> str | None:
     """
     Resolves special URLs like
      - conference://url_resolver_name
@@ -163,7 +164,8 @@ def resolve_internal_url(url: str, fallback_as_is: bool = True) -> str | None:
      - event://slug
      - wiki://slug
 
-    Regular URLs (i.e. https://...) are returned as-is by default but may be resolved as None (i.e. no match).
+    Regular URLs (i.e. https://...) are accepted by default, too.
+    Everything else (unparseable, unknown protocl) are returned as-is by default but may be resolved as None (i.e. no match).
     """
     try:
         protocol, rhs = url.split('://', maxsplit=1)
@@ -174,6 +176,9 @@ def resolve_internal_url(url: str, fallback_as_is: bool = True) -> str | None:
     remainder = rhs_splitted[0]
     query_string = '' if len(rhs_splitted) == 1 else rhs_splitted[1]
 
+    if accept_http_https and protocol in ['http', 'https']:
+        return url
+
     try:
         from core.templatetags.hub_absolute import hub_absolute  # pylint: disable=import-outside-toplevel
 
@@ -189,6 +194,17 @@ def resolve_internal_url(url: str, fallback_as_is: bool = True) -> str | None:
         if protocol == 'wiki':
             return hub_absolute('plainui:static_page', query_string=query_string, page_slug=remainder)
 
+        if link := settings.ADDITIONAL_LINK_PROTOCOLS.get(protocol):
+            link = link.replace('VALUE', remainder)
+            if query_string:
+                result = urlparse(link)
+                if q := result.query:
+                    q += '&' + query_string
+                else:
+                    q = query_string
+                link = urlunparse((result[0], result[1], result[2], result[3], q, result[5]))
+            return link
+
     except NoReverseMatch:
         # we matched a protocol but the remainder was something bogus
         return None
@@ -197,6 +213,49 @@ def resolve_internal_url(url: str, fallback_as_is: bool = True) -> str | None:
     return url if fallback_as_is else None
 
 
+def get_dereferred_url(url: str, use_derefer_allowlist: bool = True):
+    """rewrite given URL unless it is trusted or in dereferrer-allowlist while those shall not be dereferred"""
+
+    do_derefer = True
+    if use_derefer_allowlist:
+        try:
+            scheme_and_netloc = scheme_and_netloc_from_url(url)
+            if url_in_allowlist(scheme_and_netloc, settings.DEREFERRER_GLOBAL_ALLOWLIST):
+                do_derefer = False
+        except ValueError:
+            # ignore URL parsing error
+            pass
+
+    return settings.PLAINUI_DEREFERER_URL.format(quoted_target=quote(url)) if do_derefer else url
+
+
+def is_trusted_link_destination(url: str):
+    url = urlparse(url)
+    is_local_domain = url.netloc in settings.ALLOWED_HOSTS
+    is_external = (url.scheme or url.netloc) and not is_local_domain
+    return not is_external or url.scheme not in {'http', 'https', 'ftp', 'ftps'}
+
+
+def resolve_link(url: str, use_derefer_allowlist: bool = True) -> tuple[str, str]:
+    """
+    Resolves a given URL, classifies it as internal or external and optionally rewrites it to use the dereferrer.
+    :param url: the original URL to resolve
+    :param use_derefer_allowlist: controls if the global allowlist (see settings.DEREFERRER_GLOBAL_ALLOWLIST) shall be used
+    :return: tuple with two values, the first being either 'internal' or 'external', the second being the resolved link (might be to the dereferrer)
+    """
+
+    # attempt resolving an internal URL
+    if resolved_internal_url := resolve_internal_url(url, accept_http_https=False, fallback_as_is=False):
+        url = resolved_internal_url
+
+    # derive external link (i.e. apply dereferer), if its not an internal or trusted location
+    if resolved_internal_url is None and not is_trusted_link_destination(url):
+        return 'external', get_dereferred_url(url, use_derefer_allowlist=use_derefer_allowlist)
+
+    # otherwise, it's an internal link
+    return 'internal', url
+
+
 def download_from_url(url: str) -> tuple[str, bytes]:
     # let requests library fetch the URL
     r = requests.get(url, timeout=30)
diff --git a/src/hub/settings/base.py b/src/hub/settings/base.py
index edfd10e2b2705e9be4fe4d3ebf12fe7d87044826..087ef7e96c24c12b9076da56f62d5c4532c78f00 100644
--- a/src/hub/settings/base.py
+++ b/src/hub/settings/base.py
@@ -115,6 +115,7 @@ env = environ.FileAwareEnv(
     CSP_FORM_ACTION=(list, ["'self'"]),
     CSP_BASE_URI=(list, ["'self'"]),
     CSP_INCLUDE_NONCE_IN=(list, ['script-src']),
+    ADDITIONAL_LINK_PROTOCOLS=(dict, {}),
 )
 
 
@@ -536,6 +537,12 @@ PRETIX_SECRET_KEY = env('PRETIX_SECRET')  # the JWT shared secret with Pretix
 METRICS_SERVER_IPS = env('METRICS_SERVER_IPS')
 
 
+# ----------------------------------
+# additional protocols supported by core.utils.resolve_internal_url() and thus, all markdown in the hub
+# ----------------------------------
+
+ADDITIONAL_LINK_PROTOCOLS = env.dict('ADDITIONAL_LINK_PROTOCOLS', cast={'value': str})
+
 # ----------------------------------
 # External Schedule Support
 # ----------------------------------
diff --git a/src/plainui/jinja2.py b/src/plainui/jinja2.py
index 7365c3a327e7084da0485e6ecd2c0b994b94e0ca..1094fa4df85caea4f2565d4446a33207e934cfca 100644
--- a/src/plainui/jinja2.py
+++ b/src/plainui/jinja2.py
@@ -159,6 +159,7 @@ link_icon_map = {
     'media.ccc.de': 'play-circle-fill',
     'video': 'camera-reels-fill',
     'audio': 'volume-up-fill',
+    'nav': 'map',
 }
 
 
diff --git a/src/plainui/jinja2/plainui/assembly.html.j2 b/src/plainui/jinja2/plainui/assembly.html.j2
index 81942bd6ef808f2adff41b266bf42cdb7573dd1d..c8b116d105ea99cbbce14bc41139b2cfb81795bc 100644
--- a/src/plainui/jinja2/plainui/assembly.html.j2
+++ b/src/plainui/jinja2/plainui/assembly.html.j2
@@ -47,14 +47,13 @@
         {% endif %}
 
         {% if assembly.assembly_link %}
-          {% set assembly_url = '/' + assembly.assembly_link if assembly.assembly_link.conference_internal else url('plainui:dereferrer', assembly.assembly_link) %}
           <div class="hub-tags">
             {{ tagboxMacro.tag(_("Website") ,
             icon="globe",
-            link=assembly_url,
+            link=assembly.assembly_link.resolved_url,
             style="secondary",
-            target=('_self' if assembly.assembly_link.conference_internal else '_blank'),
-            rel=('' if assembly.assembly_link.conference_internal else 'external')
+            target=('_self' if assembly.assembly_link.resolved_url_internal else '_blank'),
+            rel=('' if assembly.assembly_link.resolved_url_internal else 'external')
             ) }}
           </div>
         {% endif %}
diff --git a/src/plainui/jinja2/plainui/projects/detail.html.j2 b/src/plainui/jinja2/plainui/projects/detail.html.j2
index 461b7a2e6c38f7b280f33cd14b3db3367d2cad27..d12fc39a5aac8b03bd33d2fe07cde3cd2d8c6835 100644
--- a/src/plainui/jinja2/plainui/projects/detail.html.j2
+++ b/src/plainui/jinja2/plainui/projects/detail.html.j2
@@ -68,13 +68,12 @@
           {% if project.links %}
             <div class="hub-tags" role="list">
               {% for link in project.links.all() %}
-                {% set url = '/' + link.link if link.conference_internal else url('plainui:dereferrer', dst=link.link) %}
                 {{ tagMacros.tag(link.name,
-                                link=url,
+                                link=link.resolved_url,
                                 icon=link_icon(link) ,
                 style='secondary',
-                target=('_self' if link.conference_internal else '_blank'),
-                rel=('' if link.conference_internal else 'external, noreferrer')
+                target=('_self' if link.resolved_url_internal else '_blank'),
+                rel=('' if link.resolved_url_internal else 'external, noreferrer')
                 ) }}
               {% endfor %}
             </div>
diff --git a/src/plainui/jinja2/plainui/room.html.j2 b/src/plainui/jinja2/plainui/room.html.j2
index d37cae1bb1de2067cc59e4d85aeffbbc59f145cd..de12e4b29d711a3c6c4fdcce37a41d166b3fc63a 100644
--- a/src/plainui/jinja2/plainui/room.html.j2
+++ b/src/plainui/jinja2/plainui/room.html.j2
@@ -41,13 +41,12 @@
       {% if links %}
         <div class="hub-tags" role="list">
           {% for link in links %}
-            {% set url = '/' + link.link if link.conference_internal else url('plainui:dereferrer', dst=link.link) %}
             {{ tagMacros.tag(link.name,
-                        link=url,
+                        link=link.resolved_url,
                         icon=link_icon(link) ,
             style='secondary',
-            target=('_self' if link.conference_internal else '_blank'),
-            rel=('' if link.conference_internal else 'external, noreferrer')
+            target=('_self' if link.resolved_url_internal else '_blank'),
+            rel=('' if link.resolved_url_internal else 'external, noreferrer')
             ) }}
           {% endfor %}
         </div>
diff --git a/src/plainui/views/rooms.py b/src/plainui/views/rooms.py
index ae1b7ce7155e6b6e01183103d25fb0d183486233..0de04f39d117cdaa539c16bb96e97a33741968eb 100644
--- a/src/plainui/views/rooms.py
+++ b/src/plainui/views/rooms.py
@@ -56,7 +56,7 @@ class RoomView(ConferenceRequiredMixin, DetailView):
         for link in self.room.links.all():
             if link.link_type == RoomLink.LinkType.MEDIA_CCC_DE:
                 media_link = link.link
-            elif link.link_type in {RoomLink.LinkType.WEBSITE, RoomLink.LinkType.CHAT, RoomLink.LinkType.JITSI, RoomLink.LinkType.PAD}:
+            elif link.link_type in [RoomLink.LinkType.WEBSITE, RoomLink.LinkType.CHAT, RoomLink.LinkType.JITSI, RoomLink.LinkType.PAD, RoomLink.LinkType.NAV]:
                 linking_links.append(link)
 
         context['voc_stream'] = media_link