paperus 0.0.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. paperus/__init__.py +5 -0
  2. paperus/access.py +202 -0
  3. paperus/api/__init__.py +1 -0
  4. paperus/api/admin.py +364 -0
  5. paperus/api/auth.py +297 -0
  6. paperus/api/avatars.py +60 -0
  7. paperus/api/backups.py +91 -0
  8. paperus/api/citations.py +92 -0
  9. paperus/api/deps.py +77 -0
  10. paperus/api/duplicates.py +61 -0
  11. paperus/api/files.py +350 -0
  12. paperus/api/groups.py +491 -0
  13. paperus/api/health.py +22 -0
  14. paperus/api/imports.py +120 -0
  15. paperus/api/items.py +749 -0
  16. paperus/api/notes.py +423 -0
  17. paperus/api/organize.py +649 -0
  18. paperus/api/search.py +272 -0
  19. paperus/api/setup.py +120 -0
  20. paperus/api/tokens.py +101 -0
  21. paperus/api/trash.py +43 -0
  22. paperus/api/users.py +217 -0
  23. paperus/app.py +176 -0
  24. paperus/auth.py +134 -0
  25. paperus/avatars.py +44 -0
  26. paperus/backup.py +496 -0
  27. paperus/changes.py +104 -0
  28. paperus/citations.py +337 -0
  29. paperus/cli.py +275 -0
  30. paperus/config.py +59 -0
  31. paperus/db.py +18 -0
  32. paperus/duplicates.py +183 -0
  33. paperus/embedding.py +116 -0
  34. paperus/handover.py +119 -0
  35. paperus/identifiers.py +118 -0
  36. paperus/identify.py +243 -0
  37. paperus/importing/__init__.py +53 -0
  38. paperus/importing/apply.py +444 -0
  39. paperus/importing/bibtex.py +303 -0
  40. paperus/importing/common.py +67 -0
  41. paperus/importing/csljson.py +55 -0
  42. paperus/importing/ris.py +157 -0
  43. paperus/importing/run.py +205 -0
  44. paperus/importing/zotero.py +441 -0
  45. paperus/integrity.py +123 -0
  46. paperus/jobs.py +480 -0
  47. paperus/library.py +247 -0
  48. paperus/mcp_server.py +914 -0
  49. paperus/merge.py +296 -0
  50. paperus/metadata.py +110 -0
  51. paperus/metrics.py +228 -0
  52. paperus/migrate.py +23 -0
  53. paperus/migrations/env.py +53 -0
  54. paperus/migrations/procrastinate_3.10.0.sql +599 -0
  55. paperus/migrations/script.py.mako +20 -0
  56. paperus/migrations/versions/0001_initial_schema.py +294 -0
  57. paperus/migrations/versions/0002_add_system_settings.py +32 -0
  58. paperus/migrations/versions/0003_mark_reference_lists.py +28 -0
  59. paperus/migrations/versions/0004_add_tags_and_collections.py +115 -0
  60. paperus/migrations/versions/0005_add_tag_details.py +22 -0
  61. paperus/migrations/versions/0006_add_groups_and_libraries.py +149 -0
  62. paperus/migrations/versions/0007_items_in_several_libraries.py +49 -0
  63. paperus/migrations/versions/0008_one_owner_per_item.py +53 -0
  64. paperus/migrations/versions/0009_add_seen_hints.py +29 -0
  65. paperus/migrations/versions/0010_recent_activity.py +44 -0
  66. paperus/migrations/versions/0011_user_login_and_deactivation.py +26 -0
  67. paperus/migrations/versions/0012_add_access_tokens.py +45 -0
  68. paperus/migrations/versions/0013_add_notes.py +150 -0
  69. paperus/migrations/versions/0014_add_imports.py +47 -0
  70. paperus/migrations/versions/0015_keep_embedding_model.py +34 -0
  71. paperus/migrations/versions/0016_add_not_duplicates.py +39 -0
  72. paperus/migrations/versions/0017_add_favourites_and_pinned_collections.py +63 -0
  73. paperus/migrations/versions/0018_add_oidc_subject.py +22 -0
  74. paperus/migrations/versions/0019_add_avatars.py +24 -0
  75. paperus/ml.py +205 -0
  76. paperus/models.py +769 -0
  77. paperus/notes.py +111 -0
  78. paperus/oidc.py +296 -0
  79. paperus/openapi.py +11 -0
  80. paperus/organize.py +158 -0
  81. paperus/processing.py +161 -0
  82. paperus/py.typed +0 -0
  83. paperus/readable.py +337 -0
  84. paperus/storage.py +235 -0
  85. paperus/system.py +217 -0
  86. paperus/text.py +134 -0
  87. paperus/tokens.py +151 -0
  88. paperus/trash.py +121 -0
  89. paperus/usage.py +89 -0
  90. paperus/web.py +25 -0
  91. paperus-0.0.8.dist-info/METADATA +57 -0
  92. paperus-0.0.8.dist-info/RECORD +94 -0
  93. paperus-0.0.8.dist-info/WHEEL +4 -0
  94. paperus-0.0.8.dist-info/entry_points.txt +3 -0
paperus/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """paperus: a self-hosted research library for groups."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("paperus")
paperus/access.py ADDED
@@ -0,0 +1,202 @@
1
+ """Who may read and change which library.
2
+
3
+ A person reads their own library and those of the groups they belong to,
4
+ including every group inside those (membership is inherited downwards, and a
5
+ subgroup can only raise a role). Site administrators manage users and groups
6
+ but read no library because of it. All of this is worked out once per
7
+ request (``Access.of``) from the group tree, which is small, and the person's
8
+ memberships; lists and searches then filter by library ID in SQL.
9
+ """
10
+
11
+ import uuid
12
+ from dataclasses import dataclass, field
13
+
14
+ from sqlalchemy import ColumnElement, select
15
+ from sqlalchemy.orm import Session
16
+
17
+ from paperus.models import (
18
+ ROLE_RANK,
19
+ Group,
20
+ Item,
21
+ Library,
22
+ Membership,
23
+ Note,
24
+ Role,
25
+ User,
26
+ )
27
+
28
+
29
+ class Forbidden(Exception):
30
+ """The person may see this but not change it (HTTP 403)."""
31
+
32
+
33
+ class NotVisible(Exception):
34
+ """The person may not see this; answered like a missing object (404)."""
35
+
36
+
37
+ @dataclass
38
+ class Access:
39
+ user: User
40
+ own_library: uuid.UUID
41
+ roles: dict[uuid.UUID, Role] = field(default_factory=dict)
42
+ """Effective role per group (direct or inherited)."""
43
+ group_libraries: dict[uuid.UUID, uuid.UUID] = field(default_factory=dict)
44
+ """Library of each group the person belongs to."""
45
+ own_allowed: bool = True
46
+ """False for an access token limited to some groups' libraries."""
47
+ site_admin: bool = False
48
+ """A site administrator acting in person (never through a token, which
49
+ gets no administration)."""
50
+
51
+ @classmethod
52
+ def of(
53
+ cls,
54
+ db: Session,
55
+ user: User,
56
+ only: list[uuid.UUID] | None = None,
57
+ *,
58
+ token: bool = False,
59
+ ) -> "Access":
60
+ """What ``user`` may do; with ``only`` (an access token's owner limit),
61
+ nothing outside these libraries; ``token``: through an access token."""
62
+ own = db.scalar(select(Library.id).where(Library.user_id == user.id))
63
+ if own is None: # users from before libraries existed get one now
64
+ library = Library(user_id=user.id)
65
+ db.add(library)
66
+ db.flush()
67
+ own = library.id
68
+ parents: dict[uuid.UUID, uuid.UUID | None] = {
69
+ group_id: parent
70
+ for group_id, parent in db.execute(select(Group.id, Group.parent_id))
71
+ }
72
+ direct = {
73
+ group_id: Role(role)
74
+ for group_id, role in db.execute(
75
+ select(Membership.group_id, Membership.role).where(
76
+ Membership.user_id == user.id
77
+ )
78
+ )
79
+ }
80
+ roles: dict[uuid.UUID, Role] = {}
81
+ for group_id in parents:
82
+ # The highest role on the way up to the top.
83
+ best: Role | None = None
84
+ current: uuid.UUID | None = group_id
85
+ while current is not None:
86
+ role = direct.get(current)
87
+ if role and (best is None or ROLE_RANK[role] > ROLE_RANK[best]):
88
+ best = role
89
+ current = parents.get(current)
90
+ if best is not None:
91
+ roles[group_id] = best
92
+ libraries = {
93
+ group_id: library_id
94
+ for group_id, library_id in db.execute(
95
+ select(Library.group_id, Library.id).where(Library.group_id.in_(roles))
96
+ )
97
+ if group_id is not None
98
+ }
99
+ site_admin = user.is_admin and not token
100
+ if only is None:
101
+ return cls(user, own, roles, libraries, site_admin=site_admin)
102
+ allowed = set(only)
103
+ libraries = {g: lib for g, lib in libraries.items() if lib in allowed}
104
+ roles = {g: r for g, r in roles.items() if g in libraries}
105
+ return cls(
106
+ user,
107
+ own,
108
+ roles,
109
+ libraries,
110
+ own_allowed=own in allowed,
111
+ site_admin=site_admin,
112
+ )
113
+
114
+ @property
115
+ def readable(self) -> list[uuid.UUID]:
116
+ """Libraries this person may read: their own and their groups'."""
117
+ own = [self.own_library] if self.own_allowed else []
118
+ return [*own, *self.group_libraries.values()]
119
+
120
+ def role_in_library(self, library_id: uuid.UUID) -> Role | None:
121
+ for group_id, group_library in self.group_libraries.items():
122
+ if group_library == library_id:
123
+ return self.roles[group_id]
124
+ return None
125
+
126
+ def can_read(self, library_id: uuid.UUID) -> bool:
127
+ """The person's own library (unless a token is limited to others) or
128
+ one of their groups' (only those a limited token allows)."""
129
+ if library_id == self.own_library:
130
+ return self.own_allowed
131
+ return library_id in self.group_libraries.values()
132
+
133
+ def can_write(self, library_id: uuid.UUID) -> bool:
134
+ """Add, change and delete items, tags and collections."""
135
+ if library_id == self.own_library:
136
+ return self.own_allowed
137
+ role = self.role_in_library(library_id)
138
+ return role is not None and ROLE_RANK[role] >= ROLE_RANK[Role.MEMBER]
139
+
140
+ @property
141
+ def writable(self) -> list[uuid.UUID]:
142
+ return [library for library in self.readable if self.can_write(library)]
143
+
144
+ def is_group_admin(self, group_id: uuid.UUID) -> bool:
145
+ return self.roles.get(group_id) == Role.ADMIN
146
+
147
+ def require_write(self, library_id: uuid.UUID) -> None:
148
+ if not self.can_write(library_id):
149
+ raise Forbidden("readers cannot change this library")
150
+
151
+ # Items have one owner (``Item.library_id``): a person or a group.
152
+
153
+ def can_read_item(self, item: Item) -> bool:
154
+ return self.can_read(item.library_id)
155
+
156
+ def can_write_item(self, item: Item) -> bool:
157
+ return self.can_write(item.library_id)
158
+
159
+ def require_write_item(self, item: Item) -> None:
160
+ if not self.can_write_item(item):
161
+ raise Forbidden("readers cannot change this item")
162
+
163
+ def item_filter(
164
+ self, libraries: list[uuid.UUID] | None = None
165
+ ) -> ColumnElement[bool]:
166
+ """Items owned by these (by default: everything the person reads)."""
167
+ return Item.library_id.in_(self.readable if libraries is None else libraries)
168
+
169
+ def may_tag(self, library_id: uuid.UUID, item: Item) -> bool:
170
+ """Whether the person may put a tag of ``library_id`` on (or take it
171
+ off) the item: their private tags on any item they see; a group's
172
+ tags (they read) on any item they may change, e.g. their own papers
173
+ with the group's vocabulary."""
174
+ if library_id == self.own_library:
175
+ return self.own_allowed and self.can_read_item(item)
176
+ return self.can_read(library_id) and self.can_write_item(item)
177
+
178
+ def may_hold(self, library_id: uuid.UUID, item: Item | Note) -> bool:
179
+ """Whether a collection of ``library_id`` may hold the item (or note):
180
+ a group's only the group's own, a person's private ones anything the
181
+ person can read."""
182
+ if library_id == self.own_library:
183
+ return self.own_allowed and self.can_read(item.library_id)
184
+ return library_id == item.library_id
185
+
186
+ def target_library(self, requested: uuid.UUID | None) -> uuid.UUID:
187
+ """Where something new goes: the library asked for (if the person may
188
+ add to it), else their default library, else their own."""
189
+ if requested is not None:
190
+ if not self.can_read(requested):
191
+ raise NotVisible("library not found")
192
+ self.require_write(requested)
193
+ return requested
194
+ default = self.user.default_library_id
195
+ if default is not None and self.can_write(default):
196
+ return default
197
+ if self.own_allowed:
198
+ return self.own_library
199
+ writable = self.writable
200
+ if not writable:
201
+ raise Forbidden("this token may not add anything")
202
+ return writable[0]
@@ -0,0 +1 @@
1
+ """HTTP API routes."""
paperus/api/admin.py ADDED
@@ -0,0 +1,364 @@
1
+ """Admin page: settings, the ML service, progress and jobs."""
2
+
3
+ from datetime import UTC, datetime
4
+ from typing import Annotated, Any
5
+
6
+ from fastapi import APIRouter, Depends, HTTPException, Request
7
+ from pydantic import BaseModel, Field
8
+ from sqlalchemy import func, select
9
+
10
+ from paperus import auth, integrity, metrics, oidc, readable, system, usage
11
+ from paperus.api.auth import OIDC_CALLBACK, public_base
12
+ from paperus.api.deps import DB, CurrentUser, request_token
13
+ from paperus.jobs import (
14
+ queue_embedding_sweep,
15
+ queue_empty_trash,
16
+ queue_integrity_check,
17
+ queue_ml_configuration,
18
+ queue_readable_copy,
19
+ queue_text_sweep,
20
+ )
21
+ from paperus.ml import MLClient, MLLoading, MLRejected, MLUnavailable
22
+ from paperus.models import LoginSession, User
23
+ from paperus.processing import reread_all
24
+
25
+ router = APIRouter(prefix="/api/admin", tags=["admin"])
26
+
27
+
28
+ def current_admin(user: CurrentUser) -> User:
29
+ if not user.is_admin:
30
+ raise HTTPException(403, "admins only")
31
+ return user
32
+
33
+
34
+ Admin = Annotated[User, Depends(current_admin)]
35
+
36
+
37
+ class MLService(BaseModel):
38
+ url: str
39
+ ok: bool
40
+ error: str | None
41
+
42
+
43
+ class MLModel(BaseModel):
44
+ name: str
45
+ task: str
46
+ dimensions: int
47
+ max_tokens: int
48
+ loaded: bool
49
+ cached_bytes: int | None
50
+ # For choosing (older ML services do not send these).
51
+ description: str = ""
52
+ licence: str = ""
53
+ download_mb: int = 0
54
+ scores: dict[str, float] = {}
55
+ """Published retrieval scores (nDCG@10, out of 100), by benchmark."""
56
+ benchmarks: dict[str, str] = {}
57
+ """What the keys of ``scores`` mean."""
58
+
59
+
60
+ class MLSpeed(BaseModel):
61
+ model: str
62
+ loading: bool = False
63
+ """The model is still downloading or loading: ask again in a while."""
64
+ passages: int = 0
65
+ seconds: float = 0
66
+ passages_per_second: float = 0
67
+ url: str | None = None
68
+ """The ML service that measured it."""
69
+
70
+
71
+ class MLServiceState(BaseModel):
72
+ url: str
73
+ threads: int
74
+ keep: list[str]
75
+ cores: int
76
+ """CPU cores this ML service may use."""
77
+ loaded: list[str]
78
+ loading: list[str] = []
79
+
80
+
81
+ class MLStatus(BaseModel):
82
+ services: list[MLService]
83
+ models: list[MLModel]
84
+ """Empty if no service answers."""
85
+ service: MLServiceState | None = None
86
+ """The first service's settings and cores (None for older services)."""
87
+
88
+
89
+ class Stats(BaseModel):
90
+ items: int
91
+ files: int
92
+ files_pending: int
93
+ """Files whose text (and, for uploads, metadata) is still being read."""
94
+ chunks: int
95
+ """Passages searchable by meaning (reference lists are left out)."""
96
+ embedded: int
97
+ """Chunks with a vector from the current embedding model."""
98
+ embedding_model: str
99
+ jobs: dict[str, dict[str, int]]
100
+ """Background jobs per queue and status (todo, doing, failed, …)."""
101
+ storage: usage.Usage
102
+ """Space the library and the backups take, and what is free."""
103
+
104
+
105
+ def _ml(request: Request) -> MLClient:
106
+ ml: MLClient = request.app.state.ml
107
+ return ml
108
+
109
+
110
+ def _models(ml: MLClient) -> list[MLModel]:
111
+ return [MLModel.model_validate(m) for m in ml.models()]
112
+
113
+
114
+ @router.get("/config")
115
+ def get_config(request: Request, db: DB, admin: Admin) -> system.SystemConfig:
116
+ return system.load_config(db, request.app.state.settings)
117
+
118
+
119
+ @router.put("/config")
120
+ def set_config(
121
+ body: system.SystemConfig, request: Request, db: DB, admin: Admin
122
+ ) -> system.SystemConfig:
123
+ """Save settings. A new embedding model must be one the ML service
124
+ offers; the library is then re-embedded in the background, while keyword
125
+ search keeps working and search by meaning covers more and more."""
126
+ current = system.load_config(db, request.app.state.settings)
127
+ model_changed = body.embedding_model != current.embedding_model
128
+ if model_changed:
129
+ try:
130
+ known = {m.name for m in _models(_ml(request)) if m.task == "embedding"}
131
+ except (MLUnavailable, MLRejected) as error:
132
+ raise HTTPException(
133
+ 503, "ML service unreachable; cannot check the model"
134
+ ) from error
135
+ if body.embedding_model not in known:
136
+ raise HTTPException(
137
+ 422, f"the ML service has no model {body.embedding_model!r}"
138
+ )
139
+ system.save_config(db, body)
140
+ db.commit()
141
+ system.apply(body, request.app.state.http)
142
+ if model_changed:
143
+ queue_embedding_sweep(request.app.state.jobs)
144
+ copy = ("copy_enabled", "copy_template", "copy_links")
145
+ if body.copy_enabled and any(
146
+ getattr(body, name) != getattr(current, name) for name in copy
147
+ ):
148
+ queue_readable_copy(request.app.state.jobs)
149
+ if model_changed or body.ml_threads != current.ml_threads:
150
+ # The worker sends it at once (first in its queue), and every minute.
151
+ queue_ml_configuration(request.app.state.jobs)
152
+ return body
153
+
154
+
155
+ @router.get("/ml")
156
+ def ml_status(request: Request, admin: Admin) -> MLStatus:
157
+ ml = _ml(request)
158
+ services = [MLService(url=u, ok=e is None, error=e) for u, e in ml.status()]
159
+ try:
160
+ models = _models(ml)
161
+ except (MLUnavailable, MLRejected):
162
+ models = []
163
+ try:
164
+ service = MLServiceState.model_validate(ml.service_config())
165
+ except (MLUnavailable, MLRejected, ValueError):
166
+ service = None
167
+ return MLStatus(services=services, models=models, service=service)
168
+
169
+
170
+ @router.post("/ml/models/{name}/speed")
171
+ def measure_ml_speed(name: str, request: Request, admin: Admin) -> MLSpeed:
172
+ """Time a model on this installation's ML service with sample passages.
173
+ A model not downloaded yet starts downloading: the answer then says
174
+ ``loading``; ask again in a while."""
175
+ try:
176
+ return MLSpeed.model_validate(_ml(request).measure_speed(name))
177
+ except MLLoading:
178
+ return MLSpeed(model=name, loading=True)
179
+ except MLRejected as error:
180
+ raise HTTPException(
181
+ 404 if error.status == 404 else 502, error.detail
182
+ ) from error
183
+ except MLUnavailable as error:
184
+ raise HTTPException(503, f"ML service unreachable: {error}") from error
185
+
186
+
187
+ @router.delete("/ml/models/{name}", status_code=204)
188
+ def delete_ml_model(name: str, request: Request, db: DB, admin: Admin) -> None:
189
+ """Remove a downloaded model from the ML service (not the one in use)."""
190
+ if name == system.load_config(db, request.app.state.settings).embedding_model:
191
+ raise HTTPException(409, "this model is in use")
192
+ try:
193
+ _ml(request).delete_model(name)
194
+ except MLRejected as error:
195
+ raise HTTPException(404, error.detail) from error
196
+ except MLUnavailable as error:
197
+ raise HTTPException(503, "ML service unreachable") from error
198
+
199
+
200
+ @router.get("/stats")
201
+ def stats(request: Request, db: DB, admin: Admin) -> Stats:
202
+ figures = metrics.library_figures(db, request.app.state.settings)
203
+ return Stats(
204
+ items=figures.items,
205
+ files=figures.files,
206
+ files_pending=figures.files_pending,
207
+ chunks=figures.chunks,
208
+ embedded=figures.embedded,
209
+ embedding_model=figures.embedding_model,
210
+ jobs=figures.jobs,
211
+ storage=usage.usage(db, request.app.state.settings),
212
+ )
213
+
214
+
215
+ @router.post("/jobs/{job}", status_code=202)
216
+ def run_job(job: str, request: Request, db: DB, admin: Admin) -> dict[str, Any]:
217
+ """Start a sweep now: ``embeddings`` (embed what is missing), ``text``
218
+ (read files still pending), ``text-all`` (read every file again),
219
+ ``trash`` (delete what is older than the trash days), ``integrity``
220
+ (check stored contents), ``integrity-deep`` (also their SHA-256) or
221
+ ``integrity-clean`` (check and remove unused stored contents), ``copy``
222
+ (update the readable copy) or ``copy-remove`` (delete it)."""
223
+ jobs = request.app.state.jobs
224
+ sweeps = {
225
+ "embeddings": queue_embedding_sweep,
226
+ "text": queue_text_sweep,
227
+ "trash": queue_empty_trash,
228
+ "integrity": queue_integrity_check,
229
+ "integrity-deep": lambda app: queue_integrity_check(app, deep=True),
230
+ "integrity-clean": lambda app: queue_integrity_check(app, remove_unused=True),
231
+ "copy": queue_readable_copy,
232
+ "copy-remove": lambda app: queue_readable_copy(app, remove=True),
233
+ }
234
+ if job == "text-all":
235
+ count = reread_all(db)
236
+ queue_text_sweep(request.app.state.jobs)
237
+ return {"queued": job, "files": count}
238
+ if job not in sweeps:
239
+ raise HTTPException(404, f"unknown job {job!r}")
240
+ sweeps[job](jobs)
241
+ return {"queued": job}
242
+
243
+
244
+ @router.get("/integrity")
245
+ def integrity_report(db: DB, admin: Admin) -> integrity.Report | None:
246
+ """The last integrity check's report (None before the first)."""
247
+ return integrity.last_report(db)
248
+
249
+
250
+ class Authentication(BaseModel):
251
+ redirect_uri: str
252
+ """To register at the identity provider."""
253
+ secret_set: bool
254
+ secret_from_environment: bool
255
+ """Set with ``PAPERUS_OIDC_CLIENT_SECRET`` (then not changeable here)."""
256
+ linked: int
257
+ """Accounts that have logged in with the OIDC login."""
258
+ sessions: int
259
+ """Devices logged in now."""
260
+
261
+
262
+ @router.get("/authentication")
263
+ def authentication(request: Request, db: DB, admin: Admin) -> Authentication:
264
+ """What the Authentication settings show besides the settings."""
265
+ settings = request.app.state.settings
266
+ config = system.load_config(db, settings)
267
+ now = datetime.now(UTC)
268
+ return Authentication(
269
+ redirect_uri=public_base(request, config) + OIDC_CALLBACK,
270
+ secret_set=system.oidc_secret(db, settings) is not None,
271
+ secret_from_environment=bool(settings.oidc_client_secret),
272
+ linked=db.scalar(select(func.count()).where(User.oidc_subject.is_not(None)))
273
+ or 0,
274
+ sessions=db.scalar(select(func.count()).where(LoginSession.expires_at > now))
275
+ or 0,
276
+ )
277
+
278
+
279
+ class Secret(BaseModel):
280
+ secret: str | None = Field(None, max_length=1000)
281
+ """None or empty: no secret (a public client)."""
282
+
283
+
284
+ @router.put("/authentication/secret", status_code=204)
285
+ def set_oidc_secret(body: Secret, db: DB, admin: Admin) -> None:
286
+ """The OIDC login's client secret; never sent back."""
287
+ system.save_oidc_secret(db, body.secret or None)
288
+ db.commit()
289
+
290
+
291
+ class ProviderCheck(BaseModel):
292
+ issuer: str = Field(max_length=500)
293
+
294
+
295
+ class ProviderFound(BaseModel):
296
+ ok: bool
297
+ error: str | None = None
298
+ authorization_endpoint: str | None = None
299
+
300
+
301
+ @router.post("/authentication/check")
302
+ def check_provider(
303
+ body: ProviderCheck, request: Request, admin: Admin
304
+ ) -> ProviderFound:
305
+ """Whether the identity provider answers at this address."""
306
+ try:
307
+ provider = oidc.discover(request.app.state.http, body.issuer, fresh=True)
308
+ except oidc.LoginFailed as error:
309
+ return ProviderFound(ok=False, error=str(error))
310
+ return ProviderFound(
311
+ ok=True, authorization_endpoint=provider.authorization_endpoint
312
+ )
313
+
314
+
315
+ @router.delete("/sessions")
316
+ def end_all_sessions(request: Request, db: DB, admin: Admin) -> dict[str, int]:
317
+ """Log everyone out on every device, except this one."""
318
+ ended = auth.end_all_sessions(db, request_token(request) or "")
319
+ db.commit()
320
+ return {"ended": ended}
321
+
322
+
323
+ class ReadableCopy(BaseModel):
324
+ path: str
325
+ """The folder on the server (``PAPERUS_COPY_PATH``)."""
326
+ free_bytes: int | None
327
+ report: readable.CopyReport | None
328
+ """The last update."""
329
+ variables: dict[str, str]
330
+ links_possible: bool
331
+ """Hard links work only with files stored on a disk."""
332
+
333
+
334
+ @router.get("/readable-copy")
335
+ def readable_copy(request: Request, db: DB, admin: Admin) -> ReadableCopy:
336
+ settings = request.app.state.settings
337
+ root = settings.copy_path.resolve()
338
+ return ReadableCopy(
339
+ path=str(root),
340
+ free_bytes=readable.free_space(root),
341
+ report=readable.last_report(db),
342
+ variables=readable.VARIABLES,
343
+ links_possible=settings.storage_backend == "filesystem",
344
+ )
345
+
346
+
347
+ class TemplateIn(BaseModel):
348
+ template: str = Field(max_length=300)
349
+
350
+
351
+ class TemplatePreview(BaseModel):
352
+ paths: list[str]
353
+ """Where some of the library's files would go."""
354
+ error: str | None = None
355
+
356
+
357
+ @router.post("/readable-copy/preview")
358
+ def preview_template(body: TemplateIn, db: DB, admin: Admin) -> TemplatePreview:
359
+ try:
360
+ template = readable.check_template(body.template)
361
+ except readable.TemplateError as error:
362
+ return TemplatePreview(paths=[], error=str(error))
363
+ found = readable.wanted(db, template, limit=40)
364
+ return TemplatePreview(paths=[w.path for w in found[:8]])