Compare commits

...
Author SHA1 Message Date
shamoon d618a61788 Make testing easier 2026-11-10 15:21:06 -08:00
shamoon 7b3daa313a Fix: fix allowed_hosts default 2026-11-10 07:53:53 -08:00
GitHub Actions 936b1b3bfa Auto translate strings 2026-11-09 06:53:56 +00:00
shamoon e0c33b3d39 Tweak: chat reference tweaks (#14723) 2026-11-08 22:52:34 -08:00
8 changed files with 170 additions and 36 deletions

No files matched your search

+11 -11
View File
@@ -501,20 +501,20 @@ See the [Django project documentation on the settings](https://docs.djangoprojec
#### [`PAPERLESS_ALLOWED_HOSTS=<comma-separated-list>`](#PAPERLESS_ALLOWED_HOSTS) {#PAPERLESS_ALLOWED_HOSTS} #### [`PAPERLESS_ALLOWED_HOSTS=<comma-separated-list>`](#PAPERLESS_ALLOWED_HOSTS) {#PAPERLESS_ALLOWED_HOSTS}
: If you're planning on putting Paperless on the open internet, then : If you expose Paperless to the internet, set `PAPERLESS_URL` to its public URL
you really should set this value to the domain name you're using. or list its public hostname in `PAPERLESS_ALLOWED_HOSTS` to help protect against
Failing to do so leaves you open to HTTP host header attacks. HTTP Host header attacks.
You can read more about this in [the Django project's documentation](https://docs.djangoproject.com/en/4.1/topics/security/#host-header-validation) You can read more about this in [the Django project's documentation](https://docs.djangoproject.com/en/4.1/topics/security/#host-header-validation)
Just remember that this is a comma-separated list, so Separate multiple hostnames with commas, as in `example.com,www.example.com`.
"example.com" is fine, as is "example.com,www.example.com", but
NOT " example.com" or "example.com,"
Can also be set using PAPERLESS_URL (see above). - With neither setting configured, Paperless accepts any host.
- With only `PAPERLESS_URL` set, Paperless accepts the hostname from that URL
"localhost" is always allowed for docker healthcheck and `localhost` (used by the Docker health check).
- `PAPERLESS_ALLOWED_HOSTS` adds hosts to `localhost` and the hostname from
Defaults to "\*", which is all hosts. `PAPERLESS_URL`, if set. A blank list adds no hosts.
- With `PAPERLESS_ALLOWED_HOSTS=*`, Paperless accepts any host, even when
`PAPERLESS_URL` is set.
#### [`PAPERLESS_CORS_ALLOWED_HOSTS=<comma-separated-list>`](#PAPERLESS_CORS_ALLOWED_HOSTS) {#PAPERLESS_CORS_ALLOWED_HOSTS} #### [`PAPERLESS_CORS_ALLOWED_HOSTS=<comma-separated-list>`](#PAPERLESS_CORS_ALLOWED_HOSTS) {#PAPERLESS_CORS_ALLOWED_HOSTS}
+15 -8
View File
@@ -4030,14 +4030,21 @@
<source>Sources</source> <source>Sources</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context> <context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
<context context-type="linenumber">46,47</context> <context context-type="linenumber">47,48</context>
</context-group>
</trans-unit>
<trans-unit id="6025275430560167531" datatype="html">
<source>+<x id="INTERPOLATION" equiv-text="{{ hiddenReferenceCount(message) }}"/> more</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
<context context-type="linenumber">49,50</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8066608938393600549" datatype="html"> <trans-unit id="8066608938393600549" datatype="html">
<source>Message</source> <source>Message</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context> <context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
<context context-type="linenumber">61,62</context> <context context-type="linenumber">66,67</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.html</context>
@@ -4048,22 +4055,22 @@
<source>Stop</source> <source>Stop</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context> <context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
<context context-type="linenumber">74</context> <context context-type="linenumber">79</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context> <context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
<context context-type="linenumber">74</context> <context context-type="linenumber">79</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6490688569532630280" datatype="html"> <trans-unit id="6490688569532630280" datatype="html">
<source>Send</source> <source>Send</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context> <context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
<context context-type="linenumber">78</context> <context context-type="linenumber">83</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context> <context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
<context context-type="linenumber">78</context> <context context-type="linenumber">83</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
@@ -4090,14 +4097,14 @@
<source>Ask a question about this document...</source> <source>Ask a question about this document...</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.ts</context> <context context-type="sourcefile">src/app/components/chat/chat/chat.component.ts</context>
<context context-type="linenumber">61</context> <context context-type="linenumber">83</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="224049052163028286" datatype="html"> <trans-unit id="224049052163028286" datatype="html">
<source>Ask a question about a document...</source> <source>Ask a question about a document...</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.ts</context> <context context-type="sourcefile">src/app/components/chat/chat/chat.component.ts</context>
<context context-type="linenumber">62</context> <context context-type="linenumber">84</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8700121026680200191" datatype="html"> <trans-unit id="8700121026680200191" datatype="html">
@@ -42,11 +42,16 @@
<span class="text-break">{{ message.content }}</span> <span class="text-break">{{ message.content }}</span>
} }
@if (message.role === 'assistant' && !message.isStreaming && message.references?.length) { @if (message.role === 'assistant' && !message.isStreaming && message.references?.length) {
<div class="chat-references list-group mt-3"> <div class="chat-references mt-2 pt-2 border-top">
<div class="list-group-item text-muted small py-1" i18n>Sources</div> <div class="d-flex align-items-center text-muted mb-1">
@for (reference of message.references; track reference.id) { <span i18n>Sources</span>
<a class="list-group-item list-group-item-action d-flex align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title"> @if (hiddenReferenceCount(message) > 0 && !expandedReferences().has(message)) {
<pngx-bs-icon width="0.9em" height="0.9em" name="file-text" class="me-2 flex-shrink-0"></pngx-bs-icon><span class="text-truncate">{{ reference.title }}</span> <button type="button" class="badge rounded-pill bg-body-secondary text-body-secondary fw-normal border-0 ms-2" (click)="expandReferences(message)" i18n>+{{ hiddenReferenceCount(message) }} more</button>
}
</div>
@for (reference of visibleReferences(message); track reference.id) {
<a class="reference-link d-flex align-items-center lh-sm text-primary text-decoration-none py-1" [routerLink]="['/documents', reference.id]" [title]="reference.title">
<pngx-bs-icon width="0.8em" height="0.8em" name="file-text" class="me-2 flex-shrink-0"></pngx-bs-icon><span class="text-truncate">{{ reference.title }}</span>
</a> </a>
} }
</div> </div>
@@ -12,6 +12,10 @@
display: none; display: none;
} }
.message .flex-grow-1 {
min-width: 0;
}
.dropdown-item { .dropdown-item {
white-space: initial; white-space: initial;
} }
@@ -14,6 +14,7 @@ import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe' import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
import { import {
ChatMessage, ChatMessage,
ChatReference,
ChatService, ChatService,
parseChatResponse, parseChatResponse,
} from 'src/app/services/chat.service' } from 'src/app/services/chat.service'
@@ -40,8 +41,29 @@ export class ChatComponent implements OnInit {
readonly input = signal('') readonly input = signal('')
readonly documentId = signal<number>(undefined) readonly documentId = signal<number>(undefined)
readonly slowResponse = signal(false) readonly slowResponse = signal(false)
readonly expandedReferences = signal<Set<ChatMessage>>(new Set())
public popperOptions = pngxPopperOptions public popperOptions = pngxPopperOptions
static readonly COLLAPSED_REFERENCE_COUNT = 2
public visibleReferences(message: ChatMessage): ChatReference[] {
if (this.expandedReferences().has(message)) {
return message.references
}
return message.references.slice(0, ChatComponent.COLLAPSED_REFERENCE_COUNT)
}
public hiddenReferenceCount(message: ChatMessage): number {
return Math.max(
0,
message.references.length - ChatComponent.COLLAPSED_REFERENCE_COUNT
)
}
public expandReferences(message: ChatMessage): void {
this.expandedReferences.update((expanded) => new Set(expanded).add(message))
}
private chatService: ChatService = inject(ChatService) private chatService: ChatService = inject(ChatService)
private router: Router = inject(Router) private router: Router = inject(Router)
+18 -7
View File
@@ -450,23 +450,34 @@ CORS_EXPOSE_HEADERS = [
"Content-Disposition", "Content-Disposition",
] ]
ALLOWED_HOSTS = get_list_from_env("PAPERLESS_ALLOWED_HOSTS", default=["*"])
if ALLOWED_HOSTS != ["*"]: def _get_allowed_hosts() -> list[str]:
# always allow localhost. Necessary e.g. for healthcheck in docker. if "PAPERLESS_ALLOWED_HOSTS" in os.environ or os.getenv("PAPERLESS_URL"):
ALLOWED_HOSTS.append("localhost") allowed_hosts = get_list_from_env("PAPERLESS_ALLOWED_HOSTS")
else:
allowed_hosts = ["*"]
if allowed_hosts != ["*"]:
# Always allow localhost for the Docker health check.
allowed_hosts.append("localhost")
return allowed_hosts
ALLOWED_HOSTS = _get_allowed_hosts()
def _parse_paperless_url(): def _parse_paperless_url():
global CSRF_TRUSTED_ORIGINS, CORS_ALLOWED_ORIGINS, ALLOWED_HOSTS global CSRF_TRUSTED_ORIGINS, CORS_ALLOWED_ORIGINS, ALLOWED_HOSTS
url = os.getenv("PAPERLESS_URL") url = os.getenv("PAPERLESS_URL")
if url: if url:
hostname = urlparse(url).hostname hostname = (urlparse(url).hostname or "").removesuffix(".")
if not hostname: if not hostname or hostname == "*" or hostname.startswith("."):
raise ImproperlyConfigured( raise ImproperlyConfigured(
f"PAPERLESS_URL {url!r} has no hostname, expected a URL like https://example.com", f"PAPERLESS_URL {url!r} has no valid hostname, expected a URL like https://example.com",
) )
CSRF_TRUSTED_ORIGINS.append(url) CSRF_TRUSTED_ORIGINS.append(url)
CORS_ALLOWED_ORIGINS.append(url) CORS_ALLOWED_ORIGINS.append(url)
if ":" in hostname and not hostname.startswith("["):
hostname = f"[{hostname}]" # Add brackets around IPv6 addresses
ALLOWED_HOSTS.append(hostname) ALLOWED_HOSTS.append(hostname)
return url return url
+89 -4
View File
@@ -3,9 +3,14 @@ from unittest import TestCase
from unittest import mock from unittest import mock
import pytest import pytest
from django.core.exceptions import DisallowedHost
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest
from django.test import override_settings
import paperless.settings as paperless_settings
from paperless.settings import _get_allauth_trusted_proxy_count from paperless.settings import _get_allauth_trusted_proxy_count
from paperless.settings import _get_allowed_hosts
from paperless.settings import _get_classifier_language_setting from paperless.settings import _get_classifier_language_setting
from paperless.settings import _get_search_language_setting from paperless.settings import _get_search_language_setting
from paperless.settings import _parse_paperless_url from paperless.settings import _parse_paperless_url
@@ -168,8 +173,88 @@ class TestPaperlessURLSettings(TestCase):
self.assertIn(url, settings.CORS_ALLOWED_ORIGINS) self.assertIn(url, settings.CORS_ALLOWED_ORIGINS)
@pytest.mark.parametrize("url", ["example.com", "https://", "/just/a/path"]) @pytest.mark.parametrize(
def test_paperless_url_without_hostname_is_rejected( ("url", "allowed_hosts", "expected", "accepted_host", "rejected_host"),
[
(None, None, ["*"], "other.example.com", None),
(None, "", ["localhost"], "localhost:8000", "other.example.com"),
(
None,
"lan.example.com",
["lan.example.com", "localhost"],
"lan.example.com",
"other.example.com",
),
(None, "*", ["*"], "other.example.com", None),
(
"https://example.com:8443",
None,
["localhost", "example.com"],
"example.com:8443",
"other.example.com",
),
(
"https://example.com.",
"",
["localhost", "example.com"],
"example.com.",
"other.example.com",
),
(
"http://[::1]:8000",
None,
["localhost", "[::1]"],
"[::1]:8000",
"other.example.com",
),
("https://example.com", "*", ["*", "example.com"], "other.example.com", None),
(
"https://example.com",
"lan.example.com",
["lan.example.com", "localhost", "example.com"],
"lan.example.com",
"other.example.com",
),
],
)
def test_allowed_hosts_at_startup(
monkeypatch: pytest.MonkeyPatch,
url: str | None,
allowed_hosts: str | None,
expected: list[str],
accepted_host: str,
rejected_host: str | None,
) -> None:
for name, value in (
("PAPERLESS_URL", url),
("PAPERLESS_ALLOWED_HOSTS", allowed_hosts),
):
if value is None:
monkeypatch.delenv(name, raising=False)
else:
monkeypatch.setenv(name, value)
monkeypatch.setattr(paperless_settings, "ALLOWED_HOSTS", _get_allowed_hosts())
monkeypatch.setattr(paperless_settings, "CSRF_TRUSTED_ORIGINS", [])
monkeypatch.setattr(paperless_settings, "CORS_ALLOWED_ORIGINS", [])
assert _parse_paperless_url() == url
assert expected == paperless_settings.ALLOWED_HOSTS
request = HttpRequest()
with override_settings(ALLOWED_HOSTS=expected):
request.META["HTTP_HOST"] = accepted_host
assert request.get_host() == accepted_host
if rejected_host:
request.META["HTTP_HOST"] = rejected_host
with pytest.raises(DisallowedHost):
request.get_host()
@pytest.mark.parametrize(
"url",
["example.com", "https://", "/just/a/path", "https://*", "https://.example.com"],
)
def test_paperless_url_without_valid_hostname_is_rejected(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
url: str, url: str,
) -> None: ) -> None:
@@ -179,9 +264,9 @@ def test_paperless_url_without_hostname_is_rejected(
WHEN: WHEN:
- The URL is parsed - The URL is parsed
THEN: THEN:
- A configuration error is raised instead of adding None to ALLOWED_HOSTS - A configuration error is raised for missing or wildcard hostnames
""" """
monkeypatch.setenv("PAPERLESS_URL", url) monkeypatch.setenv("PAPERLESS_URL", url)
with pytest.raises(ImproperlyConfigured, match="no hostname"): with pytest.raises(ImproperlyConfigured, match="no valid hostname"):
_parse_paperless_url() _parse_paperless_url()
+1 -1
View File
@@ -22,8 +22,8 @@ logger = logging.getLogger("paperless_ai.chat")
CHAT_METADATA_DELIMITER = "\n\n__PAPERLESS_CHAT_METADATA__" CHAT_METADATA_DELIMITER = "\n\n__PAPERLESS_CHAT_METADATA__"
CHAT_ERROR_MESSAGE = "Sorry, something went wrong while generating a response." CHAT_ERROR_MESSAGE = "Sorry, something went wrong while generating a response."
CHAT_NO_CONTENT_MESSAGE = "Sorry, I couldn't find any content to answer your question." CHAT_NO_CONTENT_MESSAGE = "Sorry, I couldn't find any content to answer your question."
MAX_CHAT_REFERENCES = 3
CHAT_RETRIEVER_TOP_K = 5 CHAT_RETRIEVER_TOP_K = 5
MAX_CHAT_REFERENCES = CHAT_RETRIEVER_TOP_K
def _build_chat_prompt(output_language: str | None) -> str: def _build_chat_prompt(output_language: str | None) -> str: