mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-11 00:41:02 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d618a61788 | ||
|
|
7b3daa313a | ||
|
|
936b1b3bfa | ||
|
|
e0c33b3d39 |
No files matched your search
+11
-11
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user