Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31fa554b87 | ||
|
|
6a0efba477 | ||
|
|
9f28f46839 | ||
|
|
14f1c97c0a | ||
|
|
fc56f514b3 | ||
|
|
358d722ce9 | ||
|
|
fe38dfbcca | ||
|
|
f98b060e2b | ||
|
|
bc9e34d0ae | ||
|
|
0cc450d1c9 | ||
|
|
688efb9ed6 | ||
|
|
ca4e559cc7 | ||
|
|
e23128b4a1 | ||
|
|
0d5f8f0708 | ||
|
|
a03bf8d371 | ||
|
|
3dc14402b0 | ||
|
|
540ca244c8 | ||
|
|
563fa0513d | ||
|
|
10c0ca4cf7 | ||
|
|
a76bab9b34 | ||
|
|
7acdf5d577 | ||
|
|
b1086e05dc | ||
|
|
ed91c83dd7 | ||
|
|
a614ee2435 | ||
|
|
c74487fbb3 | ||
|
|
876cece881 | ||
|
|
e5997ca567 | ||
|
|
c134b514c1 | ||
|
|
243869401a | ||
|
|
7339a0b84b | ||
|
|
c960c07a6a | ||
|
|
3cfdb36f06 | ||
|
|
ada95d04a4 | ||
|
|
1b004e6ccf | ||
|
|
95c33e0b0f | ||
|
|
4e6bcbc078 | ||
|
|
e17fb5bbf5 | ||
|
|
a9044ece50 | ||
|
|
473aafd1c8 | ||
|
|
7019e3afdf | ||
|
|
c81b110637 | ||
|
|
2ecc70ca6d | ||
|
|
f8c63e80c2 | ||
|
|
3ac3d382e7 | ||
|
|
94c62ca699 | ||
|
|
1fe24d374a | ||
|
|
2cba6b9fca | ||
|
|
8d7aaad9a8 | ||
|
|
85043013f0 | ||
|
|
44f626f732 | ||
|
|
24c93cbb83 | ||
|
|
cb1f4f00b0 | ||
|
|
f6e1d1e69f | ||
|
|
b64c3f514d | ||
|
|
b5a1002412 | ||
|
|
ea931dca30 | ||
|
|
d954e71a77 | ||
|
|
602f62967a | ||
|
|
ba00b50dea | ||
|
|
92adb593c8 | ||
|
|
66132b6dda | ||
|
|
d09f021f0c | ||
|
|
d7e9240e54 | ||
|
|
f2fa49b778 | ||
|
|
d4ed45b2eb | ||
|
|
375f6e58be | ||
|
|
c1a3b7b935 | ||
|
|
bf98b48517 | ||
|
|
b563155a37 | ||
|
|
915319fa35 | ||
|
|
a2a8f5681d | ||
|
|
4827aebb7f | ||
|
|
840526b1b8 | ||
|
|
10d69cb316 |
+1789
File diff suppressed because it is too large
Load Diff
+13
@@ -60,6 +60,19 @@ RUN apt-get update \
|
||||
&& rm /tmp/deno.zip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Intel NEO OpenCL runtime (intel-opencl-icd) — needed by the Plex transcode's HDR/10-bit HW path, which
|
||||
# tone-maps HDR→SDR on the GPU via `tonemap_opencl` (Gen 9.5 / UHD 630 can't tonemap in the VAAPI VPP, so
|
||||
# OpenCL is the only HW route; without this the 10-bit branch drops to CPU libx264). NEO was REMOVED from
|
||||
# Debian trixie (this base = python:3.12-slim = trixie), so it's pulled from **bookworm** — the 22.43 there
|
||||
# still supports Gen 9.5 and its .deb runs on trixie (verified live on the prod UHD 630). The bookworm apt
|
||||
# source is added only for this one install and removed after, so it can't drift the rest of the image.
|
||||
# Harmless on hosts with no /dev/dri or no OpenCL use (the runtime just sits unused).
|
||||
RUN printf 'Types: deb\nURIs: http://deb.debian.org/debian\nSuites: bookworm\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.pgp\n' > /etc/apt/sources.list.d/bookworm.sources \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends -t bookworm intel-opencl-icd \
|
||||
&& rm /etc/apt/sources.list.d/bookworm.sources \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install from the PINNED lock (requirements.lock, generated from requirements.txt) so the image is
|
||||
# byte-reproducible and the exact versions are the ones pip-audit scanned in CI — not whatever floats
|
||||
# up from the floor ranges on build day. Regenerate the lock when requirements.txt changes (see its header).
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -94,9 +94,18 @@ docker compose -f docker-compose.selfhost.yml pull # or: docker compose pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
Database migrations run automatically on startup. Your data (accounts, subscriptions, playlists, the
|
||||
video catalog) lives in a Postgres volume — back it up with `scripts/backup.sh` (or `backup.ps1` on
|
||||
Windows) and restore with `scripts/restore.sh`.
|
||||
That follows `latest`, which moves with every release. Set `IMAGE_TAG=0.80.0` in `.env` to pin a
|
||||
version and upgrade on your own schedule; released versions are listed in [`CHANGELOG.md`](CHANGELOG.md)
|
||||
and in the app's Release Notes. The registry keeps only the newest few of those, so a pin decides
|
||||
*when* you take the next hop rather than letting you stay put forever — see
|
||||
[docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||
|
||||
**Back up before upgrading.** Migrations run automatically on startup and are not undone by pulling
|
||||
the old image back. A backup is the database dump **plus your `.env`** — `TOKEN_ENCRYPTION_KEY` is
|
||||
what decrypts the Google, SMTP and Plex secrets stored in that database, so a dump restored without it
|
||||
is only half an instance. Use `scripts/backup.sh` (or `backup.ps1` on Windows) and `scripts/restore.sh`;
|
||||
the equivalent one-liners, the pinning recipe and the rollback steps are in
|
||||
[docs/self-hosting.md](docs/self-hosting.md#backups).
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -111,6 +120,12 @@ Windows) and restore with `scripts/restore.sh`.
|
||||
FastAPI + PostgreSQL (SQLAlchemy, Alembic) backend; React + Vite + Tailwind + TanStack Query
|
||||
frontend; packaged as a single Docker image with Docker Compose.
|
||||
|
||||
## License
|
||||
|
||||
[GNU AGPL-3.0](LICENSE). You can run, study, modify and share Siftlode; if you modify it and let other
|
||||
people use it over a network, they are entitled to your modified source. Copyright © 2026 Siftlode
|
||||
contributors.
|
||||
|
||||
## Note
|
||||
|
||||
This project is developed with AI assistance.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""plex_items playback-class facet (E7: filter the feed by Direct Play / Direct Stream / Transcode)
|
||||
|
||||
Adds the columns the playback-class filter needs:
|
||||
* transcode_hw — for a `playable="transcode"` item, whether the prod iGPU can hardware-transcode it
|
||||
(codec HW-decodable AND 8-bit SDR) vs software-only. NULL for non-transcode items.
|
||||
* video_bit_depth — the source video bit depth (from the Plex media Stream), NULL until probed.
|
||||
* media_probed_at — watermark for the bit-depth backfill (NULL = not yet probed).
|
||||
|
||||
Backfill for existing rows: `transcode_hw` is derived from the ALREADY-stored `codec_video` alone (the
|
||||
same HW-decodable allow-list `plex.sync.HW_DECODABLE_CODECS` / `plex.stream` use), so the filter works
|
||||
immediately after migration. Bit depth is unknown for existing rows (the cheap Plex listing doesn't
|
||||
carry it), so a 10-bit/HDR HEVC is provisionally 'hw' here and later refined to 'sw' by the bit-depth
|
||||
backfill (`plex.sync._probe_media_batch`) once its Stream is probed. `video_bit_depth`/`media_probed_at`
|
||||
start NULL.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0077_plex_playback_class"
|
||||
down_revision: Union[str, None] = "0076_media_asset_kind"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("plex_items", sa.Column("transcode_hw", sa.Boolean(), nullable=True))
|
||||
op.add_column("plex_items", sa.Column("video_bit_depth", sa.Integer(), nullable=True))
|
||||
op.add_column(
|
||||
"plex_items", sa.Column("media_probed_at", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
op.create_index("ix_plex_items_transcode_hw", "plex_items", ["transcode_hw"])
|
||||
# Codec-only derivation for existing rows — keep this codec set identical to
|
||||
# plex.sync.HW_DECODABLE_CODECS. Bit depth is unknown here, so provisionally 8-bit (→ hw for a
|
||||
# decodable codec); the bit-depth backfill downgrades 10-bit/HDR HEVC to sw later.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE plex_items
|
||||
SET transcode_hw = CASE
|
||||
WHEN playable = 'transcode'
|
||||
THEN lower(codec_video) IN ('h264','hevc','h265','vp9','vc1','mpeg2video')
|
||||
ELSE NULL
|
||||
END
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_plex_items_transcode_hw", table_name="plex_items")
|
||||
op.drop_column("plex_items", "media_probed_at")
|
||||
op.drop_column("plex_items", "video_bit_depth")
|
||||
op.drop_column("plex_items", "transcode_hw")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""users.password_revoked_at — remember that SEC-10 dropped a preset password
|
||||
|
||||
Revision ID: 0078_password_revoked_at
|
||||
Revises: 0077_plex_playback_class
|
||||
Create Date: 2026-09-02
|
||||
|
||||
SEC-10 (`auth.revoke_preset_password_if_unverified`) nulls `password_hash` when an account whose email
|
||||
was never verified is attested (Google adoption, or admin approval). That left NO trace, and
|
||||
password-reset eligibility keyed off `password_hash IS NOT NULL` — so such an account could neither
|
||||
sign in (no password) nor request a reset link (not "eligible"): a permanent lockout with a silent,
|
||||
uniform response at every step.
|
||||
|
||||
This column is that trace. A row with `password_revoked_at` set is a local account that HAD a password
|
||||
and had it revoked, so the reset flow treats it as eligible again. Setting a password (reset-confirm or
|
||||
/auth/set-password) clears the stamp. A Google-only account that never had a password keeps it NULL and
|
||||
stays reset-ineligible by design — its owner adds a password from Settings while signed in.
|
||||
|
||||
Backfill: the signature is exact and narrow — a local (google_sub IS NULL), non-demo account with NO
|
||||
password could ONLY have got there through the revoke. Registration always sets a hash, the setup
|
||||
wizard sets one too, every Google row carries a google_sub (the adoption path assigns it BEFORE
|
||||
revoking), and nothing in the codebase ever clears google_sub. Stamp those with the current time so
|
||||
they become recoverable without operator intervention.
|
||||
|
||||
Deliberately NOT also requiring `email_verified = false`, even though the revoke only fires on an
|
||||
unverified account: verifying is exactly what such a victim does NEXT. The revoke does not invalidate
|
||||
the outstanding verification token, so clicking the emailed link sets email_verified=True while
|
||||
password_hash stays NULL — and that row has no route back at all (reset ineligible, resend refuses an
|
||||
already-verified address, and set-password needs a signed-in session the account can no longer obtain:
|
||||
no password to sign in with, and no Google identity). Those are the victims who most need the stamp,
|
||||
so the predicate must not exclude them.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0078_password_revoked_at"
|
||||
down_revision: Union[str, None] = "0077_plex_playback_class"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("password_revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.execute(
|
||||
"UPDATE users SET password_revoked_at = now() "
|
||||
"WHERE password_hash IS NULL "
|
||||
"AND google_sub IS NULL "
|
||||
"AND is_demo = false"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "password_revoked_at")
|
||||
@@ -0,0 +1,65 @@
|
||||
"""email_canonical on users + invites, for matching one mailbox across Gmail spellings
|
||||
|
||||
Revision ID: 0079_email_canonical
|
||||
Revises: 0078_password_revoked_at
|
||||
Create Date: 2026-09-04
|
||||
|
||||
Gmail delivers `x.y@gmail.com`, `xy@gmail.com` and `xy+tag@gmail.com` to ONE mailbox; the invite gate
|
||||
only lowercased. On 2026-09-03 that turned an approved user's Google sign-in into a silent second
|
||||
access request (OAuth returns the account's stored primary, which need not be the spelling they typed)
|
||||
and left prod with two user rows for one mailbox.
|
||||
|
||||
The backfill below reproduces `app.utils.canonical_email` in SQL. That duplication is deliberate — a
|
||||
migration must not import application code that will keep changing — but it means the two have to
|
||||
agree; `test_canonical_email_backfill_matches_the_helper` pins them together.
|
||||
|
||||
Deliberately NOT unique. Prod already carries canonical duplicates: two approved invites for one
|
||||
mailbox dating back to 2026-06-16, and two active user rows from the 2026-09-03 walkthrough. Merging
|
||||
accounts is a judgement call about people's data, not something a migration gets to do on its own, so
|
||||
this migration only records the canonical form and leaves the duplicates in place for a human.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0079_email_canonical"
|
||||
down_revision: str | None = "0078_password_revoked_at"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
# Mirrors app.utils.canonical_email: Gmail (and its googlemail alias) loses dots and any `+tag` from
|
||||
# the local part and lands on gmail.com; every other domain is only lowercased, because the dot rule
|
||||
# is not guaranteed elsewhere — and does not hold for Workspace custom domains unless their admin
|
||||
# enabled it.
|
||||
_CANON_SQL = """
|
||||
CASE
|
||||
WHEN split_part(lower({col}), '@', 2) IN ('gmail.com', 'googlemail.com')
|
||||
-- an empty local part after stripping falls through to plain lowercase, as the helper does
|
||||
AND replace(split_part(split_part(lower({col}), '@', 1), '+', 1), '.', '') <> ''
|
||||
THEN replace(split_part(split_part(lower({col}), '@', 1), '+', 1), '.', '') || '@gmail.com'
|
||||
ELSE lower({col})
|
||||
END
|
||||
"""
|
||||
|
||||
|
||||
def _backfill(table: str) -> None:
|
||||
op.execute(
|
||||
f"UPDATE {table} SET email_canonical = {_CANON_SQL.format(col='email')} "
|
||||
f"WHERE email IS NOT NULL"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in ("users", "invites"):
|
||||
op.add_column(
|
||||
table,
|
||||
sa.Column("email_canonical", sa.String(length=320), nullable=False, server_default=""),
|
||||
)
|
||||
_backfill(table)
|
||||
op.create_index(f"ix_{table}_email_canonical", table, ["email_canonical"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in ("users", "invites"):
|
||||
op.drop_index(f"ix_{table}_email_canonical", table_name=table)
|
||||
op.drop_column(table, "email_canonical")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""users.google_email — the address Google asserts, kept beside the one the user registered with
|
||||
|
||||
Revision ID: 0080_google_email
|
||||
Revises: 0079_email_canonical
|
||||
Create Date: 2026-09-04
|
||||
|
||||
Since 0079's follow-up a Google sign-in no longer overwrites `users.email`, so a deliberate
|
||||
`me+siftlode@gmail.com` survives being linked to a Google account whose primary is `me@gmail.com`.
|
||||
That leaves two true addresses for one account and only one of them stored. This column holds the
|
||||
other, so the account menu and the Settings sign-in row can show which address Google actually
|
||||
answers with.
|
||||
|
||||
No backfill is possible: Google's answer is only known at sign-in. Accounts linked before this
|
||||
column existed carry NULL until their owner next signs in with Google — and for the majority the two
|
||||
addresses are identical anyway, where the UI shows nothing either way.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0080_google_email"
|
||||
down_revision: str | None = "0079_email_canonical"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("google_email", sa.String(length=320), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "google_email")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""invites.invited_at — when the admin actually told this person the door was open
|
||||
|
||||
Revision ID: 0081_invite_invited_at
|
||||
Revises: 0080_google_email
|
||||
Create Date: 2026-09-05
|
||||
|
||||
Whitelisting an address stays SILENT by design: the operator is opening a door as a possibility,
|
||||
usually after agreeing it with the person, and a row can sit approved for weeks without anyone
|
||||
registering. Telling them is therefore a separate, deliberate act — a send button on the row.
|
||||
|
||||
Which raises the one question the admin cannot otherwise answer: "did I already write to them?"
|
||||
Without a record the only failure mode is writing to a real person twice, which is exactly the
|
||||
nudging the silent-by-default design exists to avoid. The audit log holds the event, but not where
|
||||
the decision is made.
|
||||
|
||||
NULL means "never invited from here", which is true of every existing row: no backfill is possible
|
||||
or wanted, since the historic approval mails answered a REQUEST rather than inviting anyone.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0081_invite_invited_at"
|
||||
down_revision: str | None = "0080_google_email"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("invites", sa.Column("invited_at", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("invites", "invited_at")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""invites.lang — the language this person asked us in
|
||||
|
||||
Revision ID: 0082_invite_lang
|
||||
Revises: 0081_invite_invited_at
|
||||
Create Date: 2026-09-05
|
||||
|
||||
The mails an access request produces — approved, denied, invited — go to an address that often has
|
||||
no account yet, so there is no `preferences.language` to read. The signal does exist, though, and
|
||||
only at the moment of the request: the language of the page the form was submitted from, or the
|
||||
locale Google reported for a sign-in we refused. Recording it there is what lets the decision mail
|
||||
answer in the requester's own language instead of an admin's guess about a stranger.
|
||||
|
||||
Captured rather than derived, for the third time in this epic: `preferences.language` (S6) and
|
||||
`google_email` (S3) both taught that a signal which exists only in one moment cannot be backfilled
|
||||
later. So no backfill here either — NULL means "we were never told", which is the honest state for
|
||||
every historic row, and for every address an admin whitelists by hand. Those fall back to English
|
||||
unless the admin picks otherwise on the invitation send.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0082_invite_lang"
|
||||
down_revision: str | None = "0081_invite_invited_at"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("invites", sa.Column("lang", sa.String(length=8), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("invites", "lang")
|
||||
@@ -27,9 +27,18 @@ class AuditAction:
|
||||
USER_SUSPEND = "user_suspend"
|
||||
USER_UNSUSPEND = "user_unsuspend"
|
||||
USER_DELETE = "user_delete"
|
||||
# E9.5 S5: an admin let a stuck registrant in, or re-sent their confirmation link. Both are
|
||||
# recorded because both are the admin acting ON someone's access, not merely reading a list.
|
||||
USER_ACTIVATE = "user_activate"
|
||||
USER_VERIFY_RESEND = "user_verify_resend"
|
||||
USER_SETPW_LINK = "user_setpw_link"
|
||||
INVITE_APPROVE = "invite_approve"
|
||||
INVITE_DENY = "invite_deny"
|
||||
INVITE_ADD = "invite_add"
|
||||
INVITE_DELETE = "invite_delete"
|
||||
# E9.5 S6: the admin chose to tell someone their address is whitelisted. Adding the address is
|
||||
# silent, so this is the act that actually reaches a person — and the one worth a record.
|
||||
INVITE_SEND = "invite_send"
|
||||
DEMO_ADD = "demo_add"
|
||||
DEMO_REMOVE = "demo_remove"
|
||||
DEMO_RESET = "demo_reset"
|
||||
|
||||
+135
-8
@@ -7,11 +7,13 @@ from sqlalchemy.orm import Session
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
from app import email as email_mod
|
||||
from app import mailtext
|
||||
from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.models import Invite, User
|
||||
from app.security import decrypt_optional
|
||||
from app.utils import canonical_email
|
||||
|
||||
PASSWORD_MIN_LEN = 10
|
||||
|
||||
@@ -63,16 +65,126 @@ def has_write_scope(user: User) -> bool:
|
||||
return WRITE_SCOPE in tok.scopes.split()
|
||||
|
||||
|
||||
def env_allowed_canonicals() -> set[str]:
|
||||
"""The env bootstrap allow-list, in CANONICAL form — the only shape it may be compared in.
|
||||
|
||||
S3 canonicalised the Invite branch of `is_allowed` and left this one matching the literal string,
|
||||
which is the 2026-09-03 lockout with a different source of truth: an operator who writes
|
||||
`first.last@gmail.com` into ALLOWED_EMAILS is refused when Google answers with the undotted
|
||||
primary, and a `+tag`ged entry can never match at all, because a Google account cannot contain a
|
||||
`+`. The addresses themselves stay literal wherever we MAIL them (`admin_notify_groups`); only
|
||||
the comparison is canonical.
|
||||
"""
|
||||
return env_admin_canonicals() | {canonical_email(e) for e in settings.allowed_email_set}
|
||||
|
||||
|
||||
def env_admin_canonicals() -> set[str]:
|
||||
"""The env ADMIN_EMAILS bootstrap list, canonical — for the role check only. Kept separate from
|
||||
`env_allowed_canonicals` because being listed as an admin also grants access, but being allowed
|
||||
does not make anyone an admin."""
|
||||
return {canonical_email(e) for e in settings.admin_email_set}
|
||||
|
||||
|
||||
def is_allowed(db: Session, email: str) -> bool:
|
||||
"""May this email sign in? An approved Invite is the source of truth; the env
|
||||
ALLOWED_EMAILS/ADMIN_EMAILS remain a bootstrap fallback (e.g. a fresh DB)."""
|
||||
ALLOWED_EMAILS/ADMIN_EMAILS remain a bootstrap fallback (e.g. a fresh DB).
|
||||
|
||||
Matched on the CANONICAL form (utils.canonical_email), not the literal string. Gmail delivers
|
||||
every dotted / `+tagged` spelling to one mailbox, so approving one spelling always meant approving
|
||||
that mailbox — matching literally only produced the 2026-09-03 lockout, where an approved user's
|
||||
Google sign-in became a silent second access request because OAuth returned the undotted primary.
|
||||
|
||||
One canonical form can match SEVERAL invite rows (prod carries approved pairs for one mailbox,
|
||||
the oldest from 2026-06-16). An approved row among them grants access: the API cannot flip an
|
||||
approved invite to denied (`_decide` only accepts a pending one), so approved-beside-denied means
|
||||
the admin approved that mailbox under a different spelling, not that they revoked it. Revoking is
|
||||
deleting the invite row or suspending the account — both still work, since a deleted row is no
|
||||
longer here to match and suspension is enforced at sign-in.
|
||||
"""
|
||||
if not email:
|
||||
return False
|
||||
email = email.lower()
|
||||
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
||||
if inv is not None:
|
||||
return inv.status == "approved"
|
||||
return email in settings.allowed_email_set or email in settings.admin_email_set
|
||||
canon = canonical_email(email)
|
||||
statuses = (
|
||||
db.execute(select(Invite.status).where(Invite.email_canonical == canon)).scalars().all()
|
||||
)
|
||||
if statuses:
|
||||
return "approved" in statuses
|
||||
return canon in env_allowed_canonicals()
|
||||
|
||||
|
||||
def can_activate(db: Session, user: User) -> bool:
|
||||
"""Is this account merely waiting for an admin to finish letting it in?
|
||||
|
||||
True when it is inactive but its address is ALREADY allowed — the shape roadmap UX-25 describes:
|
||||
the admin approved the address, the person signed up afterwards, and nothing connects the two
|
||||
(the Access-requests queue only offers Approve on a still-pending invite).
|
||||
|
||||
Gated on `is_allowed`, the same function the sign-in paths consult, because activating an address
|
||||
that is NOT allowed manufactures an inconsistent account: password sign-in only checks
|
||||
`is_active` and would let them in, while their Google sign-in would be turned away and silently
|
||||
reopened as a fresh access request. Letting in someone with no approved invite is the whitelist
|
||||
box's job — that action says so out loud.
|
||||
|
||||
Lives in core so the admin surface and the /api/me badge count answer from ONE definition; a
|
||||
button that appears and a number that counts must never disagree about who is stuck.
|
||||
"""
|
||||
if user.is_demo or user.is_suspended or user.is_active:
|
||||
return False
|
||||
return is_allowed(db, user.email)
|
||||
|
||||
|
||||
def count_pending_activations(db: Session) -> int:
|
||||
"""How many sign-ups are waiting on an admin click (see can_activate). Drives the admin badge.
|
||||
|
||||
Deliberately NOT "everyone who cannot sign in": an account that has simply not confirmed its
|
||||
address yet is waiting on its owner, not on the admin, and badging that would nag about something
|
||||
the admin did not cause. It is still listed on the admin screen — with a re-send — because the
|
||||
mail may never have arrived.
|
||||
"""
|
||||
# Narrowed in SQL, then decided by can_activate itself — so the number the badge shows and the
|
||||
# button the admin sees cannot drift apart by one of them re-deriving the rule.
|
||||
rows = (
|
||||
db.execute(
|
||||
select(User).where(
|
||||
User.is_demo.is_(False),
|
||||
User.is_suspended.is_(False),
|
||||
User.is_active.is_(False),
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return sum(1 for u in rows if can_activate(db, u))
|
||||
|
||||
|
||||
def find_user_by_email(db: Session, email: str) -> User | None:
|
||||
"""The account someone means when they type an address at sign-in, password-reset or
|
||||
verify-resend. None when there isn't exactly one answer.
|
||||
|
||||
Exact spelling wins. Failing that, the address is resolved to its mailbox
|
||||
(utils.canonical_email) — so a password chosen at registration still works when the person types
|
||||
another spelling of their own Gmail, and so the three flows that look an account up by typed
|
||||
address stop being dead ends. Before this, requesting a reset for `xy@gmail.com` on an account
|
||||
registered as `x.y@gmail.com` issued nothing and said nothing: the endpoint answers uniformly by
|
||||
design, so the user got silence and no way in.
|
||||
|
||||
SEVERAL canonical candidates return None rather than a guess. That case is real — prod carries two
|
||||
accounts for one mailbox — and picking one of them for a password sign-in would hand someone the
|
||||
wrong account. The callers all treat None as "no such account", which is the uniform,
|
||||
non-enumerating answer they already gave.
|
||||
"""
|
||||
if not email:
|
||||
return None
|
||||
email = email.strip().lower()
|
||||
exact = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
if exact is not None:
|
||||
return exact
|
||||
candidates = (
|
||||
db.execute(select(User).where(User.email_canonical == canonical_email(email)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return candidates[0] if len(candidates) == 1 else None
|
||||
|
||||
|
||||
def get_or_create_demo_user(db: Session) -> User:
|
||||
@@ -119,6 +231,9 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
|
||||
touch any browser session — each caller handles its own."""
|
||||
email = user.email.lower()
|
||||
user_id = user.id
|
||||
# Read the language off the row while it still exists — the confirmation below is queued as a
|
||||
# background task that runs after this session (and this user) are gone.
|
||||
lang = mailtext.lang_for_user(user)
|
||||
tok = user.token
|
||||
# Both are stored encrypted — decrypt for the revoke call (the access-token fallback was passing
|
||||
# ciphertext, so a token row with no refresh token silently failed to revoke). decrypt_optional so a
|
||||
@@ -130,13 +245,25 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
|
||||
)
|
||||
# Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table.
|
||||
db.execute(delete(User).where(User.id == user_id))
|
||||
db.execute(delete(Invite).where(Invite.email == email))
|
||||
# Every canonically-equal invite goes, not just the literal spelling: is_allowed matches on the
|
||||
# canonical form, so leaving a sibling row behind would keep granting access to a mailbox whose
|
||||
# account was just erased — and prod really does carry approved pairs for one mailbox.
|
||||
#
|
||||
# Unless ANOTHER account still lives on that mailbox — prod carries that too (two user rows for
|
||||
# one Gmail mailbox). Then NO invite goes, not even the literal one: is_allowed matches
|
||||
# canonically, so every row here is what keeps the survivor allowed, and removing any of them
|
||||
# would refuse their next Google sign-in and reopen it as a fresh access request — this epic's
|
||||
# founding bug, caused by its own cleanup. `delete_invite` refuses for exactly this reason; the
|
||||
# user row is already gone by now, so this query sees only the others.
|
||||
canon = canonical_email(email)
|
||||
if db.execute(select(User.id).where(User.email_canonical == canon)).first() is None:
|
||||
db.execute(delete(Invite).where(Invite.email_canonical == canon))
|
||||
db.commit()
|
||||
if google_token:
|
||||
background.add_task(revoke_google_token, google_token)
|
||||
# Confirm the erasure to the (now former) user — GDPR good practice, and covers both the
|
||||
# self-service and admin deletion paths since both funnel through here.
|
||||
background.add_task(email_mod.send_account_deleted, email, operator_contact())
|
||||
background.add_task(email_mod.send_account_deleted, email, lang, operator_contact())
|
||||
|
||||
|
||||
ACTIVE_ACCOUNT_HEADER = "x-siftlode-account"
|
||||
|
||||
@@ -265,7 +265,7 @@ class Settings(BaseSettings):
|
||||
# --- Transcode (playable="transcode") re-encode tuning (E6 S1) ---
|
||||
# Hardware-encoder policy: "auto" = VAAPI (h264_vaapi) when the iGPU render node is present AND the
|
||||
# source codec is hardware-decodable, else libx264 software; "on" = force VAAPI wherever the render
|
||||
# node exists (skip the decodability gate); "off" = always software. See plex/stream.py `_want_hw`.
|
||||
# node exists (skip the decodability gate); "off" = always software. See plex/stream.py `_select_encode`.
|
||||
plex_transcode_hw: str = "auto"
|
||||
# Software-fallback libx264 tuning (IGNORED on the VAAPI path). `preset` trades CPU for size; `crf`
|
||||
# trades quality for size (lower = better + bigger). `max_height` caps the SOFTWARE transcode's output
|
||||
|
||||
@@ -54,6 +54,10 @@ class EditAborted(Exception):
|
||||
|
||||
# --- edit spec ------------------------------------------------------------------------------
|
||||
|
||||
# SEC-13: ceiling on a join's cut-list. An accurate join emits two filters per segment into a single
|
||||
# ffmpeg `filter_complex`, so "however many the caller sent" is an unbounded command line.
|
||||
_MAX_SEGMENTS = 100
|
||||
|
||||
|
||||
def normalize_edit_spec(spec: dict | None) -> dict:
|
||||
"""Canonicalize a raw edit spec: clamp/round trim, coerce crop to ints, drop empties.
|
||||
@@ -88,6 +92,12 @@ def normalize_edit_spec(spec: dict | None) -> dict:
|
||||
if en > st:
|
||||
norm.append({"start_s": round(st, 3), "end_s": round(en, 3)})
|
||||
norm.sort(key=lambda x: x["start_s"])
|
||||
# SEC-13: an accurate join builds one ffmpeg `filter_complex` with two filters per segment,
|
||||
# so an unbounded list becomes an unbounded command line and an ffmpeg that never finishes.
|
||||
# The cap is far above any real cut-list (the UI's own splitter produces a handful) and the
|
||||
# excess is DROPPED rather than raising: the surplus segments are the attack, the first
|
||||
# _MAX_SEGMENTS are a legitimate edit.
|
||||
del norm[_MAX_SEGMENTS:]
|
||||
if norm:
|
||||
out["segments"] = norm
|
||||
# A join always chooses a codec path: accurate=filter-concat re-encode,
|
||||
|
||||
@@ -8,9 +8,28 @@ explanation (and decides whether a retry makes sense). The raw text stays availa
|
||||
`_RETRIABLE_MARKERS` — kept here as the single source of truth for "what kind of failure is this".
|
||||
"""
|
||||
|
||||
# Two worker-authored strings that this module owns because both are coupled to `_RULES`.
|
||||
#
|
||||
# QUOTA_FULL_MESSAGE is raised by the worker and must classify back to `quota_exceeded`; its rule
|
||||
# below matches a fragment of it, and test_download_errors pins the pair, because a message and its
|
||||
# rule drifting apart is exactly how a category silently becomes unreachable.
|
||||
#
|
||||
# NETWORK_REDACTED_MESSAGE is the opposite direction: it REPLACES a message after classification, so
|
||||
# it deliberately has no rule. Nothing re-classifies a stored `job.error` today; if anything ever
|
||||
# does, it must carry the original code rather than re-deriving one from this text.
|
||||
#
|
||||
# Other worker-raised texts are still inline literals at their raise sites. The ffmpeg ones and
|
||||
# "produced no output file" have markers here; the urlguard refusal deliberately has none and
|
||||
# classifies as `unknown` — it is a refusal, not a diagnosis, and no category would say more.
|
||||
QUOTA_FULL_MESSAGE = "Your download storage is full — free up space, then try again."
|
||||
NETWORK_REDACTED_MESSAGE = "The server could not reach that address."
|
||||
|
||||
# (marker, category) — first match wins, so order most-specific → most-general. Matching is
|
||||
# case-insensitive on a substring, against the raw yt-dlp/ffmpeg message.
|
||||
_RULES: tuple[tuple[str, str], ...] = (
|
||||
# Our own refusal: the worker found the user already at their storage cap (SEC-13). Placed
|
||||
# first — it is our wording, so it can never be a false match on an upstream message.
|
||||
("download storage is full", "quota_exceeded"),
|
||||
# Login / age / members / bot-check — the user must be signed in (we deliberately are not).
|
||||
("sign in to confirm your age", "login_required"),
|
||||
("confirm your age", "login_required"),
|
||||
@@ -91,3 +110,20 @@ def classify(msg: str | None) -> str:
|
||||
if marker in low:
|
||||
return category
|
||||
return "unknown"
|
||||
|
||||
|
||||
def user_error_text(source_kind: str, msg: str, code: str) -> str:
|
||||
"""The failure text to STORE on the job, given the raw message and its category.
|
||||
|
||||
SEC-12: for a user-supplied URL, a transport-level failure text distinguishes "connection
|
||||
refused" from "HTTP Error 401" from "timed out" per host:port — which is a port scanner with
|
||||
extra steps, since the raw text is shown to the user behind the details disclosure. `urlguard`
|
||||
stops the fetch aiming at our own network; this stops the remaining readout.
|
||||
|
||||
Only the network class, and only for ``url`` jobs: a YouTube job has no user-chosen host, and
|
||||
every other class (login_required, geo_blocked, drm, …) is real, useful diagnosis. A separate
|
||||
function rather than an inline branch in the worker so it can be pinned by a test — the raw
|
||||
text leaking back is a security regression that would otherwise pass a green suite."""
|
||||
if source_kind == "url" and code == "network":
|
||||
return NETWORK_REDACTED_MESSAGE
|
||||
return msg
|
||||
|
||||
@@ -116,6 +116,7 @@ def build_ydl_opts(
|
||||
progress_hook,
|
||||
extra: dict | None = None,
|
||||
cookiefile: str | None = None,
|
||||
max_bytes: int | None = None,
|
||||
) -> dict:
|
||||
"""Assemble yt-dlp options for a single download to `outtmpl` (a full path template).
|
||||
|
||||
@@ -125,12 +126,20 @@ def build_ydl_opts(
|
||||
|
||||
`cookiefile` is the acting user's per-host cookies.txt path (S6), attached ONLY by the worker
|
||||
after the host-scope + private-asset gate in `downloads.credentials`. It is set AFTER the `extra`
|
||||
merge so a source adapter can never clobber it."""
|
||||
merge so a source adapter can never clobber it.
|
||||
|
||||
`max_bytes` (SEC-13) is the caller's remaining storage budget; yt-dlp refuses a format larger
|
||||
than it rather than filling the disk and only then being caught by the quota accounting."""
|
||||
s = normalize(spec)
|
||||
h = s["max_height"]
|
||||
opts: dict = {
|
||||
"outtmpl": outtmpl,
|
||||
"noplaylist": True,
|
||||
# SEC-13: `noplaylist` only demotes a video-inside-a-playlist URL to the single video. A
|
||||
# URL that is PURELY a playlist or channel has no such video to fall back to, so it still
|
||||
# expands into every entry — one enqueue, an unbounded number of downloads. One job means
|
||||
# one item; a user who wants the whole list enqueues it item by item, under their quota.
|
||||
"playlist_items": "1",
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"noprogress": True,
|
||||
@@ -154,6 +163,16 @@ def build_ydl_opts(
|
||||
if extra:
|
||||
opts.update(extra)
|
||||
|
||||
# SEC-13: hard ceiling on a single download. AFTER the adapter merge, for the same reason
|
||||
# `cookiefile` is — a source adapter must not be able to raise a limit the quota layer set.
|
||||
#
|
||||
# A non-positive budget is left unset because 0 is not an expressible yt-dlp filter, NOT because
|
||||
# nothing needs to happen: the worker refuses that job outright before extraction (`_OwnerRefused`
|
||||
# → `quota_exceeded`). Enqueue cannot make that call — it ran while the user was still under the
|
||||
# cap. Delete the worker's refusal and this branch silently uncaps the one user already over it.
|
||||
if max_bytes and max_bytes > 0:
|
||||
opts["max_filesize"] = max_bytes
|
||||
|
||||
# The acting user's per-host cookie (S6). AFTER the adapter merge on purpose: an adapter must not
|
||||
# be able to override the authenticated session the worker resolved for this specific user.
|
||||
if cookiefile:
|
||||
|
||||
@@ -15,7 +15,8 @@ from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads import adapters, credentials, formats, mediakind, probe, service, storage
|
||||
from app.downloads import adapters, credentials, formats, mediakind, probe, quota, service, storage
|
||||
from app.downloads import urlguard
|
||||
from app.downloads.jobdb import (
|
||||
_edit_staging_dir,
|
||||
_job_status,
|
||||
@@ -36,6 +37,18 @@ class _Aborted(Exception):
|
||||
"""Raised inside the progress hook when the job was paused/canceled mid-download."""
|
||||
|
||||
|
||||
class _OwnerRefused(Exception):
|
||||
"""A refusal that belongs to THIS job's owner, not to the source.
|
||||
|
||||
MediaAssets are shared: two users asking for the same video and format get one row (see
|
||||
`service.get_or_create_asset`), and the generic failure handler writes the message onto it —
|
||||
correctly, because an upstream failure really is everyone's. A per-user refusal is not. Written
|
||||
to the shared row it would fail a second user's queued job for the same video with the first
|
||||
user's private reason (`worker._process_job` reads `asset.error` verbatim), telling someone with
|
||||
an empty library to free up space. So this is caught separately: the job fails, the asset goes
|
||||
back to `pending` for whoever else is holding it."""
|
||||
|
||||
|
||||
# Both progress sources call back FAR faster than the database should be touched: yt-dlp fires its
|
||||
# hook per chunk, and ffmpeg's `-progress pipe:1` emits a block of ~12 lines every ~0.5s while
|
||||
# run_ffmpeg calls its callbacks once per LINE. Every callback here is a round-trip (`_job_status`
|
||||
@@ -305,13 +318,17 @@ def _media_info(info: dict) -> dict:
|
||||
return {**info, **chosen}
|
||||
|
||||
|
||||
def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter, cookiefile=None):
|
||||
def _extract_with_fallback(
|
||||
job_id, asset_id, url, spec, staging, adapter, cookiefile=None, max_bytes=None
|
||||
):
|
||||
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
|
||||
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
|
||||
error); other sources make a single attempt. Returns the info dict of the successful attempt.
|
||||
|
||||
`cookiefile` (S6) is the acting user's per-host cookies.txt, already gated by
|
||||
`credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated."""
|
||||
`credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated.
|
||||
|
||||
`max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt."""
|
||||
client_sets = adapter.client_sets()
|
||||
outtmpl = str(staging / "%(id)s.%(ext)s")
|
||||
last_exc: Exception | None = None
|
||||
@@ -322,6 +339,7 @@ def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter, cookie
|
||||
_make_progress_hook(job_id, asset_id),
|
||||
extra=adapter.ydl_opts_extra(clients),
|
||||
cookiefile=cookiefile,
|
||||
max_bytes=max_bytes,
|
||||
)
|
||||
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
||||
_touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow /
|
||||
@@ -415,8 +433,17 @@ def _download(
|
||||
_set_job(job_id, phase="downloading", progress=0)
|
||||
adapter = adapters.resolve_adapter(source_kind, source_ref)
|
||||
url = service.source_url(source_kind, source_ref)
|
||||
# SEC-12, second half: the enqueue guard ran when the job was created, which may have been
|
||||
# hours ago and against a different DNS answer. This is where the socket actually gets
|
||||
# opened, so the check is repeated here and the job fails rather than fetching.
|
||||
if source_kind == "url":
|
||||
try:
|
||||
urlguard.check_url(url)
|
||||
except urlguard.BlockedUrl as e:
|
||||
raise RuntimeError(f"Refusing to download from this address ({e.reason}).") from e
|
||||
# S6: attach the acting user's per-host cookie ONLY when it's host-scoped to this URL AND
|
||||
# this asset is that user's private, salted identity (the double gate lives in credentials).
|
||||
remaining_bytes: int | None = None
|
||||
with SessionLocal() as cdb:
|
||||
asset_sig = cdb.execute(
|
||||
select(MediaAsset.format_sig).where(MediaAsset.id == asset_id)
|
||||
@@ -425,6 +452,19 @@ def _download(
|
||||
cookiefile = credentials.resolve_cookiefile(
|
||||
cdb, user_id, url, asset_sig, spec, staging
|
||||
)
|
||||
# SEC-13: what this user has left. `check_enqueue` only compared the footprint as it
|
||||
# stood when the job was QUEUED — a download's size is unknown until it finishes, so a
|
||||
# single job could previously blow through the cap by any amount. Handing the remainder
|
||||
# to yt-dlp as `max_filesize` makes the format selector refuse an oversized file up
|
||||
# front instead.
|
||||
remaining_bytes = quota.remaining_bytes(cdb, user_id)
|
||||
# …and zero is a REFUSAL, not "no limit". Earlier jobs of this user may have filled the cap
|
||||
# while this one sat in the queue, and 0 is not an expressible yt-dlp `max_filesize`, so
|
||||
# passing it down would leave the ceiling unset — the one user already at their cap would be
|
||||
# the only one downloading unbounded. Fail the job here instead. Not retryable until they
|
||||
# free space, which is what the `quota_exceeded` category tells them.
|
||||
if remaining_bytes == 0:
|
||||
raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE)
|
||||
info = _media_info(
|
||||
_extract_with_fallback(
|
||||
job_id,
|
||||
@@ -434,6 +474,7 @@ def _download(
|
||||
staging,
|
||||
adapter,
|
||||
cookiefile=str(cookiefile) if cookiefile else None,
|
||||
max_bytes=remaining_bytes,
|
||||
)
|
||||
)
|
||||
# Re-resolve now that yt-dlp's info reveals the true source (a bare/redirected url only
|
||||
@@ -525,8 +566,31 @@ def _download(
|
||||
# job's status (the route set it to paused/canceled). ref_count is adjusted by the route.
|
||||
_reset_asset_pending(asset_id)
|
||||
log.info("job %s aborted (pause/cancel)", job_id)
|
||||
except _OwnerRefused as exc:
|
||||
# Fail this job only. The asset is never marked `error`; it goes back to `pending` (below),
|
||||
# so another user's job for the same video is claimed and downloaded normally instead of
|
||||
# inheriting a reason that is about someone else's account.
|
||||
msg = str(exc)[:500]
|
||||
with SessionLocal() as db:
|
||||
service.finalize_if_running(
|
||||
db,
|
||||
job_id,
|
||||
status=JobStatus.ERROR,
|
||||
error=msg,
|
||||
error_code=dlerrors.classify(msg),
|
||||
phase=None,
|
||||
)
|
||||
db.commit()
|
||||
# Released AFTER the job is terminal: the moment the asset is `pending` another job can claim
|
||||
# it, and this one must already be out of the way. (The staging tree is still torn down in
|
||||
# `finally` below — the same narrow window the pause/cancel path above has always had.)
|
||||
_reset_asset_pending(asset_id)
|
||||
log.info("job %s refused for its owner: %s", job_id, msg)
|
||||
except Exception as exc: # noqa: BLE001 — surface any failure to the user, keep worker alive
|
||||
msg = str(exc)[:500]
|
||||
code = dlerrors.classify(msg)
|
||||
# SEC-12: redact a transport-level readout for user-supplied URLs (see user_error_text).
|
||||
msg = dlerrors.user_error_text(source_kind, msg, code)
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
@@ -539,7 +603,7 @@ def _download(
|
||||
job_id,
|
||||
status=JobStatus.ERROR,
|
||||
error=msg,
|
||||
error_code=dlerrors.classify(msg),
|
||||
error_code=code,
|
||||
phase=None,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
@@ -7,6 +7,13 @@ Limits come from a per-user DownloadQuota row if the admin set one, else the sys
|
||||
|
||||
Enforcement split: max_jobs + max_bytes are checked at enqueue (they bound holdings); per-user
|
||||
max_concurrent is enforced by the worker at claim time (it bounds simultaneous downloads).
|
||||
|
||||
DOWNLOAD jobs additionally consult `remaining_bytes` in the worker, because enqueue can only compare
|
||||
the footprint as it stood when the job was created and earlier jobs may have filled the cap since.
|
||||
That is a per-job ceiling, not an aggregate ledger: concurrent jobs of one user each read the same
|
||||
remainder (in-flight bytes are not `ready`, so `footprint` cannot see them), so a wave of
|
||||
max_concurrent downloads can still land over the cap — the deliberate trade, since a download's size
|
||||
is unknown until it finishes. Edit jobs do not consult it at all (tracked as SEC-19).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -122,6 +129,21 @@ def check_enqueue(db: Session, user_id: int) -> None:
|
||||
raise QuotaExceeded("max_bytes", lim.max_bytes, used)
|
||||
|
||||
|
||||
def remaining_bytes(db: Session, user_id: int) -> int | None:
|
||||
"""Storage this user may still spend on ONE download: ``None`` for an unlimited user, else the
|
||||
cap minus the current footprint, floored at 0.
|
||||
|
||||
Zero is a real answer, not "no limit" — it means the cap is already met and the caller must
|
||||
REFUSE rather than proceed unbounded. `check_enqueue` cannot make that refusal on the caller's
|
||||
behalf: it ran when the job was queued, while the user was still under the cap, and the jobs
|
||||
that filled it have landed since. Keeping the arithmetic here (rather than inline in the worker)
|
||||
is what keeps every max_bytes decision in this module."""
|
||||
lim = resolve(db, user_id)
|
||||
if lim.unlimited:
|
||||
return None
|
||||
return max(0, lim.max_bytes - footprint(db, user_id))
|
||||
|
||||
|
||||
def check_upload(db: Session, user_id: int, add_bytes: int) -> None:
|
||||
"""Like check_enqueue, but for an upload whose size is KNOWN at ingest: also reject when adding
|
||||
this file would push the footprint over max_bytes. A download can't pre-check its size (unknown
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Refuse a user-supplied download URL that points back into our own network (SEC-12).
|
||||
|
||||
The Download Center deliberately accepts an arbitrary ``http(s)`` URL — that is the feature — and
|
||||
hands it to yt-dlp running on the WORKER. Without a guard, any approved user can make the server
|
||||
fetch anything the server can reach: other containers on the compose network, the LAN, a cloud
|
||||
metadata endpoint. The failure text then comes back to them in ``job.error``, so "connection
|
||||
refused" vs "HTTP Error 401" vs "timed out" turns the download box into an internal port scanner.
|
||||
|
||||
What this closes: a URL whose host resolves to a loopback / private / link-local / reserved address
|
||||
is refused, at enqueue AND again in the worker before extraction. Both, because DNS can answer
|
||||
differently between the two moments, and the worker is where the socket is actually opened.
|
||||
|
||||
What it does NOT close, stated plainly rather than implied, because both need the same fix — gating
|
||||
the connection itself with a custom socket factory inside yt-dlp, a much larger change than this:
|
||||
|
||||
* DNS rebinding: a host that passes the worker's check and then answers yt-dlp's own resolution
|
||||
with an internal address.
|
||||
* HTTP redirects: a public host that answers 3xx with an internal ``Location``. yt-dlp follows it,
|
||||
and only the URL the user TYPED was ever judged here. An attacker-controlled host or any public
|
||||
open redirect reaches it, so this is the cheaper of the two. It does NOT usually yield the
|
||||
response body — a non-media body fails the mp4 remux and stores nothing — but it does hand back
|
||||
the readout above: the redaction in ``errors.user_error_text`` fires only for the ``network``
|
||||
class, so a dead internal port (refused → ``network`` → redacted) is distinguishable from a live
|
||||
one (an HTTP answer that is not media → ``unsupported_host``/``format_unavailable``, raw text
|
||||
kept). Tracked as SEC-17.
|
||||
|
||||
The two checks here stop the straightforward case (typing ``http://127.0.0.1:6379`` or an internal
|
||||
hostname into the box), which is the reachable one.
|
||||
|
||||
Not used for YouTube jobs: those resolve to a fixed public host from an 11-char id, so there is no
|
||||
user-controlled destination to guard.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Reason codes travel to the client inside the structured 422 detail, so they are part of the API
|
||||
# vocabulary (frontend `localizeDetail`) — keep them stable.
|
||||
REASON_SCHEME = "scheme" # not http(s)
|
||||
REASON_NO_HOST = "no_host" # URL carries no hostname
|
||||
REASON_MALFORMED = "malformed" # unparseable authority or port; urlparse raises late, so guard it
|
||||
REASON_UNRESOLVABLE = "unresolvable" # DNS said nothing (honest: not the same as "blocked")
|
||||
REASON_PRIVATE = "private" # resolves into a network the server must not fetch from
|
||||
|
||||
|
||||
class BlockedUrl(Exception):
|
||||
"""A user-supplied URL that must not be fetched server-side. ``reason`` is one of the
|
||||
REASON_* codes above."""
|
||||
|
||||
def __init__(self, reason: str):
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
|
||||
|
||||
def _is_forbidden(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
"""Every address family we must not open a connection to. Checked explicitly rather than
|
||||
leaning on ``is_private`` alone, whose coverage differs between IPv4 and IPv6."""
|
||||
# ::ffff:127.0.0.1 is loopback wearing an IPv6 hat — unwrap before judging it.
|
||||
mapped = getattr(ip, "ipv4_mapped", None)
|
||||
if mapped is not None:
|
||||
ip = mapped
|
||||
return bool(
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_multicast
|
||||
or ip.is_reserved
|
||||
or ip.is_unspecified
|
||||
)
|
||||
|
||||
|
||||
def _resolve(host: str, port: int | None) -> list[str]:
|
||||
"""Every address ``host`` resolves to. Raises BlockedUrl(REASON_UNRESOLVABLE) when DNS has no
|
||||
answer — reported as its own outcome, because "we could not look that up" and "we refuse to
|
||||
fetch that" are different things to tell a user."""
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
||||
except socket.gaierror as exc:
|
||||
raise BlockedUrl(REASON_UNRESOLVABLE) from exc
|
||||
return [info[4][0] for info in infos]
|
||||
|
||||
|
||||
def check_url(raw: str) -> None:
|
||||
"""Raise BlockedUrl unless ``raw`` is an http(s) URL whose host resolves only to addresses the
|
||||
server is allowed to fetch from. A host with even ONE forbidden address is refused: a
|
||||
round-robin answer must not become a coin flip."""
|
||||
# `urlparse` itself raises ValueError on a malformed authority ("http://[::1/x" — an unclosed
|
||||
# bracket), and `.port` raises on ":99999" / ":abc" when READ rather than at parse time. Both
|
||||
# escape the caller's `except BlockedUrl` as a 500 unless caught here, which is the opposite of
|
||||
# what this module exists to do: turn a bad URL into a structured, localizable refusal.
|
||||
try:
|
||||
u = urlparse((raw or "").strip())
|
||||
port = u.port
|
||||
except ValueError as exc:
|
||||
raise BlockedUrl(REASON_MALFORMED) from exc
|
||||
if u.scheme not in ("http", "https"):
|
||||
raise BlockedUrl(REASON_SCHEME)
|
||||
host = u.hostname
|
||||
if not host:
|
||||
raise BlockedUrl(REASON_NO_HOST)
|
||||
for addr in _resolve(host, port):
|
||||
try:
|
||||
ip = ipaddress.ip_address(addr)
|
||||
except ValueError: # getaddrinfo returned something unparseable; treat as unusable
|
||||
raise BlockedUrl(REASON_PRIVATE) from None
|
||||
if _is_forbidden(ip):
|
||||
raise BlockedUrl(REASON_PRIVATE)
|
||||
+252
-114
@@ -3,36 +3,103 @@
|
||||
Gmail SMTP + App Password by default. Deliberately fail-soft: if SMTP isn't configured
|
||||
or a send errors, we log and return False so the caller can fall back to in-app
|
||||
notifications — email is never load-bearing for the app's core flow.
|
||||
|
||||
Fail-soft must not mean invisible, though: every drop also leaves an in-app notice in the admins'
|
||||
inbox (see `_alert_admins`). An instance whose SMTP was never filled in silently swallows
|
||||
verification links, password resets and access-request notices, and the operator's first clue used
|
||||
to be a person telling them nothing arrived.
|
||||
"""
|
||||
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formatdate
|
||||
|
||||
from app import sysconfig
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import mailtext, sysconfig
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import User
|
||||
from app.notifications import create_notification
|
||||
from app.ratelimit import RateLimiter
|
||||
|
||||
log = logging.getLogger("siftlode.email")
|
||||
|
||||
|
||||
def _smtp() -> dict:
|
||||
"""Effective SMTP config (admin DB override over env defaults). Opens its own short
|
||||
session because email is often sent from a BackgroundTask, outside a request's db."""
|
||||
with SessionLocal() as db:
|
||||
return {
|
||||
"host": sysconfig.get_str(db, "smtp_host"),
|
||||
"port": sysconfig.get_int(db, "smtp_port"),
|
||||
"user": sysconfig.get_str(db, "smtp_user"),
|
||||
"password": sysconfig.get_str(db, "smtp_password"),
|
||||
"from": sysconfig.get_str(db, "smtp_from"),
|
||||
}
|
||||
# One admin notice an hour per reason. A relay that is down fails every send, and the inbox must
|
||||
# stay readable; the log keeps the full record either way.
|
||||
_alert_throttle = RateLimiter(max_events=1, window_seconds=3600)
|
||||
|
||||
|
||||
def email_enabled() -> bool:
|
||||
c = _smtp()
|
||||
def _alert_admins(reason: str, subject: str, recipients: list[str]) -> None:
|
||||
"""Record an in-app notice that a message did NOT go out. Best effort in every direction: it
|
||||
opens its own session (callers are often background tasks), swallows its own errors, and never
|
||||
reports failure to the caller — this is the fallback path, it must not become a new way to
|
||||
break a send."""
|
||||
if not _alert_throttle.allow(reason):
|
||||
return
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
admins = (
|
||||
db.execute(
|
||||
select(User.id).where(
|
||||
User.role == "admin",
|
||||
User.is_active.is_(True),
|
||||
User.is_suspended.is_(False),
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if not admins:
|
||||
# Nothing was recorded, so don't let this cost the next hour of alerts on the very
|
||||
# path whose job is to stop drops going unseen. Same on the failure branch below.
|
||||
_alert_throttle.reset()
|
||||
return
|
||||
for uid in admins:
|
||||
create_notification(
|
||||
db,
|
||||
user_id=uid,
|
||||
type="email_undelivered",
|
||||
# English fallback; the inbox renders a translated version from `data`.
|
||||
title="Email could not be sent",
|
||||
body=subject,
|
||||
data={"reason": reason, "subject": subject, "recipients": recipients},
|
||||
commit=False,
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
_alert_throttle.reset()
|
||||
log.exception("Could not record the undelivered-email notice (%r)", subject)
|
||||
|
||||
|
||||
def _read_smtp(db: Session) -> dict:
|
||||
return {
|
||||
"host": sysconfig.get_str(db, "smtp_host"),
|
||||
"port": sysconfig.get_int(db, "smtp_port"),
|
||||
"user": sysconfig.get_str(db, "smtp_user"),
|
||||
"password": sysconfig.get_str(db, "smtp_password"),
|
||||
"from": sysconfig.get_str(db, "smtp_from"),
|
||||
}
|
||||
|
||||
|
||||
def _smtp(db: Session | None = None) -> dict:
|
||||
"""Effective SMTP config (admin DB override over env defaults). Opens its own short session when
|
||||
the caller has none, because email is usually sent from a BackgroundTask outside a request's db.
|
||||
A caller that IS inside a request should pass its session: reading the password decrypts a Fernet
|
||||
value, and /api/me polls this for every admin tab."""
|
||||
if db is not None:
|
||||
return _read_smtp(db)
|
||||
with SessionLocal() as own:
|
||||
return _read_smtp(own)
|
||||
|
||||
|
||||
def email_enabled(db: Session | None = None) -> bool:
|
||||
c = _smtp(db)
|
||||
return bool(c["host"] and c["user"] and c["password"])
|
||||
|
||||
|
||||
@@ -40,6 +107,48 @@ def _admin_contact() -> str | None:
|
||||
return next(iter(sorted(settings.admin_email_set)), None)
|
||||
|
||||
|
||||
# A bare URL in a text/plain mail is only clickable if the CLIENT decides to linkify it. Gmail,
|
||||
# Outlook and Apple Mail do; plenty of lighter web clients render the body verbatim and leave the
|
||||
# link as dead text — which, for a verification or password-reset mail, is the whole message. So
|
||||
# every mail also carries an HTML alternative built from the SAME plain body (below), rather than
|
||||
# per-template HTML that would have to be kept in sync with it.
|
||||
#
|
||||
# `[^\s<]` stops at whitespace and at the `<` of any following markup; the trailing-punctuation trim
|
||||
# keeps a sentence-final period out of the href. `;` and `:` are deliberately NOT trimmed — escaping
|
||||
# turns `&` into `&`, so a query string can legitimately end in a semicolon.
|
||||
_URL_RE = re.compile(r"https?://[^\s<]+")
|
||||
|
||||
|
||||
def _html_body(text: str) -> str:
|
||||
"""Render a plain-text mail body as minimal HTML: escaped, blank-line-separated paragraphs, with
|
||||
real links. Escaping happens FIRST and the linkifier then runs over the escaped text — bodies
|
||||
interpolate untrusted values (a requester's email address, the operator contact), so no caller
|
||||
can inject markup, and `&` inside a URL is already `&` by the time it reaches the href, which
|
||||
is what an HTML attribute wants anyway."""
|
||||
|
||||
def _anchor(m: re.Match[str]) -> str:
|
||||
url = m.group(0)
|
||||
trail = ""
|
||||
while url and url[-1] in ".,!?)":
|
||||
url, trail = url[:-1], url[-1] + trail
|
||||
if not url:
|
||||
return trail
|
||||
return f'<a href="{url}">{url}</a>{trail}'
|
||||
|
||||
paragraphs = [p for p in html.escape(text).split("\n\n") if p.strip()]
|
||||
rendered = "".join(
|
||||
f"<p>{_URL_RE.sub(_anchor, p).replace(chr(10), '<br>')}</p>" for p in paragraphs
|
||||
)
|
||||
# Inline styles only — many clients drop <style> blocks. No images, no external CSS, no tracking:
|
||||
# the HTML part exists to make the link clickable, not to become a template to maintain.
|
||||
return (
|
||||
'<html><body style="margin:0;padding:16px;font-family:system-ui,-apple-system,'
|
||||
"'Segoe UI',sans-serif;font-size:15px;line-height:1.6\">"
|
||||
f'<div style="max-width:560px">{rendered}</div>'
|
||||
"</body></html>"
|
||||
)
|
||||
|
||||
|
||||
def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -> bool:
|
||||
recipients = [e for e in to if e]
|
||||
if not recipients:
|
||||
@@ -47,6 +156,7 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
|
||||
c = _smtp()
|
||||
if not (c["host"] and c["user"] and c["password"]):
|
||||
log.info("SMTP not configured; skipping email %r to %s", subject, recipients)
|
||||
_alert_admins("not_configured", subject, recipients)
|
||||
return False
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
@@ -57,7 +167,10 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
if reply_to:
|
||||
msg["Reply-To"] = reply_to
|
||||
# text/plain stays the primary part (it is the source of truth, and a text-only client still
|
||||
# gets the full message); the HTML alternative below is what makes the link clickable everywhere.
|
||||
msg.set_content(body)
|
||||
msg.add_alternative(_html_body(body), subtype="html")
|
||||
try:
|
||||
with smtplib.SMTP(c["host"], c["port"], timeout=20) as s:
|
||||
s.starttls(context=ssl.create_default_context())
|
||||
@@ -67,128 +180,153 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
|
||||
return True
|
||||
except Exception:
|
||||
log.exception("SMTP send failed (%r to %s)", subject, recipients)
|
||||
_alert_admins("send_failed", subject, recipients)
|
||||
return False
|
||||
|
||||
|
||||
def send_access_approved(email: str) -> bool:
|
||||
def _next_step(verify_link: str | None, has_account: bool, hours: int, lang: str) -> str:
|
||||
"""What this person actually has to do next, in the one paragraph that carries the whole flow.
|
||||
|
||||
Three states, because a mail that guesses wrong sends someone looking for a link that isn't
|
||||
coming — or worse, tells them to sign in when their address is still unconfirmed and sign-in will
|
||||
refuse them (E9.5 S4 named that state on screen; the mail must not contradict it).
|
||||
|
||||
The link branch is the point of the C option: in the common ordering — approve first, the person
|
||||
signs up later, or signs up and waits — they would otherwise have to find and open TWO separate
|
||||
messages. One mail, one click.
|
||||
"""
|
||||
if verify_link:
|
||||
return mailtext.text(
|
||||
"next_step.verify", lang, link=verify_link, expiry=mailtext.link_expiry(hours, lang)
|
||||
)
|
||||
if has_account:
|
||||
return mailtext.text("next_step.sign_in", lang, app=settings.app_base)
|
||||
return mailtext.text("next_step.register", lang, app=settings.app_base)
|
||||
|
||||
|
||||
def _reply_contact(lang: str) -> tuple[str | None, str]:
|
||||
"""The admin address to answer to, and the closing line that names it — empty when no admin
|
||||
contact is configured, so the mail never promises a reply nobody will read."""
|
||||
admin = _admin_contact()
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"Your request to use Siftlode has been approved — welcome aboard.\n\n"
|
||||
"Open Siftlode and sign in, and your YouTube subscriptions feed will be ready:\n\n"
|
||||
f"{settings.app_base}\n\n"
|
||||
"If you weren't expecting this, you can ignore the message.\n\n"
|
||||
"— Siftlode" + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
|
||||
return admin, (mailtext.text("contact.reply", lang, admin=admin) if admin else "")
|
||||
|
||||
|
||||
def send_access_approved(
|
||||
email: str,
|
||||
lang: str,
|
||||
verify_link: str | None = None,
|
||||
has_account: bool = False,
|
||||
hours: int = 24,
|
||||
) -> bool:
|
||||
"""Answer someone who ASKED for access. Sent automatically on approval, because a person who
|
||||
made a request is owed a reply — unlike the admin-initiated invitation below."""
|
||||
admin, contact = _reply_contact(lang)
|
||||
body = mailtext.text(
|
||||
"access_approved.body",
|
||||
lang,
|
||||
next_step=_next_step(verify_link, has_account, hours, lang),
|
||||
contact=contact,
|
||||
)
|
||||
return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin)
|
||||
return _send([email], mailtext.text("access_approved.subject", lang), body, reply_to=admin)
|
||||
|
||||
|
||||
def send_access_denied(email: str) -> bool:
|
||||
admin = _admin_contact()
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"Thanks for your interest in Siftlode. After review, your access request wasn't approved "
|
||||
"this time.\n\n"
|
||||
"If you think this was a mistake, you're welcome to reply and let us know.\n\n"
|
||||
"— Siftlode" + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
|
||||
def send_access_invitation(
|
||||
email: str,
|
||||
lang: str,
|
||||
verify_link: str | None = None,
|
||||
has_account: bool = False,
|
||||
hours: int = 24,
|
||||
) -> bool:
|
||||
"""Invite someone the admin let in WITHOUT them asking — sent only when the admin presses send.
|
||||
|
||||
Whitelisting an address is opening a door, not knocking on one: the operator has usually already
|
||||
agreed it with the person, and an address can sit approved for weeks without anyone registering.
|
||||
So adding to the whitelist stays silent and this is a separate, deliberate act. That is also why
|
||||
the closing line offers no deadline — nudging is the one thing this mail must not do.
|
||||
|
||||
Its language is the one place the app has no signal to read: the admin typed this address in, so
|
||||
nobody has ever told us how to address its owner. That is why the send button asks (E9.5 S7).
|
||||
"""
|
||||
admin, contact = _reply_contact(lang)
|
||||
body = mailtext.text(
|
||||
"access_invitation.body",
|
||||
lang,
|
||||
next_step=_next_step(verify_link, has_account, hours, lang),
|
||||
contact=contact,
|
||||
)
|
||||
return _send([email], "About your Siftlode access request", body, reply_to=admin)
|
||||
return _send([email], mailtext.text("access_invitation.subject", lang), body, reply_to=admin)
|
||||
|
||||
|
||||
def send_role_changed(to: str, role: str, operator: str | None) -> bool:
|
||||
line = (
|
||||
"You've been granted admin access on Siftlode — you can now manage settings, the "
|
||||
"scheduler and users."
|
||||
if role == "admin"
|
||||
else "Your admin access on Siftlode has been removed — you're now a regular user."
|
||||
def send_access_denied(email: str, lang: str) -> bool:
|
||||
admin, contact = _reply_contact(lang)
|
||||
body = mailtext.text("access_denied.body", lang, contact=contact)
|
||||
return _send([email], mailtext.text("access_denied.subject", lang), body, reply_to=admin)
|
||||
|
||||
|
||||
def send_role_changed(to: str, lang: str, role: str, operator: str | None) -> bool:
|
||||
line = mailtext.text(
|
||||
"role_changed.granted" if role == "admin" else "role_changed.revoked", lang
|
||||
)
|
||||
contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else ""
|
||||
body = "Hi,\n\n" + line + contact + "\n\n— Siftlode"
|
||||
return _send([to], "Your Siftlode role has changed", body, reply_to=operator)
|
||||
contact = mailtext.text("contact.operator", lang, operator=operator) if operator else ""
|
||||
body = mailtext.text("role_changed.body", lang, line=line, contact=contact)
|
||||
return _send([to], mailtext.text("role_changed.subject", lang), body, reply_to=operator)
|
||||
|
||||
|
||||
def send_account_unsuspended(to: str, operator: str | None) -> bool:
|
||||
contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else ""
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"Your Siftlode account has been reinstated — the suspension was lifted and you can sign "
|
||||
"in again." + contact + "\n\n— Siftlode"
|
||||
)
|
||||
return _send([to], "Your Siftlode account has been reinstated", body, reply_to=operator)
|
||||
def send_account_unsuspended(to: str, lang: str, operator: str | None) -> bool:
|
||||
contact = mailtext.text("contact.operator", lang, operator=operator) if operator else ""
|
||||
body = mailtext.text("account_unsuspended.body", lang, contact=contact)
|
||||
return _send([to], mailtext.text("account_unsuspended.subject", lang), body, reply_to=operator)
|
||||
|
||||
|
||||
def send_account_deleted(to: str, operator: str | None) -> bool:
|
||||
def send_account_deleted(to: str, lang: str, operator: str | None) -> bool:
|
||||
contact = mailtext.text("contact.deleted", lang, operator=operator) if operator else ""
|
||||
body = mailtext.text("account_deleted.body", lang, contact=contact)
|
||||
return _send([to], mailtext.text("account_deleted.subject", lang), body, reply_to=operator)
|
||||
|
||||
|
||||
def send_account_suspended(to: str, lang: str, operator: str | None) -> bool:
|
||||
contact = (
|
||||
f"\n\nIf you didn't request this, contact the Siftlode operator at {operator}."
|
||||
mailtext.text("contact.suspended", lang, operator=operator)
|
||||
if operator
|
||||
else ""
|
||||
else mailtext.text("contact.suspended_unknown", lang)
|
||||
)
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"Your Siftlode account and all associated data — subscriptions, tags, watch history, "
|
||||
"playlists and settings — have been permanently deleted. Nothing is retained.\n\n"
|
||||
"Thanks for having used Siftlode." + contact + "\n\n— Siftlode"
|
||||
body = mailtext.text("account_suspended.body", lang, contact=contact)
|
||||
return _send([to], mailtext.text("account_suspended.subject", lang), body, reply_to=operator)
|
||||
|
||||
|
||||
def send_admin_new_request(admins: list[str], lang: str, requester: str) -> bool:
|
||||
"""Tell the admins someone is waiting. One call per language group (see
|
||||
`routes.auth.notify_admins_of_request`) — admins are people with their own UI language, and a
|
||||
single blast to all of them would have to pick one and be wrong for the rest.
|
||||
|
||||
The link deep-links straight to the admin Access-requests page (the SPA reads ?admin=…). The
|
||||
requester address is spelled out (mail clients auto-link both the URL and the address), and the
|
||||
reply note is accurate: _send sets Reply-To to the requester, so a reply reaches THEM — not the
|
||||
sending mailbox. (The old copy said "Settings → Account", which was the wrong menu.)
|
||||
"""
|
||||
body = mailtext.text(
|
||||
"admin_new_request.body",
|
||||
lang,
|
||||
requester=requester,
|
||||
link=f"{settings.app_base}/?admin=access-requests",
|
||||
)
|
||||
return _send([to], "Your Siftlode account has been deleted", body, reply_to=operator)
|
||||
subject = mailtext.text("admin_new_request.subject", lang, requester=requester)
|
||||
return _send(admins, subject, body, reply_to=requester)
|
||||
|
||||
|
||||
def send_account_suspended(to: str, operator: str | None) -> bool:
|
||||
contact = (
|
||||
f"If you think this is a mistake or have questions, contact the Siftlode operator "
|
||||
f"at {operator}.\n\n"
|
||||
if operator
|
||||
else "If you think this is a mistake, please contact the Siftlode operator.\n\n"
|
||||
def send_verify_email(to: str, lang: str, link: str, hours: int = 24) -> bool:
|
||||
# The body says what the link's rules are. The reset mail always has; this one left the reader to
|
||||
# discover both by meeting a dead link, which is precisely the silence this epic removes.
|
||||
body = mailtext.text(
|
||||
"verify_email.body", lang, link=link, expiry=mailtext.link_expiry(hours, lang)
|
||||
)
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"A sign-in to your Siftlode account was just attempted, but the account is currently "
|
||||
"suspended, so access was denied.\n\n" + contact + "— Siftlode"
|
||||
)
|
||||
return _send([to], "Your Siftlode account is suspended", body, reply_to=operator)
|
||||
return _send([to], mailtext.text("verify_email.subject", lang), body)
|
||||
|
||||
|
||||
def send_admin_new_request(admins: list[str], requester: str) -> bool:
|
||||
# The link deep-links straight to the admin Access-requests page (the SPA reads ?admin=…). The
|
||||
# requester address is spelled out (mail clients auto-link both the URL and the address), and the
|
||||
# reply note is accurate: _send sets Reply-To to the requester, so a reply reaches THEM — not the
|
||||
# sending mailbox. (The old copy said "Settings → Account", which was the wrong menu.)
|
||||
approve_url = f"{settings.app_base}/?admin=access-requests"
|
||||
body = (
|
||||
f"{requester} just requested access to Siftlode.\n\n"
|
||||
f"Approve or deny it here:\n{approve_url}\n"
|
||||
f"(or in Siftlode: open Users → Access requests)\n\n"
|
||||
f"Requester's email: {requester}\n"
|
||||
f"Replying to this message goes straight to the requester (it's the Reply-To address).\n"
|
||||
)
|
||||
return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester)
|
||||
def send_password_reset(to: str, lang: str, link: str) -> bool:
|
||||
body = mailtext.text("password_reset.body", lang, link=link)
|
||||
return _send([to], mailtext.text("password_reset.subject", lang), body)
|
||||
|
||||
|
||||
def send_verify_email(to: str, link: str) -> bool:
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"Confirm your email to finish creating your Siftlode account:\n\n"
|
||||
f"{link}\n\n"
|
||||
"If you didn't sign up, you can ignore this message.\n\n"
|
||||
"— Siftlode"
|
||||
)
|
||||
return _send([to], "Confirm your Siftlode email", body)
|
||||
|
||||
|
||||
def send_password_reset(to: str, link: str) -> bool:
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"Use this link to set a new Siftlode password (it expires in an hour):\n\n"
|
||||
f"{link}\n\n"
|
||||
"If you didn't request this, you can ignore this message — your password is unchanged.\n\n"
|
||||
"— Siftlode"
|
||||
)
|
||||
return _send([to], "Reset your Siftlode password", body)
|
||||
|
||||
|
||||
def send_test(to: str) -> bool:
|
||||
body = (
|
||||
"This is a test email from Siftlode.\n\n"
|
||||
"If you're reading this, your SMTP settings work.\n\n"
|
||||
"— Siftlode"
|
||||
)
|
||||
return _send([to], "Siftlode SMTP test", body)
|
||||
def send_test(to: str, lang: str) -> bool:
|
||||
return _send([to], mailtext.text("test.subject", lang), mailtext.text("test.body", lang))
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
"""Bilingual text for every outbound mail, and the one resolver that decides which language a
|
||||
recipient gets (E9.5 S7).
|
||||
|
||||
Why a catalog instead of literals in `email.py`: HU/EN parity is a house rule for every user-facing
|
||||
string, and a mail is the one user-facing surface the reader CANNOT re-render by switching the UI
|
||||
language — it arrives once, in whatever language we picked, and stays that way. `test_mail_language.py`
|
||||
enforces that every key carries both languages *and* the same `{placeholders}`, so a half-translated
|
||||
mail fails the gate instead of reaching a person.
|
||||
|
||||
The language is resolved by the REQUEST handler and handed to `send_*` as a plain code. It is never
|
||||
resolved inside the mail: every send runs as a `BackgroundTasks` job, after the request's session is
|
||||
gone — the same reason `_welcome_mail_parts` computes its link in the handler and not in the task.
|
||||
"""
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Invite, User
|
||||
from app.utils import canonical_email
|
||||
|
||||
SUPPORTED: tuple[str, ...] = ("en", "hu")
|
||||
DEFAULT = "en"
|
||||
|
||||
|
||||
def normalize(lang: str | None) -> str:
|
||||
"""Coerce anything to a language we can actually render. Storage is not a guarantee: a
|
||||
`preferences.language` written by an older build (or a locale we no longer ship) must degrade to
|
||||
English rather than raise inside a background task nobody is watching."""
|
||||
return lang if lang in SUPPORTED else DEFAULT
|
||||
|
||||
|
||||
def text(key: str, lang: str | None, **values: object) -> str:
|
||||
"""Render one catalog entry. Unknown languages fall back to English (see `normalize`); an
|
||||
unknown KEY raises, because that is a programming error the tests must surface, not something to
|
||||
paper over with an empty line in someone's inbox."""
|
||||
return MAIL_TEXT[key][normalize(lang)].format(**values)
|
||||
|
||||
|
||||
def link_expiry(hours: int, lang: str) -> str:
|
||||
"""How long a link lasts, in a unit a reader converts on sight. A whole number of days is
|
||||
said in days — the approval mail's token lives seven of them, and "168 hours" is not an answer
|
||||
to "how long have I got". A single day stays "24 hours", which is how everyone writes it."""
|
||||
if hours >= 48 and hours % 24 == 0:
|
||||
return text("duration.days", lang, n=hours // 24)
|
||||
return text("duration.hours", lang, n=hours)
|
||||
|
||||
|
||||
def lang_for_user(user: User | None) -> str:
|
||||
"""The language a signed-up recipient gets: the one they picked in the app."""
|
||||
prefs = (user.preferences or {}) if user is not None else {}
|
||||
return normalize(prefs.get("language"))
|
||||
|
||||
|
||||
def lang_for_email(db: Session, email: str) -> str:
|
||||
"""The language for an address that may not have an account yet: their own account preference,
|
||||
else the language they filled our access-request form in, else English.
|
||||
|
||||
Matched CANONICALLY like every other identity lookup in this epic, and it walks past rows that
|
||||
carry no usable code instead of stopping at the first one — prod really does hold two accounts
|
||||
for a single mailbox, and only one of them may have ever picked a language.
|
||||
"""
|
||||
canon = canonical_email(email)
|
||||
for prefs in (
|
||||
db.execute(select(User.preferences).where(User.email_canonical == canon).order_by(User.id))
|
||||
.scalars()
|
||||
.all()
|
||||
):
|
||||
code = (prefs or {}).get("language")
|
||||
if code in SUPPORTED:
|
||||
return code
|
||||
for code in (
|
||||
db.execute(select(Invite.lang).where(Invite.email_canonical == canon).order_by(Invite.id))
|
||||
.scalars()
|
||||
.all()
|
||||
):
|
||||
if code in SUPPORTED:
|
||||
return code
|
||||
return DEFAULT
|
||||
|
||||
|
||||
# The brand line ("— Siftlode") and the product name are deliberately NOT catalog entries: they are
|
||||
# a name, not a translated string, and giving them keys would invite someone to "translate" them.
|
||||
MAIL_TEXT: dict[str, dict[str, str]] = {
|
||||
# ── How long a link lasts ───────────────────────────────────────────────────────────────
|
||||
# Rendered, not interpolated as a raw hour count: the approval mail's token lives seven days
|
||||
# (E9.5 S6), and "expires in 168 hours" is a unit no reader converts.
|
||||
"duration.hours": {"en": "{n} hours", "hu": "{n} óra"},
|
||||
"duration.days": {"en": "{n} days", "hu": "{n} nap"},
|
||||
# ── Shared closing fragments ────────────────────────────────────────────────────────────────
|
||||
"contact.reply": {
|
||||
"en": "\n\nQuestions? Just reply to this email ({admin}).",
|
||||
"hu": "\n\nKérdésed van? Csak válaszolj erre a levélre ({admin}).",
|
||||
},
|
||||
"contact.operator": {
|
||||
"en": "\n\nQuestions? Contact the operator at {operator}.",
|
||||
"hu": "\n\nKérdésed van? Írj az üzemeltetőnek: {operator}.",
|
||||
},
|
||||
"contact.deleted": {
|
||||
"en": "\n\nIf you didn't request this, contact the Siftlode operator at {operator}.",
|
||||
"hu": "\n\nHa nem te kérted, írj a Siftlode üzemeltetőjének: {operator}.",
|
||||
},
|
||||
"contact.suspended": {
|
||||
"en": (
|
||||
"If you think this is a mistake or have questions, contact the Siftlode operator "
|
||||
"at {operator}.\n\n"
|
||||
),
|
||||
"hu": (
|
||||
"Ha szerinted ez tévedés, vagy kérdésed van, írj a Siftlode üzemeltetőjének: "
|
||||
"{operator}.\n\n"
|
||||
),
|
||||
},
|
||||
"contact.suspended_unknown": {
|
||||
"en": "If you think this is a mistake, please contact the Siftlode operator.\n\n",
|
||||
"hu": "Ha szerinted ez tévedés, keresd a Siftlode üzemeltetőjét.\n\n",
|
||||
},
|
||||
# ── The one paragraph that carries the whole onboarding flow (see email._next_step) ──────────
|
||||
"next_step.verify": {
|
||||
"en": (
|
||||
"You already started an account, so there is just one step left. Confirm your email "
|
||||
"address and you're in:\n\n"
|
||||
"{link}\n\n"
|
||||
"The link works once and expires in {expiry}. If it does, you can ask for a fresh "
|
||||
"one from the sign-in page."
|
||||
),
|
||||
"hu": (
|
||||
"A fiókodat már elkezdted, úgyhogy egyetlen lépés maradt. Erősítsd meg az "
|
||||
"e-mail-címed, és bent is vagy:\n\n"
|
||||
"{link}\n\n"
|
||||
"A link egyszer használható, és {expiry} múlva lejár. Ha lejárt, a belépési oldalon "
|
||||
"kérhetsz újat."
|
||||
),
|
||||
},
|
||||
"next_step.sign_in": {
|
||||
"en": "Your account is ready — just sign in:\n\n{app}",
|
||||
"hu": "A fiókod készen áll — csak lépj be:\n\n{app}",
|
||||
},
|
||||
"next_step.register": {
|
||||
"en": (
|
||||
"Create your account whenever you feel like it:\n\n"
|
||||
"{app}\n\n"
|
||||
"We'll send you a confirmation link to finish, and then you're in."
|
||||
),
|
||||
"hu": (
|
||||
"Hozd létre a fiókod, amikor kedved tartja:\n\n"
|
||||
"{app}\n\n"
|
||||
"Küldünk egy megerősítő linket a befejezéshez, és már bent is vagy."
|
||||
),
|
||||
},
|
||||
# ── Access decision ─────────────────────────────────────────────────────────────────────────
|
||||
"access_approved.subject": {
|
||||
"en": "You're in — your Siftlode access is approved",
|
||||
"hu": "Bent vagy — jóváhagytuk a Siftlode-hozzáférésed",
|
||||
},
|
||||
"access_approved.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Your request to use Siftlode has been approved — welcome aboard.\n\n"
|
||||
"{next_step}\n\n"
|
||||
"If you weren't expecting this, you can ignore the message.\n\n"
|
||||
"— Siftlode{contact}"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"Jóváhagytuk a Siftlode-hozzáférési kérelmed — üdv a fedélzeten.\n\n"
|
||||
"{next_step}\n\n"
|
||||
"Ha nem számítottál erre, nyugodtan hagyd figyelmen kívül ezt a levelet.\n\n"
|
||||
"— Siftlode{contact}"
|
||||
),
|
||||
},
|
||||
"access_invitation.subject": {
|
||||
"en": "You're invited to Siftlode",
|
||||
"hu": "Meghívtak a Siftlode-ra",
|
||||
},
|
||||
"access_invitation.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Someone has opened up Siftlode for you — a quiet, self-hosted reader for your YouTube "
|
||||
"subscriptions. Every upload from the channels you follow lands in one feed you can "
|
||||
"actually filter and sort, with no algorithm deciding what you see, and no Shorts or "
|
||||
"livestreams unless you want them.\n\n"
|
||||
"{next_step}\n\n"
|
||||
"No rush — the door stays open.\n\n"
|
||||
"— Siftlode{contact}"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"Valaki megnyitotta előtted a Siftlode-ot — ez egy csendes, saját üzemeltetésű olvasó "
|
||||
"a YouTube-feliratkozásaidhoz. A követett csatornák minden feltöltése egyetlen "
|
||||
"hírfolyamba érkezik, amit tényleg tudsz szűrni és rendezni; nincs algoritmus, ami "
|
||||
"eldönti, mit látsz, és nincs Shorts vagy élő adás, hacsak nem kéred.\n\n"
|
||||
"{next_step}\n\n"
|
||||
"Nem sürget semmi — az ajtó nyitva marad.\n\n"
|
||||
"— Siftlode{contact}"
|
||||
),
|
||||
},
|
||||
"access_denied.subject": {
|
||||
"en": "About your Siftlode access request",
|
||||
"hu": "A Siftlode-hozzáférési kérelmedről",
|
||||
},
|
||||
"access_denied.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Thanks for your interest in Siftlode. After review, your access request wasn't "
|
||||
"approved this time.\n\n"
|
||||
"If you think this was a mistake, you're welcome to reply and let us know.\n\n"
|
||||
"— Siftlode{contact}"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"Köszönjük az érdeklődésed a Siftlode iránt. Átnéztük a kérelmed, de most nem hagytuk "
|
||||
"jóvá.\n\n"
|
||||
"Ha szerinted ez tévedés, válaszolj nyugodtan erre a levélre.\n\n"
|
||||
"— Siftlode{contact}"
|
||||
),
|
||||
},
|
||||
# ── Account lifecycle ───────────────────────────────────────────────────────────────────────
|
||||
"role_changed.subject": {
|
||||
"en": "Your Siftlode role has changed",
|
||||
"hu": "Megváltozott a szereped a Siftlode-on",
|
||||
},
|
||||
"role_changed.granted": {
|
||||
"en": (
|
||||
"You've been granted admin access on Siftlode — you can now manage settings, the "
|
||||
"scheduler and users."
|
||||
),
|
||||
"hu": (
|
||||
"Admin jogot kaptál a Siftlode-on — mostantól kezelheted a beállításokat, az ütemezőt "
|
||||
"és a felhasználókat."
|
||||
),
|
||||
},
|
||||
"role_changed.revoked": {
|
||||
"en": "Your admin access on Siftlode has been removed — you're now a regular user.",
|
||||
"hu": "Elvettük az admin jogod a Siftlode-on — mostantól sima felhasználó vagy.",
|
||||
},
|
||||
"role_changed.body": {
|
||||
"en": "Hi,\n\n{line}{contact}\n\n— Siftlode",
|
||||
"hu": "Szia,\n\n{line}{contact}\n\n— Siftlode",
|
||||
},
|
||||
"account_unsuspended.subject": {
|
||||
"en": "Your Siftlode account has been reinstated",
|
||||
"hu": "Visszaállítottuk a Siftlode-fiókod",
|
||||
},
|
||||
"account_unsuspended.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Your Siftlode account has been reinstated — the suspension was lifted and you can "
|
||||
"sign in again.{contact}\n\n— Siftlode"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"Visszaállítottuk a Siftlode-fiókod — a felfüggesztést feloldottuk, újra be tudsz "
|
||||
"lépni.{contact}\n\n— Siftlode"
|
||||
),
|
||||
},
|
||||
"account_deleted.subject": {
|
||||
"en": "Your Siftlode account has been deleted",
|
||||
"hu": "Töröltük a Siftlode-fiókod",
|
||||
},
|
||||
"account_deleted.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Your Siftlode account and all associated data — subscriptions, tags, watch history, "
|
||||
"playlists and settings — have been permanently deleted. Nothing is retained.\n\n"
|
||||
"Thanks for having used Siftlode.{contact}\n\n— Siftlode"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"A Siftlode-fiókod és minden hozzá tartozó adat — feliratkozások, címkék, megtekintési "
|
||||
"előzmények, lejátszási listák és beállítások — véglegesen törölve. Semmit nem őrzünk "
|
||||
"meg.\n\n"
|
||||
"Köszönjük, hogy használtad a Siftlode-ot.{contact}\n\n— Siftlode"
|
||||
),
|
||||
},
|
||||
"account_suspended.subject": {
|
||||
"en": "Your Siftlode account is suspended",
|
||||
"hu": "A Siftlode-fiókod fel van függesztve",
|
||||
},
|
||||
"account_suspended.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"A sign-in to your Siftlode account was just attempted, but the account is currently "
|
||||
"suspended, so access was denied.\n\n{contact}— Siftlode"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"Valaki most próbált belépni a Siftlode-fiókodba, de a fiók jelenleg fel van "
|
||||
"függesztve, ezért a belépést elutasítottuk.\n\n{contact}— Siftlode"
|
||||
),
|
||||
},
|
||||
# ── To the admins ───────────────────────────────────────────────────────────────────────────
|
||||
"admin_new_request.subject": {
|
||||
"en": "Siftlode access request from {requester}",
|
||||
"hu": "Siftlode hozzáférési kérelem: {requester}",
|
||||
},
|
||||
"admin_new_request.body": {
|
||||
"en": (
|
||||
"{requester} just requested access to Siftlode.\n\n"
|
||||
"Approve or deny it here:\n{link}\n"
|
||||
"(or in Siftlode: open Users → Access requests)\n\n"
|
||||
"Requester's email: {requester}\n"
|
||||
"Replying to this message goes straight to the requester (it's the Reply-To address).\n"
|
||||
),
|
||||
"hu": (
|
||||
"{requester} most kért hozzáférést a Siftlode-hoz.\n\n"
|
||||
"Itt hagyhatod jóvá vagy utasíthatod el:\n{link}\n"
|
||||
"(vagy a Siftlode-ban: Felhasználók → Hozzáférési kérések)\n\n"
|
||||
"A kérelmező e-mail-címe: {requester}\n"
|
||||
"Ha erre a levélre válaszolsz, az egyenesen a kérelmezőhöz megy (ő a Reply-To cím).\n"
|
||||
),
|
||||
},
|
||||
# ── Link mails ──────────────────────────────────────────────────────────────────────────────
|
||||
"verify_email.subject": {
|
||||
"en": "Confirm your Siftlode email",
|
||||
"hu": "Erősítsd meg a Siftlode e-mail-címed",
|
||||
},
|
||||
"verify_email.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Confirm your email to finish creating your Siftlode account:\n\n"
|
||||
"{link}\n\n"
|
||||
"The link works once and expires in {expiry}. If it does, you can ask for a fresh "
|
||||
"one from the sign-in page.\n\n"
|
||||
"If you didn't sign up, you can ignore this message.\n\n"
|
||||
"— Siftlode"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"Erősítsd meg az e-mail-címed, hogy elkészüljön a Siftlode-fiókod:\n\n"
|
||||
"{link}\n\n"
|
||||
"A link egyszer használható, és {expiry} múlva lejár. Ha lejárt, a belépési oldalon "
|
||||
"kérhetsz újat.\n\n"
|
||||
"Ha nem te regisztráltál, nyugodtan hagyd figyelmen kívül ezt a levelet.\n\n"
|
||||
"— Siftlode"
|
||||
),
|
||||
},
|
||||
"password_reset.subject": {
|
||||
"en": "Reset your Siftlode password",
|
||||
"hu": "Állíts be új Siftlode-jelszót",
|
||||
},
|
||||
"password_reset.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Use this link to set a new Siftlode password (it expires in an hour):\n\n"
|
||||
"{link}\n\n"
|
||||
"If you didn't request this, you can ignore this message — your password is "
|
||||
"unchanged.\n\n"
|
||||
"— Siftlode"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"Ezzel a linkkel állíthatsz be új Siftlode-jelszót (egy óra múlva lejár):\n\n"
|
||||
"{link}\n\n"
|
||||
"Ha nem te kérted, hagyd figyelmen kívül ezt a levelet — a jelszavad változatlan.\n\n"
|
||||
"— Siftlode"
|
||||
),
|
||||
},
|
||||
"test.subject": {
|
||||
"en": "Siftlode SMTP test",
|
||||
"hu": "Siftlode SMTP-teszt",
|
||||
},
|
||||
"test.body": {
|
||||
"en": (
|
||||
"This is a test email from Siftlode.\n\n"
|
||||
"If you're reading this, your SMTP settings work.\n\n"
|
||||
"— Siftlode"
|
||||
),
|
||||
"hu": (
|
||||
"Ez egy teszt e-mail a Siftlode-tól.\n\n"
|
||||
"Ha ezt olvasod, az SMTP-beállításaid működnek.\n\n"
|
||||
"— Siftlode"
|
||||
),
|
||||
},
|
||||
}
|
||||
+67
-3
@@ -17,9 +17,10 @@ from sqlalchemy import (
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||
|
||||
from app.db import Base
|
||||
from app.utils import canonical_email
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
@@ -29,6 +30,21 @@ class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class CanonicalEmailMixin:
|
||||
"""Keeps `email_canonical` derived from `email` on every write.
|
||||
|
||||
The invariant — "identity is matched on the canonical form, mail is sent to the typed one" —
|
||||
lives HERE, in code, instead of in a comment every write site has to remember. That distinction
|
||||
is what the 2026-09-03 lockout was made of: one gate lowercased, nothing else did, and an
|
||||
approved user's Google sign-in silently became a second access request.
|
||||
"""
|
||||
|
||||
@validates("email")
|
||||
def _derive_email_canonical(self, _key: str, value: str | None) -> str | None:
|
||||
self.email_canonical = canonical_email(value)
|
||||
return value
|
||||
|
||||
|
||||
class UpdatedAtMixin:
|
||||
"""Adds an `updated_at` that server-defaults on insert and bumps on every UPDATE."""
|
||||
|
||||
@@ -37,7 +53,7 @@ class UpdatedAtMixin:
|
||||
)
|
||||
|
||||
|
||||
class User(Base, TimestampMixin):
|
||||
class User(Base, TimestampMixin, CanonicalEmailMixin):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
@@ -45,8 +61,26 @@ class User(Base, TimestampMixin):
|
||||
# multiple NULLs under a unique index). Set on Google sign-in / in-app linking.
|
||||
google_sub: Mapped[str | None] = mapped_column(String(64), unique=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||
# The same mailbox, in the form used to MATCH identities (utils.canonical_email) — Gmail dots and
|
||||
# `+tags` folded away. Kept by the validator below so no write site can forget it. Deliberately
|
||||
# NOT unique: prod already carries two rows for one mailbox (a password registration and the
|
||||
# Google sign-in that failed to find it), and a migration must not merge accounts on its own.
|
||||
email_canonical: Mapped[str] = mapped_column(String(320), index=True, server_default="")
|
||||
# The address Google asserted for this account, recorded at sign-in / link time. NOT the same
|
||||
# thing as `email`: since E9.5 S3 a Google sign-in no longer overwrites the address the user
|
||||
# registered with, so a deliberate `me+siftlode@gmail.com` survives — and this is where the other
|
||||
# half of the truth lives, for the UI to show when the two differ. NULL until the next Google
|
||||
# sign-in for accounts linked before this column existed.
|
||||
google_email: Mapped[str | None] = mapped_column(String(320))
|
||||
# argon2id hash for email+password login; NULL for Google-only / demo accounts.
|
||||
password_hash: Mapped[str | None] = mapped_column(String(255))
|
||||
# When SEC-10 dropped a preset password on Google adoption (see the single caller of
|
||||
# auth.revoke_preset_password_if_unverified; admin approval no longer revokes — E9.5 S1). Without
|
||||
# this trace a revoked account was indistinguishable from a Google-only one, so it failed the
|
||||
# password-reset eligibility check and could never be recovered — it could neither sign in nor
|
||||
# reset. Set = "had a password, lost it to the guard" -> reset-eligible. Cleared whenever a
|
||||
# password is set again (reset-confirm / set-password).
|
||||
password_revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# Email ownership proven (Google sign-in is implicitly verified; password registration
|
||||
# verifies via a link). Login requires this AND is_active.
|
||||
email_verified: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
@@ -114,7 +148,7 @@ class AuthToken(Base, TimestampMixin):
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class Invite(Base):
|
||||
class Invite(Base, CanonicalEmailMixin):
|
||||
"""Access-request / whitelist row. Source of truth for who may sign in; the env
|
||||
ALLOWED_EMAILS/ADMIN_EMAILS act only as a bootstrap fallback (see auth.is_allowed)."""
|
||||
|
||||
@@ -122,6 +156,9 @@ class Invite(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||
# See User.email_canonical. Not unique here either: prod has approved invites for both spellings
|
||||
# of the same mailbox, dating back to 2026-06-16.
|
||||
email_canonical: Mapped[str] = mapped_column(String(320), index=True, server_default="")
|
||||
# pending | approved | denied
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending", server_default="pending")
|
||||
note: Mapped[str | None] = mapped_column(Text)
|
||||
@@ -130,6 +167,17 @@ class Invite(Base):
|
||||
)
|
||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email
|
||||
# When the admin last SENT this person an invitation from the whitelist row. Adding an address
|
||||
# stays silent — it opens a door, it does not knock — so telling them is a separate, deliberate
|
||||
# act, and this is what stops it happening twice to a real person.
|
||||
invited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# The language this person asked us in ("en"/"hu"), captured when the request was made: the page
|
||||
# their form was on, or the locale Google reported for a refused sign-in. NULL for a row the
|
||||
# admin whitelisted by hand — nobody has told us anything about that mailbox, which is exactly
|
||||
# why the invitation send asks (E9.5 S7). Captured rather than derived for the same reason
|
||||
# `preferences.language` and `google_email` are: the signal exists only in that one moment and
|
||||
# cannot be backfilled afterwards.
|
||||
lang: Mapped[str | None] = mapped_column(String(8))
|
||||
|
||||
|
||||
class DemoWhitelist(Base, TimestampMixin):
|
||||
@@ -1127,6 +1175,22 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
||||
# Browser-playability class from ffprobe: "direct" | "remux" | "transcode" (None = unprobed).
|
||||
playable: Mapped[str | None] = mapped_column(String(12), index=True)
|
||||
probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# Transcode HW-capability facet (E7 playback-class filter). NULL for non-transcode items; for a
|
||||
# `playable="transcode"` item: True = the prod iGPU can hardware-transcode it (codec is HW-decodable
|
||||
# AND the source is 8-bit SDR), False = software libx264 only (AV1/XviD, or a 10-bit/HDR source that
|
||||
# breaks the VAAPI nv12 pipeline — see plex/stream.py). Derived by `plex.sync.transcode_hw_capable`:
|
||||
# set codec-only at sync (bit depth unknown from the cheap listing), then refined by the bit-depth
|
||||
# backfill (`_probe_media_batch`) which downgrades a 10-bit/HDR HEVC to False. The one filterable
|
||||
# home of the invariant `_want_hw`'s source gate computes at play time — kept in sync via the shared
|
||||
# classifier so the catalog facet and the runtime encoder choice never disagree by codec.
|
||||
transcode_hw: Mapped[bool | None] = mapped_column(Boolean, index=True)
|
||||
# Video bit depth (8/10/12…) from the Plex media Stream, filled by the bit-depth backfill. NULL =
|
||||
# not yet probed. Surfaced on the info page (e.g. "HEVC · 10-bit") and feeds `transcode_hw`.
|
||||
video_bit_depth: Mapped[int | None] = mapped_column(Integer)
|
||||
# Watermark for the bit-depth backfill: when this item's media Stream was last probed for
|
||||
# bit-depth/HDR. NULL = never (the backfill's work-list). Only HW-decodable transcode items are
|
||||
# probed (the rest are SW-only by codec and need no bit-depth); see `_probe_media_batch`.
|
||||
media_probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# Intro/credit markers: [{"type": "intro"|"credits", "start_s": int, "end_s": int}, ...]
|
||||
markers: Mapped[list | None] = mapped_column(JSON)
|
||||
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
|
||||
@@ -33,6 +33,7 @@ def movie_card(it: PlexItem, st: PlexState | None) -> dict:
|
||||
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||
"art": _art_url(it.rating_key, it.art_key),
|
||||
"playable": it.playable,
|
||||
"transcode_hw": it.transcode_hw, # HW/SW transcode split → the 4-way corner-dot badge
|
||||
"status": st.status if st else "new",
|
||||
"position_seconds": st.position_seconds if st else 0,
|
||||
}
|
||||
@@ -65,6 +66,7 @@ def episode_card(e: PlexItem, st: PlexState | None) -> dict:
|
||||
"duration_seconds": e.duration_s,
|
||||
"thumb": f"/api/plex/image/{e.rating_key}",
|
||||
"playable": e.playable,
|
||||
"transcode_hw": e.transcode_hw, # HW/SW transcode split → the 4-way corner-dot badge
|
||||
"status": st.status if st else "new",
|
||||
"position_seconds": st.position_seconds if st else 0,
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import and_, case, func, or_, select, type_coerce
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
from app.models import PlexItem
|
||||
|
||||
TS_CONFIG = "public.unaccent_simple"
|
||||
|
||||
# --- Language-aware column pickers (E5 S6) ----------------------------------
|
||||
@@ -206,6 +208,33 @@ def apply_meta_filters(q, model, p: dict, lang: str = "en"):
|
||||
return q.where(*conds) if conds else q
|
||||
|
||||
|
||||
# --- Playback-class filter (E7) — MOVIE-ONLY (a show is an aggregate with no single class) ---------
|
||||
# The 4 classes the feed filter + facet expose, derived from the stored `playable` + `transcode_hw`.
|
||||
# ONE definition (this SQL CASE) so the filter WHERE and the facet counts can't drift: `playable_conds`
|
||||
# filters on it, `facets` groups by it. transcode_hw IS NOT True groups NULL (unprobed) with software.
|
||||
# Public — it is the shared source of truth imported by routes.plex.browse (facet order + zero-fill).
|
||||
PLAYBACK_CLASSES = ("direct", "remux", "transcode_hw", "transcode_sw")
|
||||
|
||||
|
||||
def playback_class_expr():
|
||||
"""SQL CASE mapping a PlexItem (movie) to its playback-class string, or NULL. The HW branch must
|
||||
precede the generic transcode branch (ordered evaluation)."""
|
||||
return case(
|
||||
(PlexItem.playable == "direct", "direct"),
|
||||
(PlexItem.playable == "remux", "remux"),
|
||||
(and_(PlexItem.playable == "transcode", PlexItem.transcode_hw.is_(True)), "transcode_hw"),
|
||||
(PlexItem.playable == "transcode", "transcode_sw"),
|
||||
else_=None,
|
||||
)
|
||||
|
||||
|
||||
def playable_conds(sel: list[str]) -> list:
|
||||
"""A one-element condition list (or empty) selecting the given playback classes — an OR via
|
||||
`class IN (...)` over the shared CASE. Unknown keys are dropped."""
|
||||
keep = [c for c in sel if c in PLAYBACK_CLASSES]
|
||||
return [playback_class_expr().in_(keep)] if keep else []
|
||||
|
||||
|
||||
@dataclass
|
||||
class LibraryFilters:
|
||||
"""The shared sidebar filter query-params for /library + /facets, declared once and injected via
|
||||
@@ -228,6 +257,7 @@ class LibraryFilters:
|
||||
keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral
|
||||
collections: str | None = None # CSV of collection rating_keys
|
||||
collection_mode: str = "any" # how multiple collections combine: any (OR, default) | all (AND)
|
||||
playable: str | None = None # CSV of playback classes: direct|remux|transcode_hw|transcode_sw
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
@@ -247,6 +277,7 @@ class LibraryFilters:
|
||||
"keywords": self.keywords,
|
||||
"collections": self.collections,
|
||||
"collection_mode": self.collection_mode,
|
||||
"playable": self.playable,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+282
-78
@@ -34,6 +34,7 @@ from app.config import settings
|
||||
from app.downloads.edit import encode_thread_cap
|
||||
from app.models import PlexItem
|
||||
from app.plex import paths
|
||||
from app.plex.sync import HW_DECODABLE_CODECS
|
||||
|
||||
log = logging.getLogger("siftlode.plex")
|
||||
|
||||
@@ -96,12 +97,14 @@ class HlsSession:
|
||||
entry: str,
|
||||
transcode: bool = False,
|
||||
hw: bool = False,
|
||||
tonemap: bool = False,
|
||||
):
|
||||
self.key = key
|
||||
self.dir = directory
|
||||
self.proc = proc
|
||||
self.transcode = transcode # video re-encoded (non-h264 source), not stream-copied
|
||||
self.hw = hw # transcode ran on the VAAPI hardware encoder (else libx264 software)
|
||||
self.tonemap = tonemap # HW HDR/10-bit path: OpenCL tonemap between VAAPI decode and encode
|
||||
self.start_s = start_s # the REQUESTED seek offset
|
||||
# The REAL media start: with `-ss X -c:v copy` ffmpeg can only cut at the nearest earlier
|
||||
# keyframe K<=X, and hls.js zero-bases the timeline to K (so video.currentTime=0 is K, not X).
|
||||
@@ -143,7 +146,7 @@ _HLS_TAIL = [
|
||||
|
||||
# --- Transcode (playable="transcode") video re-encode -----------------------------------------
|
||||
# The remux path stream-copies the video (`-c:v copy`); a non-h264 source can't be copied and must be
|
||||
# re-encoded to H.264. Two encoders, chosen per-session by `_want_hw`:
|
||||
# re-encoded to H.264. The encoder + tonemap are chosen per-session by `_select_encode`:
|
||||
# * VAAPI hardware (`h264_vaapi`) — the prod default once the iGPU is passed in. A full GPU pipeline
|
||||
# (decode + scale + encode all on-device) offloads the CPU almost entirely, so several concurrent
|
||||
# 1080p transcodes are fine and the API stays responsive.
|
||||
@@ -153,24 +156,125 @@ _HLS_TAIL = [
|
||||
# Everything else — the `-copyts`/`_HLS_TAIL`/segment/seek-restart model and the AAC audio path — is
|
||||
# unchanged, so hls.js, subtitles, multi-audio and seeking keep working exactly as for remux.
|
||||
_VAAPI_DEVICE = "/dev/dri/renderD128"
|
||||
# Where an OpenCL ICD registers its driver. The Intel NEO runtime (intel-opencl-icd) drops an .icd file
|
||||
# here; its presence is the precondition for the HW HDR→SDR tonemap graph (see _opencl_present).
|
||||
_OPENCL_ICD_DIR = Path("/etc/OpenCL/vendors")
|
||||
# Codecs the prod iGPU (UHD 630 / Quick Sync Gen 9.5) can hardware-DECODE — the precondition for the
|
||||
# full-GPU VAAPI pipeline. A source outside this set (AV1, MPEG-4 ASP/XviD…) falls back to software even
|
||||
# when the render node is present, because the GPU can't decode it to feed its own encoder.
|
||||
_HW_DECODABLE = {"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"}
|
||||
# full-GPU VAAPI pipeline. A source outside it (AV1, MPEG-4 ASP/XviD…) falls back to software even with
|
||||
# the render node present, because the GPU can't decode it to feed its own encoder. THE single source is
|
||||
# plex.sync, so the runtime encoder choice here and the catalog `transcode_hw` facet can't disagree by
|
||||
# codec; aliased to the old name to keep the call sites below unchanged.
|
||||
_HW_DECODABLE = HW_DECODABLE_CODECS
|
||||
# Color-transfer values that mark a true-HDR source (PQ / HLG). A HW-decodable HDR source can't take the
|
||||
# plain scale_vaapi=format=nv12 path (Gen 9.5 VPP refuses it) — it needs the OpenCL tonemap graph
|
||||
# (_venc_opts tonemap branch) when the OpenCL runtime is present, else software libx264. A 10-bit source
|
||||
# WITHOUT one of these transfers is SDR, not HDR, and must not be tone-mapped. See _probe_video_kind.
|
||||
_HDR_TRANSFERS = {"smpte2084", "arib-std-b67"}
|
||||
|
||||
|
||||
def _hwaccel_in_opts(hw: bool) -> list[str]:
|
||||
# The three source classes that drive the transcode-pipeline choice (see _probe_video_kind).
|
||||
_VK_HWSAFE = "hwsafe" # 8-bit SDR — the plain scale_vaapi=format=nv12 GPU path
|
||||
_VK_HDR = "hdr" # PQ/HLG HDR — needs the OpenCL tonemap graph (or software if no runtime)
|
||||
_VK_SDR10 = "sdr10" # 10-bit *SDR* — a plain 10→8-bit downconvert, NEVER the HDR tone curve
|
||||
|
||||
|
||||
def _probe_video_kind(src: Path) -> str:
|
||||
"""Classify the source for the transcode pipeline — one of `_VK_HWSAFE` / `_VK_HDR` / `_VK_SDR10`:
|
||||
|
||||
* `_VK_HWSAFE` — 8-bit SDR: safe for the plain VAAPI path (`scale_vaapi=format=nv12` + h264_vaapi).
|
||||
* `_VK_HDR` — PQ/HLG HDR (any depth): the plain scale path breaks on Gen 9.5 VPP, so it needs the
|
||||
OpenCL tonemap graph (`tonemap_opencl=hable`) when the runtime is present, else software libx264.
|
||||
* `_VK_SDR10` — 10-bit but *SDR* (bt709 etc.): NOT HDR. Applying the HDR hable tone curve here would
|
||||
visibly alter an SDR picture (crushed highlights/midtones) where only a 10→8-bit downconvert was
|
||||
needed, so it must NOT take the tonemap graph — it goes to software libx264, which CPU-decodes and
|
||||
downconverts to 8-bit yuv420p exactly. (Kept off the plain scale_vaapi 10-bit path too: that path
|
||||
is unverified on Gen 9.5 VPP, and software is the known-correct downconvert.)
|
||||
|
||||
Ground truth from ffprobe (bit depth via pix_fmt / bits_per_raw_sample, HDR via color_transfer); the
|
||||
Plex mirror doesn't store these. Returns `_VK_HWSAFE` on any probe failure: the runtime HW→SW fallback
|
||||
in start_session backstops a HW session that still produces nothing, so a probe miss only costs the
|
||||
rare wasted first attempt, never a stranded item."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=pix_fmt,bits_per_raw_sample,color_transfer",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1",
|
||||
str(src),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return _VK_HWSAFE
|
||||
pix_fmt = bits = transfer = ""
|
||||
for line in out.stdout.splitlines():
|
||||
k, _, v = line.strip().partition("=")
|
||||
v = v.strip().lower()
|
||||
if k == "pix_fmt":
|
||||
pix_fmt = v
|
||||
elif k == "bits_per_raw_sample":
|
||||
bits = v
|
||||
elif k == "color_transfer":
|
||||
transfer = v
|
||||
if transfer in _HDR_TRANSFERS:
|
||||
return _VK_HDR # PQ/HLG — needs a real HDR→SDR tonemap, not a plain downconvert
|
||||
# >8-bit but not HDR = 10-bit SDR. bits_per_raw_sample OR the pix_fmt bit-depth suffix
|
||||
# (yuv420p10le, p010le, yuv444p12le…) signals >8-bit even when the other is absent.
|
||||
if (bits.isdigit() and int(bits) > 8) or re.search(r"(?:10|12|16)(?:le|be)$", pix_fmt):
|
||||
return _VK_SDR10
|
||||
return _VK_HWSAFE
|
||||
|
||||
|
||||
def _opencl_present() -> bool:
|
||||
"""Whether an OpenCL runtime (Intel NEO / intel-opencl-icd) is installed — the precondition for the
|
||||
HW HDR→SDR tonemap graph. Gen 9.5 VPP can't tonemap natively (tonemap_vaapi fails), so the working
|
||||
HW path for a 10-bit/HDR source is VAAPI-decode → OpenCL tonemap → VAAPI-encode, which needs an ICD
|
||||
registered here. Absent on the dev box and until the image bakes NEO in → those sources take
|
||||
software libx264. Its own function so it's monkeypatchable in tests, mirroring _render_node_present."""
|
||||
try:
|
||||
return any(_OPENCL_ICD_DIR.glob("*.icd"))
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _hwaccel_in_opts(hw: bool, tonemap: bool = False) -> list[str]:
|
||||
"""VAAPI decode options, placed before `-i` so the source is decoded (and kept) on the GPU.
|
||||
`-hwaccel_output_format vaapi` keeps frames as GPU surfaces all the way to `h264_vaapi` — no
|
||||
round-trip to system memory. Empty for the software path."""
|
||||
round-trip to system memory. Empty for the software path.
|
||||
|
||||
The tonemap variant additionally derives an OpenCL device FROM the VAAPI one (`opencl=ocl@va`) so
|
||||
tonemap_opencl can map the decoded surfaces zero-copy (see _venc_opts); it needs named devices, so it
|
||||
uses `-init_hw_device` + `-hwaccel_device` rather than the plain path's implicit `-vaapi_device`."""
|
||||
if not hw:
|
||||
return []
|
||||
if tonemap:
|
||||
return [
|
||||
"-init_hw_device",
|
||||
f"vaapi=va:{_VAAPI_DEVICE}",
|
||||
"-init_hw_device",
|
||||
"opencl=ocl@va",
|
||||
"-hwaccel",
|
||||
"vaapi",
|
||||
"-hwaccel_output_format",
|
||||
"vaapi",
|
||||
"-hwaccel_device",
|
||||
"va",
|
||||
]
|
||||
return ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", _VAAPI_DEVICE]
|
||||
|
||||
|
||||
def _venc_opts(
|
||||
transcode: bool,
|
||||
hw: bool,
|
||||
tonemap: bool = False,
|
||||
preset: str = "veryfast",
|
||||
crf: int = 20,
|
||||
max_height: int = 0,
|
||||
@@ -183,6 +287,23 @@ def _venc_opts(
|
||||
if not transcode:
|
||||
return ["-c:v", "copy"]
|
||||
force_kf = ["-force_key_frames", f"expr:gte(t,n_forced*{_SEG_SECONDS})"]
|
||||
if hw and tonemap:
|
||||
# HDR/10-bit HW path: Gen 9.5 VPP can't tonemap (scale_vaapi=format=nv12 fails), so the decoded
|
||||
# VAAPI surfaces are mapped to OpenCL (hwmap=derive_device=opencl), tone-mapped HDR→SDR to 8-bit
|
||||
# nv12 there (tonemap_opencl, hable curve, bt709 out), then mapped back to VAAPI (reverse=1) for
|
||||
# h264_vaapi. `-filter_hw_device ocl` tells the filter graph which device to run the OpenCL
|
||||
# filters on. Full GPU pipeline — ~1 CPU core, several×realtime on the UHD 630 (measured).
|
||||
return [
|
||||
"-filter_hw_device",
|
||||
"ocl",
|
||||
"-vf",
|
||||
"hwmap=derive_device=opencl,"
|
||||
"tonemap_opencl=tonemap=hable:transfer=bt709:matrix=bt709:primaries=bt709:format=nv12,"
|
||||
"hwmap=derive_device=vaapi:reverse=1",
|
||||
"-c:v",
|
||||
"h264_vaapi",
|
||||
*force_kf,
|
||||
]
|
||||
if hw:
|
||||
# scale_vaapi with format=nv12 downconverts a 10-bit (p010) HEVC source to 8-bit nv12 so
|
||||
# h264_vaapi (8-bit High) can encode it; for an 8-bit source it's a cheap format pass-through.
|
||||
@@ -220,20 +341,53 @@ def _render_node_present() -> bool:
|
||||
return Path(_VAAPI_DEVICE).exists()
|
||||
|
||||
|
||||
def _want_hw(db: DbSession, item: PlexItem, transcode: bool) -> bool:
|
||||
"""Whether to use the VAAPI hardware encoder for this transcode session, honouring the admin
|
||||
`plex_transcode_hw` policy: "off" → always software; "on" → VAAPI wherever the render node exists
|
||||
(trusting the admin, skipping the source-decodability gate); "auto" (default) → VAAPI only when the
|
||||
render node is present AND the iGPU can hardware-decode the source (else the full-GPU pipeline can't
|
||||
feed itself → software libx264)."""
|
||||
def _select_encode(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> tuple[bool, bool]:
|
||||
"""Pick the transcode video pipeline for this session — returns `(hw, tonemap)`:
|
||||
* `(False, False)` → software libx264 (the fallback: no render node, non-decodable source, or a
|
||||
10-bit/HDR source with no OpenCL runtime to tonemap on the GPU).
|
||||
* `(True, False)` → full-GPU VAAPI (`scale_vaapi=format=nv12` + `h264_vaapi`) — an 8-bit SDR source.
|
||||
* `(True, True)` → VAAPI decode → OpenCL tonemap → VAAPI encode — a true-HDR (PQ/HLG) source, when
|
||||
the OpenCL runtime is present (Gen 9.5 VPP can't tonemap natively).
|
||||
|
||||
A 10-bit *SDR* source is deliberately NOT a `(True, True)` case: it needs a plain 10→8-bit downconvert,
|
||||
not the HDR tone curve (which would visibly darken/flatten an SDR picture), so it drops to software.
|
||||
|
||||
Honours the admin `plex_transcode_hw` policy: "off" → always software; "on" → HW wherever the render
|
||||
node exists (trusting the admin, skipping the decodability gate — the runtime HW→SW fallback still
|
||||
backstops a session that produces nothing — but still routing a true-HDR source through the tonemap
|
||||
graph so it doesn't hit the scale_vaapi wall); "auto" (default) → HW only when the render node is
|
||||
present AND the iGPU can hardware-decode the source, with tonemap chosen only for a true-HDR (PQ/HLG)
|
||||
source that has an OpenCL runtime, while a 10-bit SDR source drops to software."""
|
||||
if not transcode:
|
||||
return False
|
||||
return (False, False)
|
||||
mode = (sysconfig.get_str(db, "plex_transcode_hw") or "auto").lower()
|
||||
if mode == "off" or not _render_node_present():
|
||||
return False
|
||||
return (False, False)
|
||||
if mode == "on":
|
||||
return True
|
||||
return (item.codec_video or "").lower() in _HW_DECODABLE
|
||||
# Trust the admin: HW wherever the render node exists, skipping the decodability gate (the runtime
|
||||
# backstop catches a miss). Only a true-HDR (PQ/HLG) source needs the tonemap graph — a 10-bit SDR
|
||||
# source takes the plain scale path here, NOT the HDR tone curve. Probe only when OpenCL exists
|
||||
# (short-circuit), so "on" stays probe-free where tonemap can't apply anyway.
|
||||
tonemap = _opencl_present() and _probe_video_kind(src) == _VK_HDR
|
||||
return (True, tonemap)
|
||||
# auto
|
||||
if (item.codec_video or "").lower() not in _HW_DECODABLE:
|
||||
return (False, False)
|
||||
kind = _probe_video_kind(src)
|
||||
if kind == _VK_HWSAFE:
|
||||
return (True, False) # 8-bit SDR → full-GPU scale path
|
||||
if kind == _VK_HDR:
|
||||
# PQ/HLG: HW OpenCL tonemap when the runtime is present, else software libx264.
|
||||
return (True, True) if _opencl_present() else (False, False)
|
||||
# _VK_SDR10 — 10-bit SDR: a plain 10→8-bit downconvert, done exactly on the CPU. Never the HDR tone
|
||||
# curve (it would alter an SDR picture), and off the unverified scale_vaapi 10-bit path — software.
|
||||
return (False, False)
|
||||
|
||||
|
||||
def _want_hw(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> bool:
|
||||
"""The HW gate on its own (the `hw` half of `_select_encode`). A readability shim for the tests that
|
||||
assert only whether a session runs on the GPU (production decides via `_select_encode` directly)."""
|
||||
return _select_encode(db, item, transcode, src)[0]
|
||||
|
||||
|
||||
# CPU-nice increment for the SOFTWARE transcode child. The Plex ffmpeg is a Popen in the long-lived API
|
||||
@@ -288,15 +442,17 @@ def _ffmpeg_cmd(
|
||||
audio_langs: list[str] | None = None,
|
||||
transcode: bool = False,
|
||||
hw: bool = False,
|
||||
tonemap: bool = False,
|
||||
preset: str = "veryfast",
|
||||
crf: int = 20,
|
||||
max_height: int = 0,
|
||||
) -> list[str]:
|
||||
# For a transcode session the video is re-encoded (VAAPI or libx264) instead of stream-copied; the
|
||||
# hwaccel decode options go before -i, the encoder args replace `-c:v copy` below. Everything else is
|
||||
# identical to remux.
|
||||
venc = _venc_opts(transcode, hw, preset, crf, max_height)
|
||||
args = ["ffmpeg", "-nostdin", "-loglevel", "error", *_hwaccel_in_opts(hw)]
|
||||
# identical to remux. `tonemap` is the HDR/10-bit HW variant (OpenCL tonemap between VAAPI decode and
|
||||
# encode) — it needs matching decode-side (named devices) and encode-side (the OpenCL filter graph) opts.
|
||||
venc = _venc_opts(transcode, hw, tonemap, preset, crf, max_height)
|
||||
args = ["ffmpeg", "-nostdin", "-loglevel", "error", *_hwaccel_in_opts(hw, tonemap)]
|
||||
# Keep ORIGINAL timestamps on the output so the first segment carries the true absolute PTS of the
|
||||
# keyframe ffmpeg actually seeked to. hls.js still zero-bases playback to that PTS; we read it back
|
||||
# (see _probe_first_pts) to learn the real content offset. Without -copyts the segment PTS is
|
||||
@@ -425,67 +581,104 @@ def start_session(
|
||||
return None
|
||||
key = item.rating_key
|
||||
start_s = max(0.0, float(start_s))
|
||||
hw = _want_hw(db, item, transcode)
|
||||
# Software-fallback encode tuning — read only on the SW transcode path (the VAAPI path and remux
|
||||
# ignore preset/crf/max_height, and remux must not touch the DB for them).
|
||||
preset, crf, max_height = "veryfast", 20, 0
|
||||
if transcode and not hw:
|
||||
preset = sysconfig.get_str(db, "plex_transcode_preset") or "veryfast"
|
||||
crf = sysconfig.get_int(db, "plex_transcode_crf")
|
||||
max_height = sysconfig.get_int(db, "plex_transcode_max_height")
|
||||
audio_langs = _probe_audio_langs(src) if multi else []
|
||||
is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks
|
||||
# Nice the SOFTWARE transcode child (not the HW path, and not remux) so a CPU-bound re-encode can't
|
||||
# starve the co-located API/playback. preexec_fn runs in the forked child (POSIX); on a platform
|
||||
# without fork (the API always runs in a Linux container in prod) it's simply omitted.
|
||||
preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None
|
||||
with _lock:
|
||||
old = _sessions.pop(key, None)
|
||||
if old is not None:
|
||||
_kill(old)
|
||||
tag = "multi" if is_multi else str(audio_ord)
|
||||
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}"
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
proc = subprocess.Popen(
|
||||
_ffmpeg_cmd(
|
||||
src,
|
||||
start_s,
|
||||
directory,
|
||||
audio_ord,
|
||||
aoff,
|
||||
audio_langs if is_multi else None,
|
||||
transcode=transcode,
|
||||
hw=hw,
|
||||
preset=preset,
|
||||
crf=crf,
|
||||
max_height=max_height,
|
||||
),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
preexec_fn=preexec,
|
||||
)
|
||||
s = HlsSession(
|
||||
key,
|
||||
directory,
|
||||
proc,
|
||||
start_s,
|
||||
"master.m3u8" if is_multi else "index.m3u8",
|
||||
transcode=transcode,
|
||||
hw=hw,
|
||||
)
|
||||
_sessions[key] = s
|
||||
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes);
|
||||
# at least 1 so playback can't be capped to zero.
|
||||
_enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes")))
|
||||
tag = "multi" if is_multi else str(audio_ord)
|
||||
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}"
|
||||
|
||||
# Wait for the first video segment, then warm up a few more — all outside the lock (this blocks on
|
||||
# ffmpeg producing them, and we must not hold up other sessions).
|
||||
def _seg(i: int) -> Path:
|
||||
# multi-audio: the video rendition is variant v0 → seg_{i}_0.ts; single-audio → seg_{i}.ts
|
||||
return directory / (f"seg_{i}_0.ts" if is_multi else f"seg_{i}.ts")
|
||||
|
||||
if wait_for(_seg(0), timeout=25.0):
|
||||
def _spawn(
|
||||
hw: bool, tonemap: bool = False, *, replace_only: "HlsSession | None" = None
|
||||
) -> "HlsSession | None":
|
||||
# `replace_only` makes the supersede identity-aware for the runtime fallback: the whole
|
||||
# check-and-replace happens under ONE _lock, so a newer same-key session (a concurrent
|
||||
# seek-restart) installed between the fallback's decision and here is never clobbered. When the
|
||||
# registered session is no longer `replace_only`, we abort and return None (the caller yields to
|
||||
# whoever is registered now). The INITIAL spawn passes replace_only=None and always supersedes —
|
||||
# that is the seek-restart's intended last-writer-wins.
|
||||
# Software-fallback encode tuning — read only on the SW transcode path (the VAAPI path and
|
||||
# remux ignore preset/crf/max_height, and remux must not touch the DB for them).
|
||||
preset, crf, max_height = "veryfast", 20, 0
|
||||
if transcode and not hw:
|
||||
preset = sysconfig.get_str(db, "plex_transcode_preset") or "veryfast"
|
||||
crf = sysconfig.get_int(db, "plex_transcode_crf")
|
||||
max_height = sysconfig.get_int(db, "plex_transcode_max_height")
|
||||
# Nice the SOFTWARE transcode child (not the HW path, and not remux) so a CPU-bound re-encode
|
||||
# can't starve the co-located API/playback. preexec_fn runs in the forked child (POSIX); on a
|
||||
# platform without fork (the API always runs in a Linux container in prod) it's simply omitted.
|
||||
preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None
|
||||
with _lock:
|
||||
if replace_only is not None and _sessions.get(key) is not replace_only:
|
||||
return None # superseded/culled while we waited out of lock — don't clobber the newer one
|
||||
old = _sessions.pop(key, None)
|
||||
if old is not None:
|
||||
_kill(old)
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
proc = subprocess.Popen(
|
||||
_ffmpeg_cmd(
|
||||
src,
|
||||
start_s,
|
||||
directory,
|
||||
audio_ord,
|
||||
aoff,
|
||||
audio_langs if is_multi else None,
|
||||
transcode=transcode,
|
||||
hw=hw,
|
||||
tonemap=tonemap,
|
||||
preset=preset,
|
||||
crf=crf,
|
||||
max_height=max_height,
|
||||
),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
preexec_fn=preexec,
|
||||
)
|
||||
s = HlsSession(
|
||||
key,
|
||||
directory,
|
||||
proc,
|
||||
start_s,
|
||||
"master.m3u8" if is_multi else "index.m3u8",
|
||||
transcode=transcode,
|
||||
hw=hw,
|
||||
tonemap=tonemap,
|
||||
)
|
||||
_sessions[key] = s
|
||||
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes);
|
||||
# at least 1 so playback can't be capped to zero.
|
||||
_enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes")))
|
||||
return s
|
||||
|
||||
s = _spawn(*_select_encode(db, item, transcode, src))
|
||||
# Wait for the first video segment outside the lock (this blocks on ffmpeg, and we must not hold up
|
||||
# other sessions). Pass the proc so a HW session that dies at VAAPI init fails fast (not after 25s).
|
||||
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
|
||||
if not ready and s.hw:
|
||||
# We waited on _seg(0) OUTSIDE _lock, so `s.proc` dying is ambiguous: it may have failed on its
|
||||
# own (VAProfile/init/tonemap refusal), OR a concurrent same-key _spawn (a rapid seek-restart) or
|
||||
# _enforce_cap may have killed it to install/keep a newer session. Respawn on libx264 (no HW, no
|
||||
# tonemap) so a genuinely-failed GPU session never strands playback — but pass `replace_only=s` so
|
||||
# the respawn is a no-op if we've been superseded: the identity check and the pop+replace share
|
||||
# ONE lock in _spawn, so we can never tear down the newer session. s.hw is now False, so the route
|
||||
# honestly reports "CPU" and the player's Preparing-stream state stays correct.
|
||||
log.warning(
|
||||
"plex transcode: HW session produced no segments — falling back to software key=%s", key
|
||||
)
|
||||
respawned = _spawn(False, False, replace_only=s)
|
||||
if respawned is None:
|
||||
# Superseded/culled mid-fallback — yield whoever is registered now (a newer seek-restart), or
|
||||
# our own now-dead session if the key was cap-culled. Never None here: the POST /session route
|
||||
# turns a None return into a "Media file not found on disk" 404 — dishonest for a file that
|
||||
# exists. A (dead) session instead degrades to a retryable 404 ("No active playback session" /
|
||||
# "Not available yet") the client re-requests.
|
||||
return current_session(key) or s
|
||||
s = respawned
|
||||
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
|
||||
if ready:
|
||||
# For a seek (start_s>0) learn the REAL start offset K from the first segment's absolute PTS,
|
||||
# so the client's absolute clock + subtitle shift key off the true keyframe, not the requested
|
||||
# offset. On probe failure we keep the provisional requested offset (old behaviour, small drift).
|
||||
@@ -497,17 +690,18 @@ def start_session(
|
||||
# at the first segment that isn't ready in time — ffmpeg produces them faster than realtime once
|
||||
# warm, so a miss means something slow and we proceed rather than block the request further.
|
||||
for i in range(1, _WARMUP_SEGMENTS):
|
||||
if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S):
|
||||
if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S, proc=s.proc):
|
||||
break
|
||||
log.info(
|
||||
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s transcode=%s hw=%s",
|
||||
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s transcode=%s hw=%s tonemap=%s",
|
||||
key,
|
||||
start_s,
|
||||
s.media_start_s,
|
||||
is_multi,
|
||||
audio_ord,
|
||||
transcode,
|
||||
hw,
|
||||
s.hw,
|
||||
s.tonemap,
|
||||
)
|
||||
return s
|
||||
|
||||
@@ -520,10 +714,14 @@ def current_session(key: str) -> HlsSession | None:
|
||||
return s
|
||||
|
||||
|
||||
def wait_for(path: Path, timeout: float = 20.0) -> bool:
|
||||
def wait_for(path: Path, timeout: float = 20.0, proc: subprocess.Popen | None = None) -> bool:
|
||||
"""Wait until a session file (playlist / segment) exists and is non-empty. Segments are
|
||||
produced ~faster than realtime, so a segment just ahead of playback appears quickly; a segment
|
||||
far beyond the generated region won't (the frontend restarts the session at a seek instead)."""
|
||||
far beyond the generated region won't (the frontend restarts the session at a seek instead).
|
||||
|
||||
With `proc` given, also stop as soon as that ffmpeg exits: a VAAPI session that fails to init
|
||||
(10-bit/HDR VAProfile refusal) dies within ~1s producing nothing, and without this we'd block the
|
||||
whole `timeout` before the HW→SW fallback could kick in — turning the fix into a stall."""
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
try:
|
||||
@@ -531,6 +729,12 @@ def wait_for(path: Path, timeout: float = 20.0) -> bool:
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
if proc is not None and proc.poll() is not None:
|
||||
# the producer exited — no more segments are coming; one last check then give up.
|
||||
try:
|
||||
return path.exists() and path.stat().st_size > 0
|
||||
except OSError:
|
||||
return False
|
||||
time.sleep(0.15)
|
||||
return path.exists()
|
||||
|
||||
|
||||
+130
-3
@@ -18,7 +18,7 @@ item-detail/player path (P2), not here.
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import sysconfig
|
||||
@@ -58,6 +58,57 @@ def classify_playable(vcodec: str | None, acodec: str | None, container: str | N
|
||||
return "direct"
|
||||
|
||||
|
||||
# Video codecs the prod iGPU (UHD 630 / Quick Sync Gen 9.5) can hardware-DECODE — the precondition for
|
||||
# the full-GPU VAAPI transcode pipeline. THE single source of this set: plex.stream imports it here so
|
||||
# the runtime encoder choice (`_want_hw`) and this catalog facet can never disagree by codec. A source
|
||||
# outside it (AV1, MPEG-4 ASP/XviD…) is software-only even on the iGPU (it can't decode it to feed its
|
||||
# own encoder).
|
||||
HW_DECODABLE_CODECS = frozenset({"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"})
|
||||
# Color-transfer values marking an HDR source (PQ / HLG). A 10-bit/HDR source breaks the VAAPI
|
||||
# scale=nv12 path on Gen 9.5 (see plex.stream) → software transcode, so it counts as NOT HW-capable.
|
||||
_HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"})
|
||||
|
||||
|
||||
def transcode_hw_capable(
|
||||
playable: str | None,
|
||||
vcodec: str | None,
|
||||
bit_depth: int | None = None,
|
||||
hdr: bool = False,
|
||||
) -> bool | None:
|
||||
"""The stored `PlexItem.transcode_hw` facet: for a `playable="transcode"` item, whether the prod
|
||||
iGPU can HARDWARE-transcode it (else software libx264 only); None for a non-transcode item. HW needs
|
||||
a HW-decodable codec AND an 8-bit SDR source — a 10-bit/HDR source (bit_depth>8 or an HDR transfer)
|
||||
breaks the VAAPI nv12 pipeline and falls back to software. `bit_depth`/`hdr` are unknown at sync
|
||||
time (the cheap listing carries no Stream), so they default to 8-bit SDR (codec-only) and the
|
||||
bit-depth backfill refines a 10-bit/HDR HEVC to False later."""
|
||||
if playable != "transcode":
|
||||
return None
|
||||
if (vcodec or "").lower() not in HW_DECODABLE_CODECS:
|
||||
return False
|
||||
if bit_depth is not None and bit_depth > 8:
|
||||
return False
|
||||
return not hdr
|
||||
|
||||
|
||||
def _video_stream_props(meta: dict) -> tuple[int | None, bool]:
|
||||
"""(bit_depth, is_hdr) from a FULL Plex metadata dict's video Stream (Media→Part→Stream, streamType
|
||||
1). Only the rich metadata call carries Stream; the cheap listing does not — so this is read by the
|
||||
bit-depth backfill, not the listing sync. (None, False) when absent/unparseable."""
|
||||
for m in meta.get("Media") or []:
|
||||
for part in m.get("Part") or []:
|
||||
for s in part.get("Stream") or []:
|
||||
if s.get("streamType") != 1: # 1 = video
|
||||
continue
|
||||
bd = s.get("bitDepth")
|
||||
try:
|
||||
depth = int(bd) if bd else None
|
||||
except (TypeError, ValueError):
|
||||
depth = None
|
||||
hdr = str(s.get("colorTrc") or "").lower() in _HDR_TRANSFERS
|
||||
return depth, hdr
|
||||
return None, False
|
||||
|
||||
|
||||
def _epoch(v) -> datetime | None:
|
||||
try:
|
||||
return datetime.fromtimestamp(int(v), tz=timezone.utc) if v else None
|
||||
@@ -217,6 +268,13 @@ def sync(db: Session) -> dict:
|
||||
stats["people_enriched"] = _enrich_people_batch(db, plex)
|
||||
except Exception as e: # best-effort — never fail the sync over the backfill
|
||||
log.warning("Plex people enrich batch failed: %s", e)
|
||||
# Probe a bounded batch of HW-decodable transcode items for bit-depth/HDR, so the
|
||||
# playback-class filter's HW/SW split is precise (a 10-bit/HDR HEVC → software) over
|
||||
# successive runs. Only HW-decodable-codec transcodes are probed — the rest are SW by codec.
|
||||
try:
|
||||
stats["media_probed"] = _probe_media_batch(db, plex)
|
||||
except Exception as e: # best-effort — never fail the sync over the backfill
|
||||
log.warning("Plex media probe batch failed: %s", e)
|
||||
except PlexNotConfigured as e:
|
||||
return {"skipped": str(e)}
|
||||
except PlexError as e:
|
||||
@@ -236,6 +294,49 @@ def sync(db: Session) -> dict:
|
||||
# How many titles to rich-fetch per sync run for the cast backfill — bounds the extra Plex load; the
|
||||
# whole library is covered over successive runs (each fetched item is stamped people_enriched_at once).
|
||||
_ENRICH_BATCH = 60
|
||||
# Same bounded-per-run pattern for the bit-depth probe. Only HW-decodable-codec transcode items are
|
||||
# probed (typically a small minority — the bulk of transcodes are old codecs that are SW by codec), so
|
||||
# a modest batch covers the work-list in a run or two; a stuck item retries next run (watermark unset).
|
||||
_PROBE_BATCH = 60
|
||||
|
||||
|
||||
def _probe_media_batch(db: Session, plex: PlexClient, limit: int = _PROBE_BATCH) -> int:
|
||||
"""Rich-fetch up to `limit` not-yet-probed HW-decodable transcode items, read the video Stream's
|
||||
bit-depth + HDR, and store `video_bit_depth` + the refined `transcode_hw` (a 10-bit/HDR HEVC →
|
||||
False/software). Stamps `media_probed_at` on every attempt (success OR a Plex miss with no Stream),
|
||||
so an item is probed once and not re-fetched every sync; a transient fetch FAILURE leaves the
|
||||
watermark unset to retry. Oldest-id first. Returns how many items were probed this run."""
|
||||
rows = (
|
||||
db.execute(
|
||||
select(PlexItem)
|
||||
.where(
|
||||
PlexItem.playable == "transcode",
|
||||
func.lower(PlexItem.codec_video).in_(HW_DECODABLE_CODECS),
|
||||
PlexItem.media_probed_at.is_(None),
|
||||
)
|
||||
.order_by(PlexItem.id)
|
||||
.limit(limit)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
n = 0
|
||||
for row in rows:
|
||||
try:
|
||||
meta = plex.metadata(row.rating_key) or {}
|
||||
except Exception as e: # transient — leave media_probed_at NULL so it retries next run
|
||||
log.debug("Plex media probe fetch failed for %s: %s", row.rating_key, e)
|
||||
continue
|
||||
bit_depth, hdr = _video_stream_props(meta)
|
||||
row.video_bit_depth = bit_depth
|
||||
row.transcode_hw = transcode_hw_capable("transcode", row.codec_video, bit_depth, hdr)
|
||||
row.media_probed_at = (
|
||||
now # probed (even if Plex carried no Stream — don't re-fetch forever)
|
||||
)
|
||||
n += 1
|
||||
db.commit()
|
||||
return n
|
||||
|
||||
|
||||
def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BATCH) -> int:
|
||||
@@ -369,15 +470,41 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
|
||||
row.added_at = _epoch(meta.get("addedAt"))
|
||||
_apply_facet_fields(row, meta) # filterable/orderable extras (shared with shows)
|
||||
mf = _media_facts(meta)
|
||||
new_vcodec = mf.get("codec_video")
|
||||
new_part_key = mf.get("part_key")
|
||||
# The physical file was replaced when its video codec changes OR — for a SINGLE-version item — when its
|
||||
# Plex Part key changes (a re-encode / quality upgrade gets a new part id). Either way any probed
|
||||
# bit-depth is stale, so reset the probe watermark and re-derive transcode_hw from codec alone until the
|
||||
# backfill re-probes. Codec alone is NOT enough: a same-codec bit-depth re-encode (8-bit→10-bit HEVC)
|
||||
# keeps the codec `hevc` but flips the HW class, and only the part-key move catches it.
|
||||
#
|
||||
# The part-key check is gated on single-version because `_media_facts` reads media[0]/part[0], and for a
|
||||
# MULTI-version item (an original + a Plex "Optimized" copy) that order isn't guaranteed stable across
|
||||
# listings — an unguarded part-key compare would then reset+re-probe every sync and oscillate the
|
||||
# transcode_hw bucket between the two versions. A multi-version same-codec re-encode not self-healing is
|
||||
# an acceptable rare edge; a per-sync re-probe loop is not.
|
||||
single_version = len(meta.get("Media") or []) <= 1
|
||||
codec_changed = (row.codec_video or "").lower() != (new_vcodec or "").lower()
|
||||
if codec_changed or (single_version and row.part_key != new_part_key):
|
||||
row.video_bit_depth = None
|
||||
row.media_probed_at = None
|
||||
row.file_path = mf.get("file_path")
|
||||
row.part_key = mf.get("part_key")
|
||||
row.part_key = new_part_key
|
||||
row.container = mf.get("container")
|
||||
row.codec_video = mf.get("codec_video")
|
||||
row.codec_video = new_vcodec
|
||||
row.codec_audio = mf.get("codec_audio")
|
||||
row.size_bytes = mf.get("size_bytes")
|
||||
row.playable = classify_playable(
|
||||
mf.get("codec_video"), mf.get("codec_audio"), mf.get("container")
|
||||
)
|
||||
# transcode_hw: keep the bit-depth backfill's authoritative value (it folds in bit-depth + HDR) once
|
||||
# probed; otherwise (re)derive the provisional codec-only class. A non-transcode item is always None,
|
||||
# even if it had been probed as a transcode before (an audio/container flip to remux without a codec
|
||||
# change wouldn't reset the watermark, so pin it here).
|
||||
if row.playable != "transcode":
|
||||
row.transcode_hw = None
|
||||
elif row.media_probed_at is None:
|
||||
row.transcode_hw = transcode_hw_capable("transcode", row.codec_video, row.video_bit_depth)
|
||||
|
||||
|
||||
def _prune_stale(
|
||||
|
||||
@@ -18,6 +18,13 @@ class RateLimiter:
|
||||
self._hits: dict[str, list[float]] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Forget every recorded attempt. Each limiter is a module-level singleton, so a test suite
|
||||
sharing one process would otherwise pool all of its attempts into a single window and
|
||||
eventually trip an unrelated test with a 429."""
|
||||
with self._lock:
|
||||
self._hits.clear()
|
||||
|
||||
def allow(self, key: str) -> bool:
|
||||
"""Record an attempt for ``key`` and return whether it is within the limit.
|
||||
|
||||
|
||||
+463
-22
@@ -1,20 +1,26 @@
|
||||
"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add.
|
||||
Also the demo-account admin surface: the demo email whitelist + a manual state reset."""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit
|
||||
from app import email as email_mod
|
||||
from app import mailtext
|
||||
from app.audit import AuditAction
|
||||
from app.auth import (
|
||||
admin_user,
|
||||
can_activate,
|
||||
count_admins,
|
||||
env_allowed_canonicals,
|
||||
find_user_by_email,
|
||||
get_or_create_demo_user,
|
||||
is_allowed,
|
||||
operator_contact,
|
||||
purge_user,
|
||||
)
|
||||
@@ -25,13 +31,47 @@ from app.models import (
|
||||
Invite,
|
||||
User,
|
||||
)
|
||||
from app.routes.auth import revoke_preset_password_if_unverified
|
||||
from app.utils import iso, valid_email
|
||||
from app.routes._common import get_or_404
|
||||
from app.routes.auth import (
|
||||
APPROVAL_VERIFY_TTL,
|
||||
issue_set_password_link,
|
||||
issue_verification_link,
|
||||
send_verification_link,
|
||||
)
|
||||
from app.utils import canonical_email, iso, valid_email
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
def _serialize(inv: Invite) -> dict:
|
||||
def _accounts_for(db: Session, emails: Iterable[str]) -> set[str]:
|
||||
"""Which of these addresses already have an account, as a set of CANONICAL forms. Batched, so
|
||||
listing the invites costs one extra query. An invite whose address has an account must not be
|
||||
deletable: `is_allowed` reads this row, so removing it would silently revoke a live user's access.
|
||||
|
||||
Canonical on both sides, because `is_allowed` matches that way: an invite for `x.y@gmail.com`
|
||||
whose account registered as `xy@gmail.com` would otherwise look account-less, offer the admin a
|
||||
delete button, and take away a live user's access — precisely the accident this guard exists to
|
||||
prevent."""
|
||||
wanted = {canonical_email(e) for e in emails if e}
|
||||
if not wanted:
|
||||
return set()
|
||||
rows = db.execute(
|
||||
select(User.email_canonical).where(User.email_canonical.in_(wanted))
|
||||
).scalars()
|
||||
return {e for e in rows if e}
|
||||
|
||||
|
||||
def _env_allowed(email: str) -> bool:
|
||||
"""Whether the env bootstrap lists still let this address in. `is_allowed` consults them only
|
||||
when no Invite row exists, so this is exactly what deleting the row would fall back to — and the
|
||||
admin has to know that BEFORE they confirm, not from a toast afterwards.
|
||||
|
||||
Canonical, because `is_allowed` is: a warning that disagrees with the gate it is warning about is
|
||||
worse than no warning."""
|
||||
return canonical_email(email or "") in env_allowed_canonicals()
|
||||
|
||||
|
||||
def _serialize(inv: Invite, *, has_account: bool = False) -> dict:
|
||||
return {
|
||||
"id": inv.id,
|
||||
"email": inv.email,
|
||||
@@ -40,6 +80,19 @@ def _serialize(inv: Invite) -> dict:
|
||||
"requested_at": iso(inv.requested_at),
|
||||
"decided_at": iso(inv.decided_at),
|
||||
"decided_by": inv.decided_by,
|
||||
# When (if ever) the admin sent this person an invitation. Adding an address is silent, so
|
||||
# without this the admin has no way to answer "did I already write to them?" — and the only
|
||||
# failure mode is writing to a real person twice.
|
||||
"invited_at": iso(inv.invited_at),
|
||||
# Drives the delete affordance: an entry backed by an account is not removable here.
|
||||
"has_account": has_account,
|
||||
# …and whether removing it would actually take access away, or leave the env allow-list
|
||||
# granting it anyway (for an ADMIN_EMAILS address, as an admin).
|
||||
"still_allowed_by_env": _env_allowed(inv.email),
|
||||
# The language this person asked us in, or null when nobody ever told us (a hand-added
|
||||
# whitelist row). The invitation dialog preselects it, and falls back to the admin's own UI
|
||||
# language — a guess, but a visible one the admin can correct before sending.
|
||||
"lang": inv.lang,
|
||||
}
|
||||
|
||||
|
||||
@@ -56,26 +109,71 @@ def list_invites(
|
||||
rows = db.execute(stmt.order_by(Invite.requested_at.desc())).scalars().all()
|
||||
order = {"pending": 0, "approved": 1, "denied": 2}
|
||||
rows.sort(key=lambda i: order.get(i.status, 9))
|
||||
return [_serialize(i) for i in rows]
|
||||
accounts = _accounts_for(db, (i.email for i in rows))
|
||||
return [_serialize(i, has_account=canonical_email(i.email) in accounts) for i in rows]
|
||||
|
||||
|
||||
def _welcome_mail_parts(db: Session, email: str) -> tuple[str | None, bool, int]:
|
||||
"""What a welcome or invitation mail should tell this address to do next: a confirmation link (if
|
||||
there is an unconfirmed account behind it), whether an account exists at all, and how long the
|
||||
link lasts.
|
||||
|
||||
This is the C option's whole mechanic. In the common ordering — the address is approved and the
|
||||
person signs up either side of that — they would otherwise have to find and open TWO messages:
|
||||
one saying they are in, another asking them to confirm. Carrying the link inside the mail that is
|
||||
already going out makes it one mail and one click.
|
||||
|
||||
Resolved through `find_user_by_email`, so an account registered under another spelling of the
|
||||
same Gmail mailbox is still recognised — and, deliberately, so several candidates resolve to
|
||||
"no single answer" rather than a guess: an invitation that carried the wrong account's link would
|
||||
be worse than one that simply pointed at the sign-up page.
|
||||
"""
|
||||
hours = int(APPROVAL_VERIFY_TTL.total_seconds() // 3600)
|
||||
user = find_user_by_email(db, email)
|
||||
if user is None or user.is_demo:
|
||||
return None, False, hours
|
||||
if user.email_verified or not email_mod.email_enabled(db):
|
||||
return None, True, hours
|
||||
return issue_verification_link(db, user, APPROVAL_VERIFY_TTL), True, hours
|
||||
|
||||
|
||||
def _activate_user_by_email(db: Session, email: str) -> None:
|
||||
"""Approving access activates a matching (e.g. pending password-registration) account so
|
||||
it can sign in. No-op if no such user exists yet (a Google user is created at first login)."""
|
||||
user = db.execute(select(User).where(User.email == email.lower())).scalar_one_or_none()
|
||||
it can sign in. No-op if no such user exists yet (a Google user is created at first login).
|
||||
|
||||
E9.5 S1: this path deliberately does NOT revoke a preset password. Revoking here destroyed the
|
||||
password a legitimate registrant had just chosen and made "your password was cleared for
|
||||
security" the DEFAULT experience, because approve-before-verify is the normal ordering.
|
||||
|
||||
S1 justified that by saying `password_login` refuses an unverified account BEFORE it looks at
|
||||
`is_active`, so a pre-emptor could not sign in "approved or not". A review of the finished epic
|
||||
showed that argument only holds while the address stays unverified — and S6's approval mail
|
||||
hands the real owner a confirmation link precisely so they will end that state. Nothing replaced
|
||||
the revoke, and that is deliberate: round 2 of the same review tried "the newest registration
|
||||
owns an unverified account" and reverted it, because the rule is symmetric — an attacker can
|
||||
always register last — and it handed anonymous callers a write onto a real account's credential.
|
||||
The owner's recourse is password-reset, which proves the mailbox, rewrites the hash and bumps
|
||||
the session epoch. The residual is logged as SEC-16. The revoke also stays on the Google-adoption path
|
||||
(auth.revoke_preset_password_if_unverified), where ownership is actually attested."""
|
||||
# Canonical, with the literal spelling preferred when both exist: approving
|
||||
# `npeter83.temu@gmail.com` must activate the account that registered as `npeter83temu@gmail.com`,
|
||||
# since Gmail delivers both to one mailbox and the admin decided about the mailbox.
|
||||
email = email.lower()
|
||||
candidates = (
|
||||
db.execute(
|
||||
select(User).where(User.email_canonical == canonical_email(email)).order_by(User.id)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
user = next((u for u in candidates if u.email == email), None) or next(iter(candidates), None)
|
||||
if user is not None and not user.is_active:
|
||||
# SEC-10: same guard as the Google-adoption path — approving an account whose email was never
|
||||
# verified must not hand a working login to a password set on it by whoever registered the
|
||||
# address (a pre-emption). Shared single-source helper so the two paths can't drift.
|
||||
revoke_preset_password_if_unverified(user)
|
||||
user.is_active = True
|
||||
db.commit()
|
||||
|
||||
|
||||
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
|
||||
inv = db.get(Invite, invite_id)
|
||||
if inv is None:
|
||||
raise HTTPException(status_code=404, detail="No such invite")
|
||||
inv = get_or_404(db, Invite, invite_id, detail="No such invite")
|
||||
# Idempotency guard: only a still-pending invite can be decided. Without this a repeat/stale
|
||||
# approve or deny re-runs its side effects — re-activating a user, or (now that deny emails)
|
||||
# sending a duplicate decision mail, even flipping an already-approved invite to "denied" and
|
||||
@@ -106,8 +204,13 @@ def approve_invite(
|
||||
) -> dict:
|
||||
inv = _decide(db, invite_id, admin, approved=True)
|
||||
_activate_user_by_email(db, inv.email)
|
||||
background.add_task(email_mod.send_access_approved, inv.email)
|
||||
return _serialize(inv)
|
||||
# Computed here, not in the background task: issuing the token needs this request's session, and
|
||||
# the task must not outlive it. Approval answers a request the person MADE, so unlike the
|
||||
# invitation below it is sent automatically — a reply is owed, not a nudge.
|
||||
link, has_account, hours = _welcome_mail_parts(db, inv.email)
|
||||
lang = mailtext.lang_for_email(db, inv.email)
|
||||
background.add_task(email_mod.send_access_approved, inv.email, lang, link, has_account, hours)
|
||||
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
|
||||
|
||||
|
||||
@router.post("/invites/{invite_id}/deny")
|
||||
@@ -120,8 +223,10 @@ def deny_invite(
|
||||
# Symmetry with approve: the requester hears back either way, instead of a silent limbo. No
|
||||
# account is activated, so this is an email only (the requester has no in-app inbox yet).
|
||||
inv = _decide(db, invite_id, admin, approved=False)
|
||||
background.add_task(email_mod.send_access_denied, inv.email)
|
||||
return _serialize(inv)
|
||||
background.add_task(
|
||||
email_mod.send_access_denied, inv.email, mailtext.lang_for_email(db, inv.email)
|
||||
)
|
||||
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
|
||||
|
||||
|
||||
class AddInviteIn(BaseModel):
|
||||
@@ -138,7 +243,16 @@ def add_invite(
|
||||
email = body.email.strip().lower()
|
||||
if not valid_email(email):
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
||||
# Reuse a canonically-equal row instead of adding a second one for the same mailbox. Prod carries
|
||||
# exactly that pair for one person from 2026-06-16 (hartmann.peter1986@ / hartmannpeter1986@),
|
||||
# which is how an admin ends up approving the same human twice and still seeing them bounce.
|
||||
canon = canonical_email(email)
|
||||
rows = (
|
||||
db.execute(select(Invite).where(Invite.email_canonical == canon).order_by(Invite.id))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
inv = next((r for r in rows if r.email == email), None) or next(iter(rows), None)
|
||||
if inv is None:
|
||||
inv = Invite(email=email)
|
||||
db.add(inv)
|
||||
@@ -150,7 +264,124 @@ def add_invite(
|
||||
)
|
||||
db.commit()
|
||||
_activate_user_by_email(db, email)
|
||||
return _serialize(inv)
|
||||
return _serialize(inv, has_account=bool(_accounts_for(db, [email])))
|
||||
|
||||
|
||||
class InviteSendIn(BaseModel):
|
||||
# Which language to write this person in. Optional so an existing caller (and a test) can post
|
||||
# an empty body, but the admin UI always sends it: this is the one recipient the app has no
|
||||
# signal about, because the admin typed the address in themselves.
|
||||
lang: str | None = None
|
||||
|
||||
|
||||
@router.post("/invites/{invite_id}/invite")
|
||||
def send_invitation(
|
||||
invite_id: int,
|
||||
body: InviteSendIn | None = None,
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Tell someone their address is whitelisted — only when the admin asks for it.
|
||||
|
||||
Whitelisting stays SILENT on purpose. The operator opens a door as a possibility, usually having
|
||||
already agreed it with the person, and an address can sit approved for weeks with nobody
|
||||
registering; a mail fired automatically would be nudging someone who never asked to be nudged.
|
||||
So the send is its own act, on its own button, and `invited_at` records it so the admin can see
|
||||
they have already written rather than writing twice.
|
||||
|
||||
Only an APPROVED row can be invited: there is nothing to invite someone to while their request is
|
||||
undecided, and inviting a denied address would contradict the decision."""
|
||||
inv = get_or_404(db, Invite, invite_id, detail="No such invite")
|
||||
if inv.status != "approved":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "invite_send", "reason": "not_approved", "email": inv.email},
|
||||
)
|
||||
if not email_mod.email_enabled(db):
|
||||
# Same reasoning as the admin-side verification resend: mail is fail-soft everywhere else,
|
||||
# but a button the admin pressed must not answer with a success toast for a mail that was
|
||||
# never sent.
|
||||
raise HTTPException(status_code=409, detail={"code": "invite_send", "reason": "email_off"})
|
||||
link, has_account, hours = _welcome_mail_parts(db, inv.email)
|
||||
# An explicit pick from the dialog is recorded, so a later mail to this address matches the one
|
||||
# the admin already sent. Only the explicit pick — writing the fallback back would turn a column
|
||||
# that means "what this person told us" into "what we last guessed".
|
||||
picked = body.lang if body else None
|
||||
if picked in mailtext.SUPPORTED:
|
||||
lang = inv.lang = picked
|
||||
else:
|
||||
lang = mailtext.lang_for_email(db, inv.email)
|
||||
# Sent INLINE, so `invited_at` records the send rather than the intent. Every other mail here is
|
||||
# fail-soft in the background, which is right when nobody is waiting on it — but this column
|
||||
# exists to answer "did I already write to them?", and the UI dims the button and warns about a
|
||||
# second mail on the strength of it. Stamping it before a send that then failed makes the one
|
||||
# question it answers a lie, and talks the admin out of the retry that would fix it.
|
||||
#
|
||||
# One thing a failed send does leave behind: the confirmation token `_welcome_mail_parts` minted
|
||||
# to render the link, which commits inside `_issue_token`. Accepted rather than chased — the
|
||||
# token is single-use, expires on its own, and was mailed nowhere; deferring it would mean
|
||||
# rewriting a helper three callers share so the handler owns its commit.
|
||||
if not email_mod.send_access_invitation(inv.email, lang, link, has_account, hours):
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "invite_send", "reason": "send_failed"}
|
||||
)
|
||||
# Only after the mail is actually out. Same as the other two paths that decide for an address
|
||||
# (`approve_invite`, `add_invite`): an approved row means this mailbox may come in, so finish it,
|
||||
# or the mail can promise "your account is ready — just sign in" to someone `password_login` will
|
||||
# refuse for `is_active`. Doing it BEFORE the send would mean a request the UI reports as failed
|
||||
# had quietly activated an account behind the admin's back.
|
||||
_activate_user_by_email(db, inv.email)
|
||||
inv.invited_at = datetime.now(timezone.utc)
|
||||
audit.record(
|
||||
db,
|
||||
admin.id,
|
||||
AuditAction.INVITE_SEND,
|
||||
target_type="invite",
|
||||
target_id=inv.id,
|
||||
summary=inv.email,
|
||||
)
|
||||
db.commit()
|
||||
return _serialize(inv, has_account=has_account)
|
||||
|
||||
|
||||
@router.delete("/invites/{invite_id}")
|
||||
def delete_invite(
|
||||
invite_id: int,
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Remove a whitelist/request entry outright — the counterpart to add_invite, for an address that
|
||||
never became an account (an approval added by mistake, or one that is no longer wanted). Deny
|
||||
records a decision and keeps the row for the audit trail; delete forgets the address. Neither
|
||||
stops the person asking again: `upsert_pending_invite` reopens a denied row, and a deleted one
|
||||
simply comes back as a fresh pending request.
|
||||
|
||||
Refused once an account exists for the address, whatever its state: this row is the account's
|
||||
approval gate, and orphaning the account from it would leave a person who can neither sign in nor
|
||||
be approved. Delete the USER instead — purge_user removes the invite along with it.
|
||||
|
||||
Reports `still_allowed_by_env`, because deleting the row is NOT always a revoke: `is_allowed`
|
||||
falls back to the env ALLOWED_EMAILS / ADMIN_EMAILS bootstrap lists once no invite exists, so for
|
||||
an address on those the deletion leaves access intact (and an ADMIN_EMAILS address is still
|
||||
promoted to admin on its next Google sign-in). The admin has to hear that, not guess it."""
|
||||
inv = get_or_404(db, Invite, invite_id, detail="No such invite")
|
||||
if _accounts_for(db, [inv.email]):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="That address already has an account — delete the account instead.",
|
||||
)
|
||||
still_allowed_by_env = _env_allowed(inv.email)
|
||||
audit.record(
|
||||
db,
|
||||
admin.id,
|
||||
AuditAction.INVITE_DELETE,
|
||||
target_type="invite",
|
||||
target_id=inv.id,
|
||||
summary=inv.email,
|
||||
)
|
||||
db.delete(inv)
|
||||
db.commit()
|
||||
return {"deleted": invite_id, "still_allowed_by_env": still_allowed_by_env}
|
||||
|
||||
|
||||
# --- Users & roles --------------------------------------------------------------------
|
||||
@@ -167,7 +398,15 @@ def _serialize_user(u: User) -> dict:
|
||||
"email_verified": u.email_verified,
|
||||
"is_demo": u.is_demo,
|
||||
"has_password": u.password_hash is not None,
|
||||
# Distinguishes "this account never had a password" (Google-only) from "SEC-10 cleared it" —
|
||||
# the same distinction _send_reset_if_eligible needs, and the one an admin has to see before
|
||||
# deciding what kind of help someone needs.
|
||||
"password_revoked_at": iso(u.password_revoked_at),
|
||||
"has_google": u.google_sub is not None,
|
||||
# Only when it DIFFERS from the account address (E9.5 S3): "connected" is not the same
|
||||
# statement as "connected as someone else", and the second one is the one that explains a
|
||||
# confusing sign-in.
|
||||
"google_email": u.google_email if u.google_email != u.email else None,
|
||||
"created_at": iso(u.created_at),
|
||||
}
|
||||
|
||||
@@ -224,7 +463,13 @@ def set_user_role(
|
||||
target.role = role
|
||||
db.commit()
|
||||
if changed:
|
||||
background.add_task(email_mod.send_role_changed, target.email, role, operator_contact())
|
||||
background.add_task(
|
||||
email_mod.send_role_changed,
|
||||
target.email,
|
||||
mailtext.lang_for_user(target),
|
||||
role,
|
||||
operator_contact(),
|
||||
)
|
||||
return _serialize_user(target)
|
||||
|
||||
|
||||
@@ -273,7 +518,12 @@ def set_user_suspended(
|
||||
target.is_suspended = suspended
|
||||
db.commit()
|
||||
if was_suspended and not suspended:
|
||||
background.add_task(email_mod.send_account_unsuspended, target.email, operator_contact())
|
||||
background.add_task(
|
||||
email_mod.send_account_unsuspended,
|
||||
target.email,
|
||||
mailtext.lang_for_user(target),
|
||||
operator_contact(),
|
||||
)
|
||||
return _serialize_user(target)
|
||||
|
||||
|
||||
@@ -318,6 +568,197 @@ def admin_delete_user(
|
||||
# --- Demo account: email whitelist + state reset -------------------------------------
|
||||
|
||||
|
||||
# --- Stuck sign-ups: the admin lifeline (E9.5 S5, roadmap UX-25) ---------------------------------
|
||||
|
||||
|
||||
def _pending_access_rows(db: Session) -> list[User]:
|
||||
"""Accounts that have signed up but still cannot sign in, newest first.
|
||||
|
||||
Two different dead ends share this list, because from the admin's chair they look the same — a
|
||||
person who registered and is still outside:
|
||||
· `is_active=False` — approved BEFORE they signed up, so nothing ever landed in the
|
||||
Access-requests queue and its Approve button doesn't apply (`_decide` only takes a pending
|
||||
row). This is roadmap UX-25, and it is why the list exists.
|
||||
· `email_verified=False` — the confirmation mail hasn't been acted on. Not the admin's to
|
||||
decide, but theirs to help with: on 2026-09-03 the first mail to a fresh Gmail landed in
|
||||
Spam, so "send it again" is a real move.
|
||||
A suspended account is deliberately excluded: it cannot sign in either, but that is a decision
|
||||
already made, not a person stuck waiting on one.
|
||||
"""
|
||||
return list(
|
||||
db.execute(
|
||||
select(User)
|
||||
.where(
|
||||
User.is_demo.is_(False),
|
||||
User.is_suspended.is_(False),
|
||||
or_(User.is_active.is_(False), User.email_verified.is_(False)),
|
||||
)
|
||||
.order_by(User.created_at.desc())
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pending-access")
|
||||
def list_pending_access(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> list[dict]:
|
||||
"""The sign-ups that are still outside, with which gate is shut for each. `can_activate` cannot
|
||||
be derived on the client — it needs the invite/allow-list lookup — so it is answered here.
|
||||
|
||||
One `is_allowed` query per row rather than a batched re-implementation of it: the row count is
|
||||
the admin's own backlog of stuck sign-ups (single digits on any real instance), and a second,
|
||||
subtly different copy of the "may this address sign in" rule is exactly the drift this codebase
|
||||
keeps getting bitten by."""
|
||||
out = []
|
||||
for u in _pending_access_rows(db):
|
||||
# The whole rule in one line: this address MAY sign in, and this account still can't. An
|
||||
# undecided request fails it (nothing is allowed yet) and so does a denied one — both belong
|
||||
# to the queue above, which owns that decision. Without this, every fresh registration
|
||||
# appeared in both lists at once, which UAT caught on 2026-09-05.
|
||||
if not is_allowed(db, u.email):
|
||||
continue
|
||||
out.append(_serialize_user(u) | {"can_activate": can_activate(db, u)})
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/access")
|
||||
def user_access(
|
||||
user_id: int,
|
||||
_: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Everything the account panel shows about why this person can or cannot get in (E12 S5).
|
||||
|
||||
Its own endpoint rather than extra columns on /users: `is_allowed` and `can_activate` each run a
|
||||
query, and the panel opens for ONE user at a time — folding them into the list would pay that cost
|
||||
per row on every load to answer a question nobody asked yet."""
|
||||
target = get_or_404(db, User, user_id, detail="No such user")
|
||||
return _serialize_user(target) | {
|
||||
"is_allowed": is_allowed(db, target.email),
|
||||
"can_activate": can_activate(db, target),
|
||||
"email_configured": email_mod.email_enabled(db),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/set-password-link")
|
||||
def create_set_password_link(
|
||||
user_id: int,
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Mint a one-time link that lets this account set a password, with no email involved (E12 S5).
|
||||
|
||||
The answer to SEC-15, SEC-16 and "no SMTP and someone forgot their password" at once. The admin
|
||||
hands the link over by whatever channel they trust; opening it sets a password and signs out
|
||||
whoever held the old one.
|
||||
|
||||
Deliberately NOT gated on the account's state. It is useful for a pre-empted address, for a
|
||||
revoked password, for a Google-only account that wants one, and for a plain forgotten password on
|
||||
an instance with no mail — narrowing it to one of those would only make the admin's real case the
|
||||
one it refuses. The demo account is the single exception: it is shared, so handing anyone a way to
|
||||
change its password is handing them everyone else's session."""
|
||||
target = get_or_404(db, User, user_id, detail="No such user")
|
||||
if target.is_demo:
|
||||
raise HTTPException(
|
||||
status_code=400, detail={"code": "setpw", "reason": "demo", "email": target.email}
|
||||
)
|
||||
url, expires_at = issue_set_password_link(db, target)
|
||||
# The URL itself is NOT recorded: the audit log is read by humans and kept long, and a live
|
||||
# credential-setting link is the last thing that belongs in it. That the link was issued, by whom,
|
||||
# for whom, is the auditable fact.
|
||||
audit.record(
|
||||
db,
|
||||
admin.id,
|
||||
AuditAction.USER_SETPW_LINK,
|
||||
target_type="user",
|
||||
target_id=target.id,
|
||||
summary=target.email,
|
||||
)
|
||||
db.commit()
|
||||
return {"ok": True, "url": url, "expires_at": iso(expires_at), "email": target.email}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/activate")
|
||||
def activate_user(
|
||||
user_id: int,
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Let in a registrant whose address was approved before they signed up (roadmap UX-25).
|
||||
|
||||
Admin-side ONLY, and that is the whole design. Two earlier attempts at this were reverted after
|
||||
review, and both moved authority rather than visibility: auto-activating on registration turns a
|
||||
standing whitelist entry into a bearer grant for whoever registers the address first, and
|
||||
reopening the approved invite to `pending` lets an anonymous sign-up REVOKE a standing approval.
|
||||
This endpoint changes nothing about what an unauthenticated caller can cause — the admin could
|
||||
already do exactly this by re-entering the address in the whitelist box (`add_invite` →
|
||||
`_activate_user_by_email`). What was missing was any way to know they had to."""
|
||||
target = get_or_404(db, User, user_id, detail="No such user")
|
||||
if target.is_demo:
|
||||
raise HTTPException(status_code=400, detail="The demo account can't be activated.")
|
||||
if target.is_active:
|
||||
return _serialize_user(target) # idempotent: a double click is not an error
|
||||
if not is_allowed(db, target.email):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "activate", "reason": "not_allowed", "email": target.email},
|
||||
)
|
||||
audit.record(
|
||||
db,
|
||||
admin.id,
|
||||
AuditAction.USER_ACTIVATE,
|
||||
target_type="user",
|
||||
target_id=target.id,
|
||||
summary=target.email,
|
||||
before={"is_active": False},
|
||||
after={"is_active": True},
|
||||
)
|
||||
target.is_active = True
|
||||
db.commit()
|
||||
return _serialize_user(target)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/resend-verification")
|
||||
def resend_user_verification(
|
||||
user_id: int,
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Re-send an account's confirmation link from the admin side.
|
||||
|
||||
The public /auth/verify/resend answers uniformly and reveals nothing, which is right for an
|
||||
anonymous caller but leaves the admin unable to tell a delivered mail from a dropped one. Here
|
||||
the caller is already authenticated as an admin, so this one says plainly what happened.
|
||||
|
||||
Not rate-limited: an admin is not a threat model to enumerate against, and the deliberate silence
|
||||
of the public endpoint is exactly what this exists to escape."""
|
||||
target = get_or_404(db, User, user_id, detail="No such user")
|
||||
if target.is_demo or target.email_verified:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"code": "resend", "reason": "not_applicable", "email": target.email},
|
||||
)
|
||||
if not email_mod.email_enabled(db):
|
||||
# Mail is fail-soft everywhere else, which is what left the 0.78.5 sign-ups with nothing to
|
||||
# fall back on. An admin pressing a button deserves the failure, not a success toast.
|
||||
raise HTTPException(status_code=409, detail={"code": "resend", "reason": "email_off"})
|
||||
if not send_verification_link(db, target):
|
||||
# Same rule as the invitation above: a button an admin pressed must not answer with a
|
||||
# success for a mail that was dropped. Mail is fail-soft everywhere else because nobody is
|
||||
# waiting on the answer; here somebody is.
|
||||
raise HTTPException(status_code=422, detail={"code": "resend", "reason": "send_failed"})
|
||||
audit.record(
|
||||
db,
|
||||
admin.id,
|
||||
AuditAction.USER_VERIFY_RESEND,
|
||||
target_type="user",
|
||||
target_id=target.id,
|
||||
summary=target.email,
|
||||
)
|
||||
db.commit()
|
||||
return {"ok": True, "email": target.email}
|
||||
|
||||
|
||||
def _serialize_demo(row: DemoWhitelist) -> dict:
|
||||
return {
|
||||
"id": row.id,
|
||||
|
||||
+511
-109
@@ -6,6 +6,8 @@ helpers) stays in app/auth.py, imported below."""
|
||||
import ipaddress
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import NamedTuple
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from authlib.integrations.starlette_client import OAuth, OAuthError
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||
@@ -16,6 +18,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import email as email_mod
|
||||
from app import mailtext
|
||||
from app import sysconfig
|
||||
from app.auth import (
|
||||
_YOUTUBE_SCOPES,
|
||||
@@ -25,6 +28,8 @@ from app.auth import (
|
||||
READ_SCOPES,
|
||||
WRITE_SCOPES,
|
||||
current_user,
|
||||
env_admin_canonicals,
|
||||
find_user_by_email,
|
||||
get_or_create_demo_user,
|
||||
google_enabled,
|
||||
is_allowed,
|
||||
@@ -37,27 +42,53 @@ from app.db import SessionLocal, get_db
|
||||
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.security import encrypt, hash_password, hash_token, verify_password
|
||||
from app.utils import valid_email
|
||||
from app.utils import canonical_email, valid_email
|
||||
|
||||
VERIFY_TTL = timedelta(hours=24)
|
||||
# A confirmation link that rides an APPROVAL or an invitation lives longer than one the person is
|
||||
# waiting for. Nobody sits refreshing their inbox for an approval — it can easily be read on Monday
|
||||
# after a weekend, and if it has expired by then the one-mail-one-click promise is spent and they are
|
||||
# back to two messages. The risk is small: this token only ever confirms an address. It signs nobody
|
||||
# in and sets no password.
|
||||
APPROVAL_VERIFY_TTL = timedelta(days=7)
|
||||
RESET_TTL = timedelta(hours=1)
|
||||
# An admin-issued set-password link (E12 S5) travels out of band — the admin has to actually reach the
|
||||
# person and hand it over — so the one-hour reset window is far too short. A day matches VERIFY_TTL and
|
||||
# the affordance the admin was shown.
|
||||
ADMIN_SETPW_TTL = timedelta(hours=24)
|
||||
# Both kinds set a password from a link, and the SIGNED-OUT card consumes them through the same
|
||||
# `#reset=` entry, so the user-facing flow is one flow. They stay separate kinds for one reason: a
|
||||
# `reset` token proves control of the MAILBOX (it was mailed there) and therefore confirms the address;
|
||||
# an admin-issued one proves nothing about the mailbox — the admin could hand it to anyone — so it must
|
||||
# not silently satisfy the email gate. See password_reset_confirm.
|
||||
_PASSWORD_TOKEN_KINDS = ("reset", "setpw")
|
||||
|
||||
_DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
|
||||
|
||||
_register_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
_login_limiter = RateLimiter(max_events=10, window_seconds=300)
|
||||
_reset_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
# Separate from _reset_limiter on purpose: opening a link must not eat the budget for REQUESTING one.
|
||||
_reset_check_limiter = RateLimiter(max_events=10, window_seconds=300)
|
||||
_verify_resend_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
_request_access_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
_suspend_email_limiter = RateLimiter(max_events=1, window_seconds=3600)
|
||||
|
||||
|
||||
def _notify_suspended(background: BackgroundTasks, email: str) -> None:
|
||||
def _notify_suspended(background: BackgroundTasks, user: User) -> None:
|
||||
"""Schedule the one-per-hour 'account suspended' email to a user whose (otherwise valid)
|
||||
sign-in was just blocked. Gated by verified credentials at the call site, so this can't be
|
||||
triggered by someone who doesn't control the account."""
|
||||
if _suspend_email_limiter.allow(email):
|
||||
background.add_task(email_mod.send_account_suspended, email, operator_contact())
|
||||
triggered by someone who doesn't control the account.
|
||||
|
||||
Takes the user rather than the address because the mail needs their language, and the send runs
|
||||
as a background task — after this request's session, and the row, are out of reach."""
|
||||
if _suspend_email_limiter.allow(user.email):
|
||||
background.add_task(
|
||||
email_mod.send_account_suspended,
|
||||
user.email,
|
||||
mailtext.lang_for_user(user),
|
||||
operator_contact(),
|
||||
)
|
||||
|
||||
|
||||
_demo_limiter = RateLimiter(max_events=5, window_seconds=60)
|
||||
@@ -90,18 +121,43 @@ def google_oauth(db: Session):
|
||||
return _oauth.google
|
||||
|
||||
|
||||
def admin_notify_emails(db: Session) -> list[str]:
|
||||
"""Who to email about admin events (e.g. a new access request). The ACTUAL admins — every active,
|
||||
non-suspended `role=admin` user — UNIONED with the env ADMIN_EMAILS bootstrap list. Using the DB
|
||||
roles means an admin promoted through the UI is notified too (env alone would miss them, yet they
|
||||
CAN approve requests — the approve UI is role-gated, so the notify set must match); the env union
|
||||
keeps a freshly-seeded instance working before any DB admin exists."""
|
||||
db_admins = db.execute(
|
||||
select(User.email).where(
|
||||
def admin_notify_groups(db: Session) -> dict[str, list[str]]:
|
||||
"""Who to email about admin events (e.g. a new access request), grouped by the language each of
|
||||
them reads. The ACTUAL admins — every active, non-suspended `role=admin` user — UNIONED with the
|
||||
env ADMIN_EMAILS bootstrap list. Using the DB roles means an admin promoted through the UI is
|
||||
notified too (env alone would miss them, yet they CAN approve requests — the approve UI is
|
||||
role-gated, so the notify set must match); the env union keeps a freshly-seeded instance working
|
||||
before any DB admin exists.
|
||||
|
||||
Grouped rather than one blast to everyone, because these recipients are people with their own UI
|
||||
language: a single message would have to pick one of them and be wrong for the rest. An env
|
||||
address with no admin account still gets looked up, so an operator listed in ADMIN_EMAILS whose
|
||||
account is (say) suspended is still written to in the language they chose."""
|
||||
rows = db.execute(
|
||||
select(User.email, User.preferences).where(
|
||||
User.role == "admin", User.is_active.is_(True), User.is_suspended.is_(False)
|
||||
)
|
||||
).scalars()
|
||||
return sorted({e.lower() for e in db_admins if e} | settings.admin_email_set)
|
||||
).all()
|
||||
by_email = {e.lower(): mailtext.normalize((p or {}).get("language")) for e, p in rows if e}
|
||||
for email in settings.admin_email_set:
|
||||
if email not in by_email:
|
||||
by_email[email] = mailtext.lang_for_email(db, email)
|
||||
groups: dict[str, list[str]] = {}
|
||||
for email in sorted(by_email):
|
||||
groups.setdefault(by_email[email], []).append(email)
|
||||
return groups
|
||||
|
||||
|
||||
def notify_admins_of_request(
|
||||
db: Session, background: BackgroundTasks | None, requester: str
|
||||
) -> None:
|
||||
"""Tell every admin that someone is waiting — one mail per language group. Sends inline when
|
||||
there is no BackgroundTasks to hand (the registration path already runs outside a response)."""
|
||||
for lang, recipients in admin_notify_groups(db).items():
|
||||
if background is None:
|
||||
email_mod.send_admin_new_request(recipients, lang, requester)
|
||||
else:
|
||||
background.add_task(email_mod.send_admin_new_request, recipients, lang, requester)
|
||||
|
||||
|
||||
def is_demo_allowed(db: Session, email: str) -> bool:
|
||||
@@ -147,14 +203,64 @@ def _client_ip(request: Request) -> str:
|
||||
return peer
|
||||
|
||||
|
||||
def upsert_pending_invite(db: Session, email: str) -> Invite | None:
|
||||
"""Idempotently record an access request. Returns the Invite if a *new* pending row
|
||||
was created (so the caller can notify admins), else None for already-known emails."""
|
||||
class InviteRequest(NamedTuple):
|
||||
"""What an access request settled on.
|
||||
|
||||
`invite` is the row the request now lives under — which is NOT always the address that was just
|
||||
typed: one Gmail mailbox spelled two ways settles on whichever row already exists, and that row's
|
||||
address is where mail will go. Callers that tell the user what happened need it (E9.5 S4).
|
||||
`is_new` is the old boolean return in a name: true only when a row was created or a denied one
|
||||
reopened, i.e. exactly when admins should hear about it. A repeat request must not re-spam them.
|
||||
"""
|
||||
|
||||
invite: Invite | None
|
||||
is_new: bool
|
||||
|
||||
|
||||
def upsert_pending_invite(db: Session, email: str, lang: str | None = None) -> InviteRequest:
|
||||
"""Idempotently record an access request, and the language it was made in.
|
||||
|
||||
"Already known" is decided on the CANONICAL form: `npeter83.temu@` and `npeter83temu@` are one
|
||||
Gmail mailbox, and treating them as two is what put a second, silent access request in the admin
|
||||
queue on 2026-09-03 for an already-approved user. An approved sibling wins — re-requesting must
|
||||
not reopen a decision that has already been made for that mailbox. The row is still STORED under
|
||||
the address as typed, because that is where mail has to go.
|
||||
|
||||
`lang` is the language of the surface the request came from — the sign-in page, or the locale
|
||||
Google reported for a refused sign-in. The latest one wins: someone who asks again from a
|
||||
Hungarian page is telling us something newer than last time. Anything unsupported is ignored
|
||||
rather than stored, so a forged body cannot put junk in the column.
|
||||
|
||||
The commit contract is unchanged: this still commits ONLY when it writes something. Callers rely
|
||||
on that — `_register_account` persists its own flushed user precisely because a no-op here
|
||||
commits nothing (E9.5 S6).
|
||||
"""
|
||||
email = (email or "").lower()
|
||||
if not valid_email(email):
|
||||
return None
|
||||
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
||||
return InviteRequest(None, False)
|
||||
lang = lang if lang in mailtext.SUPPORTED else None
|
||||
canon = canonical_email(email)
|
||||
rows = (
|
||||
db.execute(select(Invite).where(Invite.email_canonical == canon).order_by(Invite.id))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
# An approved row wins, so a denied duplicate can't mask an approval. Then a row that is not
|
||||
# DENIED, preferring the exact spelling — a denied sibling must never be picked while a pending
|
||||
# row exists, or the reopen branch below would fire and put one person in the admin's queue
|
||||
# twice. Only when every row is denied does the spelling decide which one reopens.
|
||||
undecided = [r for r in rows if r.status != "denied"]
|
||||
inv = (
|
||||
next((r for r in rows if r.status == "approved"), None)
|
||||
or next((r for r in undecided if r.email == email), None)
|
||||
or next(iter(undecided), None)
|
||||
or next((r for r in rows if r.email == email), None)
|
||||
or next(iter(rows), None)
|
||||
)
|
||||
if inv is not None:
|
||||
relearned = lang is not None and inv.lang != lang
|
||||
if relearned:
|
||||
inv.lang = lang
|
||||
# Let a previously-denied person ask again; don't disturb approved/pending rows.
|
||||
if inv.status == "denied":
|
||||
inv.status = "pending"
|
||||
@@ -162,12 +268,39 @@ def upsert_pending_invite(db: Session, email: str) -> Invite | None:
|
||||
inv.decided_at = None
|
||||
inv.decided_by = None
|
||||
db.commit()
|
||||
return inv
|
||||
return None
|
||||
inv = Invite(email=email, status="pending")
|
||||
return InviteRequest(inv, True)
|
||||
if relearned:
|
||||
db.commit()
|
||||
return InviteRequest(inv, False)
|
||||
inv = Invite(email=email, status="pending", lang=lang)
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
return inv
|
||||
return InviteRequest(inv, True)
|
||||
|
||||
|
||||
def _access_notice_url(kind: str, email: str = "", inv: Invite | None = None) -> str:
|
||||
"""The landing URL for a Google sign-in that did not reach an account: `requested` (we recorded
|
||||
the request) or `denied` (nothing to record).
|
||||
|
||||
It rides the FRAGMENT, not the query string, for the same reason the verify/reset tokens do
|
||||
(SB3): a fragment never reaches the server, a proxy access log or a Referer header — and this
|
||||
notice now carries the user's address. Naming it is the point of E9.5 S4: on 2026-09-03 a
|
||||
registration as `npeter83.temu@` met an OAuth claim of `npeter83temu@`, and a screen that said
|
||||
"request recorded" without saying FOR WHAT hid the mismatch for a day.
|
||||
|
||||
`linked` names the address the request actually lives under, and ONLY when that differs from the
|
||||
one Google just returned — the honest version of "we didn't open a second request". Same
|
||||
spelling twice is just a repeat, and saying anything there would be noise.
|
||||
|
||||
urlencode (not an f-string): a `+tag` address must arrive as %2B, or the SPA's URLSearchParams
|
||||
would read it back as a space and mangle exactly the address this epic exists to protect.
|
||||
"""
|
||||
params = {"access": kind}
|
||||
if email:
|
||||
params["email"] = email
|
||||
if inv is not None and inv.email != email:
|
||||
params["linked"] = inv.email
|
||||
return "/#" + urlencode(params)
|
||||
|
||||
|
||||
_DEV_OAUTH_HOSTS = frozenset(
|
||||
@@ -201,6 +334,12 @@ class EmailIn(BaseModel):
|
||||
class EmailPasswordIn(BaseModel):
|
||||
email: str | None = None
|
||||
password: str | None = None
|
||||
# The language the sign-up page was in. Captured at registration because it is the ONLY moment
|
||||
# the choice exists anywhere the server can see: it lives in the browser until an account does,
|
||||
# and a preference nobody recorded cannot be backfilled later (the lesson `google_email` taught
|
||||
# in E9.5 S3 — those rows still read NULL until each user signs in again). Nothing reads it yet;
|
||||
# the bilingual mail templates are E9.5 S7.
|
||||
lang: str | None = None
|
||||
|
||||
|
||||
class TokenIn(BaseModel):
|
||||
@@ -283,26 +422,37 @@ async def callback(
|
||||
# A denied Google login doubles as an access request: record it for the admin and
|
||||
# land the user on a friendly "request received" screen instead of a raw 403.
|
||||
if email:
|
||||
inv = upsert_pending_invite(db, email)
|
||||
admins = admin_notify_emails(db)
|
||||
if inv is not None and admins:
|
||||
background.add_task(email_mod.send_admin_new_request, admins, email)
|
||||
return RedirectResponse(url="/?access=requested")
|
||||
return RedirectResponse(url="/?access=denied")
|
||||
# Google just told us this person's locale — the only language signal that will ever
|
||||
# exist for an address with no account, and the decision mail is written from it.
|
||||
req = upsert_pending_invite(db, email, (userinfo.get("locale") or "").split("-")[0])
|
||||
if req.is_new:
|
||||
notify_admins_of_request(db, background, email)
|
||||
return RedirectResponse(url=_access_notice_url("requested", email, req.invite))
|
||||
return RedirectResponse(url=_access_notice_url("denied"))
|
||||
|
||||
user = db.query(User).filter(User.google_sub == sub).one_or_none()
|
||||
if user is None:
|
||||
# No account carries this Google identity yet. If one already exists for this email —
|
||||
# No account carries this Google identity yet. If one already exists for this MAILBOX —
|
||||
# e.g. an email+password registration — adopt the Google identity onto it instead of
|
||||
# inserting a duplicate (which would collide on the unique email and 500). Google has
|
||||
# verified the address, so this safely unifies password + Google sign-in on one account.
|
||||
existing = db.query(User).filter(User.email == email).one_or_none()
|
||||
# inserting a duplicate. Google has verified the address, so this safely unifies password +
|
||||
# Google sign-in on one account.
|
||||
#
|
||||
# Matched canonically, and that is not cosmetic: on 2026-09-03 a registration as
|
||||
# `npeter83.temu@gmail.com` met an OAuth claim of `npeter83temu@gmail.com`, the literal
|
||||
# lookup found nothing, and prod ended up with TWO active user rows for one Gmail mailbox
|
||||
# (ids 13 and 14). Exact spelling still wins when both exist, so an account that already
|
||||
# matches literally is never passed over for a canonical sibling.
|
||||
canon = canonical_email(email)
|
||||
candidates = db.query(User).filter(User.email_canonical == canon).order_by(User.id).all()
|
||||
existing = next((u for u in candidates if u.email == email), None) or next(
|
||||
iter(candidates), None
|
||||
)
|
||||
if existing is not None:
|
||||
if existing.google_sub is not None and existing.google_sub != sub:
|
||||
# A different Google identity is already bound to this email — refuse rather than
|
||||
# hijack it. (Near-impossible with real Google accounts: email↔sub is stable.)
|
||||
log.warning("Google login email collision (different sub): %s", email)
|
||||
return RedirectResponse(url="/?access=denied")
|
||||
return RedirectResponse(url=_access_notice_url("denied"))
|
||||
if not userinfo.get("email_verified", False):
|
||||
# Adopting (and activating) a pre-existing password account requires Google to
|
||||
# actually attest the address — otherwise an unverified Google email that merely
|
||||
@@ -311,7 +461,7 @@ async def callback(
|
||||
"Google login: unverified email, refusing to adopt account: %s",
|
||||
email,
|
||||
)
|
||||
return RedirectResponse(url="/?access=denied")
|
||||
return RedirectResponse(url=_access_notice_url("denied"))
|
||||
user = existing
|
||||
user.google_sub = sub
|
||||
# SEC-10: drop any password pre-set on this account while its email was still unverified,
|
||||
@@ -328,22 +478,23 @@ async def callback(
|
||||
# tell the (verified) owner why — a successful Google auth proves they control this account.
|
||||
if user.is_suspended:
|
||||
log.info("Suspended account blocked at Google login: %s", email)
|
||||
_notify_suspended(background, user.email)
|
||||
_notify_suspended(background, user)
|
||||
return RedirectResponse(url="/?login=suspended")
|
||||
# Keep the email in sync with Google, but never overwrite it with one another account already
|
||||
# owns — that would hit the unique constraint and 500, wedging this user's sign-in (rare: a
|
||||
# Google-side email change to a value that collides with a different local account).
|
||||
if user.email != email:
|
||||
clash = db.query(User).filter(User.email == email, User.id != user.id).one_or_none()
|
||||
if clash is None:
|
||||
user.email = email
|
||||
# Record what Google actually answers with, beside the address the user registered with. The two
|
||||
# are the same string for most people; where they differ (a deliberate `+tag`, a different dot
|
||||
# pattern) the UI shows both, so nobody has to guess which one signs them in.
|
||||
if email:
|
||||
user.google_email = email
|
||||
_sync_google_email(db, user, email)
|
||||
user.display_name = userinfo.get("name")
|
||||
user.avatar_url = userinfo.get("picture")
|
||||
# ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't
|
||||
# be locked out. Everyone else's role is managed in the admin UI and stored in the DB, so a
|
||||
# routine login must NOT reset it (that would clobber a UI promotion/demotion). Only seed a
|
||||
# default for a brand-new account (its role attribute is still unset before flush).
|
||||
if email in settings.admin_email_set:
|
||||
# Canonical, like every other read of the env lists: a dotted or `+tag`ged ADMIN_EMAILS entry
|
||||
# would otherwise never bootstrap its admin, because Google answers with the plain primary.
|
||||
if canonical_email(email) in env_admin_canonicals():
|
||||
user.role = "admin"
|
||||
elif not user.role:
|
||||
user.role = "user"
|
||||
@@ -355,8 +506,7 @@ async def callback(
|
||||
# picked one yet); unsupported locales fall back to English.
|
||||
prefs = dict(user.preferences or {})
|
||||
if not prefs.get("language"):
|
||||
loc = (userinfo.get("locale") or "").split("-")[0].lower()
|
||||
prefs["language"] = loc if loc in {"en", "hu"} else "en"
|
||||
prefs["language"] = mailtext.normalize((userinfo.get("locale") or "").split("-")[0].lower())
|
||||
user.preferences = prefs
|
||||
db.flush()
|
||||
|
||||
@@ -375,6 +525,30 @@ async def callback(
|
||||
return RedirectResponse(url="/")
|
||||
|
||||
|
||||
def _sync_google_email(db: Session, user: User, email: str) -> None:
|
||||
"""Follow a genuine Google-side email change — and ONLY that.
|
||||
|
||||
A different SPELLING of the same mailbox is the user's own choice and must survive. People
|
||||
register as `me+siftlode@gmail.com` precisely so a later leak from this site is identifiable, and
|
||||
silently rewriting that to `me@gmail.com` destroys the thing it was for. It also locked them out
|
||||
of the spelling they chose, because sign-in matches the stored address first. Google returns the
|
||||
account's PRIMARY address, which need not be what they registered with — and can never contain a
|
||||
`+` at all — so without this guard every plus-tagged registration lost its tag on the first Google
|
||||
sign-in. (E9.5 S3 made that reachable by matching adoption across spellings; the rewrite itself is
|
||||
older.)
|
||||
|
||||
Never overwrite with an address another account already owns either: that would hit the unique
|
||||
constraint and 500, wedging this user's sign-in.
|
||||
"""
|
||||
if not email or user.email == email:
|
||||
return
|
||||
if canonical_email(user.email) == canonical_email(email):
|
||||
return # same mailbox, their spelling — leave it alone
|
||||
clash = db.query(User).filter(User.email == email, User.id != user.id).one_or_none()
|
||||
if clash is None:
|
||||
user.email = email
|
||||
|
||||
|
||||
def _store_token(db: Session, user: User, token: dict) -> None:
|
||||
"""Persist the OAuth grant on the user's single token row, creating it if absent. Google
|
||||
only returns a refresh_token on (re)consent, so the previous one is kept otherwise.
|
||||
@@ -435,6 +609,9 @@ def _complete_link(
|
||||
if user.google_sub is not None and user.google_sub != sub:
|
||||
return RedirectResponse(url="/?link=mismatch")
|
||||
user.google_sub = sub
|
||||
linked_email = (userinfo.get("email") or "").strip().lower()
|
||||
if linked_email:
|
||||
user.google_email = linked_email # see the sign-in callback: the other half of the identity
|
||||
if not user.display_name:
|
||||
user.display_name = userinfo.get("name")
|
||||
if not user.avatar_url:
|
||||
@@ -468,15 +645,23 @@ async def logout(request: Request):
|
||||
return JSONResponse({"ok": True, "switched": False})
|
||||
|
||||
|
||||
class AccessRequestIn(EmailIn):
|
||||
# The language of the page this request was made from. Same reasoning as registration's `lang`
|
||||
# (and the same shape): it is the ONLY moment this preference exists anywhere the server can
|
||||
# see, since the requester has no account to carry it, and it decides what language the approval
|
||||
# or denial mail is written in.
|
||||
lang: str | None = None
|
||||
|
||||
|
||||
@router.post("/request-access")
|
||||
async def request_access(
|
||||
body: EmailIn,
|
||||
body: AccessRequestIn,
|
||||
request: Request,
|
||||
background: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Public: ask for access. Idempotent — repeat requests for the same email don't
|
||||
duplicate or re-spam admins (upsert returns None for an already-pending row).
|
||||
duplicate or re-spam admins (upsert answers `is_new=False` for an already-pending row).
|
||||
|
||||
Answers a uniform ``"pending"`` for EVERY outcome — already-allowed, newly-pending,
|
||||
already-pending, and rate-limited alike. The old ``"approved"`` reply for an allow-listed
|
||||
@@ -491,10 +676,9 @@ async def request_access(
|
||||
return {"status": "pending"}
|
||||
if is_allowed(db, email):
|
||||
return {"status": "pending"} # already allowed — don't reveal it
|
||||
inv = upsert_pending_invite(db, email)
|
||||
admins = admin_notify_emails(db)
|
||||
if inv is not None and admins:
|
||||
background.add_task(email_mod.send_admin_new_request, admins, email)
|
||||
req = upsert_pending_invite(db, email, body.lang)
|
||||
if req.is_new:
|
||||
notify_admins_of_request(db, background, email)
|
||||
return {"status": "pending"}
|
||||
|
||||
|
||||
@@ -551,6 +735,29 @@ def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str:
|
||||
return raw
|
||||
|
||||
|
||||
def _peek_token(db: Session, raw: str | None, kind: str) -> User | None:
|
||||
"""Is this token still usable — WITHOUT spending it. Same four conditions as _consume_token, and
|
||||
the same "does its user still exist" answer, so the two can never disagree about a given token.
|
||||
|
||||
Deliberately NOT the read half of a read-check-write: _consume_token stays one atomic UPDATE, and
|
||||
this never feeds it. A peek is only ever an early warning — the confirm endpoint remains the
|
||||
authority, because a token can expire, be spent, or lose its account between the two calls.
|
||||
"""
|
||||
if not raw:
|
||||
return None
|
||||
row = db.execute(
|
||||
select(AuthToken).where(
|
||||
AuthToken.token_hash == hash_token(raw),
|
||||
AuthToken.kind == kind,
|
||||
AuthToken.used_at.is_(None),
|
||||
AuthToken.expires_at >= datetime.now(timezone.utc),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
# db.get, exactly as _consume_token does: a deleted account cascades its tokens away, so this is
|
||||
# belt-and-braces — but the two must agree, and agreeing by construction beats agreeing by luck.
|
||||
return db.get(User, row.user_id) if row is not None else None
|
||||
|
||||
|
||||
def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
|
||||
"""Validate + burn a token atomically. Returns its user, or None if missing/expired/used/wrong
|
||||
kind. The burn is a single conditional UPDATE (…WHERE used_at IS NULL…RETURNING) rather than a
|
||||
@@ -576,6 +783,35 @@ def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
|
||||
return db.get(User, row.user_id)
|
||||
|
||||
|
||||
def _consume_password_token(db: Session, raw: str | None) -> tuple[User, str] | None:
|
||||
"""Spend a password-setting token of either kind; returns (user, kind). The kind is returned, not
|
||||
swallowed, because the caller has to treat them differently — only a mailed reset attests the
|
||||
address."""
|
||||
for kind in _PASSWORD_TOKEN_KINDS:
|
||||
user = _consume_token(db, raw, kind)
|
||||
if user is not None:
|
||||
return user, kind
|
||||
return None
|
||||
|
||||
|
||||
def issue_set_password_link(db: Session, user: User) -> tuple[str, datetime]:
|
||||
"""An admin-issued, single-use link that lets `user` set a password without any email (E12 S5).
|
||||
|
||||
One mechanism answers three things: SEC-15 (on a no-SMTP instance every registration is
|
||||
auto-verified, so a pre-empted address cannot be recovered by mail), SEC-16 (a pre-empted address
|
||||
stays takeable until its owner resets, and password-reset needs working mail), and the everyday
|
||||
"no SMTP configured and someone forgot their password".
|
||||
|
||||
Issuing does NOT revoke the current password: an admin misclick must not lock anyone out, and the
|
||||
account may be perfectly healthy. USING the link replaces the hash and bumps the session epoch,
|
||||
which is what evicts a squatter — see password_reset_confirm."""
|
||||
expires_at = datetime.now(timezone.utc) + ADMIN_SETPW_TTL
|
||||
raw = _issue_token(db, user, "setpw", ADMIN_SETPW_TTL)
|
||||
# The same `#reset=` fragment the mailed link uses: the signed-out card already knows how to
|
||||
# consume it, and a second entry shape would be a second thing to keep correct.
|
||||
return f"{_app_base()}/#reset={raw}", expires_at
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
def register(
|
||||
body: EmailPasswordIn,
|
||||
@@ -606,17 +842,59 @@ def register(
|
||||
# session). The create path hashes the password + writes several rows + schedules emails; doing it
|
||||
# inline would make a NEW email respond measurably slower than an already-registered one (which
|
||||
# skips all that) — a timing oracle for enumeration. Off-path, any valid email responds the same.
|
||||
background.add_task(_register_account, email, password)
|
||||
background.add_task(_register_account, email, password, body.lang)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def _register_account(email: str, password: str) -> None:
|
||||
def _register_account(email: str, password: str, lang: str | None = None) -> None:
|
||||
"""Background worker for /register: create the pending account (+ verification email + admin
|
||||
notice) for a genuinely new email; no-op for an already-registered one. Runs after the response
|
||||
with its own DB session, so the request's timing never reveals whether the email already exists."""
|
||||
with SessionLocal() as db:
|
||||
if db.execute(select(User).where(User.email == email)).scalar_one_or_none() is not None:
|
||||
return # already registered — nothing to do (the uniform "ok" was already returned)
|
||||
# Canonical, so a second spelling of one Gmail mailbox cannot open a SECOND account. Prod
|
||||
# already carries such a pair (user ids 13 and 14, both for npeter83temu@gmail.com); this is
|
||||
# the end that stops new ones appearing, while the Google callback copes with the existing.
|
||||
candidates = (
|
||||
db.execute(
|
||||
select(User).where(User.email_canonical == canonical_email(email)).order_by(User.id)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
# Exact spelling wins when a mailbox carries two rows, same as every other canonical lookup
|
||||
# in this epic (`find_user_by_email`, `_activate_user_by_email`, the Google callback). Without
|
||||
# it `.first()` handed back whichever row Postgres felt like, so a re-registration of the
|
||||
# dotted spelling could resolve to the undotted, already-confirmed twin and send nothing.
|
||||
existing = next((u for u in candidates if u.email == email), None) or next(
|
||||
iter(candidates), None
|
||||
)
|
||||
if existing is not None:
|
||||
# Already registered. This used to return in silence, and that silence is the most
|
||||
# likely explanation for the 2026-09-03 report that a confirmation mail "never arrived":
|
||||
# nothing was ever SENT. The person re-registered, got the same cheerful "check your
|
||||
# inbox", and waited for a message that did not exist.
|
||||
#
|
||||
# Re-sending the link instead leaks nothing new. The response and its timing are
|
||||
# unchanged (uniform "ok", decided off the response path), the mail goes to the address
|
||||
# ON THE ACCOUNT, and this is exactly what the public /auth/verify/resend already does
|
||||
# for the same input — so it is not a new capability, just the end of a dead end. An
|
||||
# account that is already confirmed still gets nothing: there is nothing to confirm.
|
||||
#
|
||||
# And a re-send is ALL it does: the submitted password is discarded. E9.5's closing
|
||||
# review tried "while the address is unverified, the newest registration owns it"
|
||||
# (replace the hash, kill the outstanding verify tokens) as a fix for address
|
||||
# pre-emption, and round 2 showed it was worse than the problem — DO NOT REPEAT IT.
|
||||
# The rule is symmetric, and an attacker can always be last: it hands an unauthenticated
|
||||
# caller the power to overwrite a real registrant's chosen password and delete the
|
||||
# confirmation link already in their inbox, and on a SEC-10-stripped row (password_hash
|
||||
# NULL, password_revoked_at set) it would install a password where the whole point was
|
||||
# that there is none. The owner's real recourse already exists and needs no new
|
||||
# primitive: password-reset accepts this account, proves the mailbox, rewrites the hash
|
||||
# and bumps the session epoch. The residual is logged as SEC-16.
|
||||
user = existing
|
||||
if not user.email_verified and not user.is_demo and email_mod.email_enabled():
|
||||
send_verification_link(db, user)
|
||||
return
|
||||
# Without working SMTP we can't deliver a verification link, so email ownership can't gate
|
||||
# sign-in. Admin approval stays the real gate (is_active=False); mark the account verified so
|
||||
# the flow still completes on a no-SMTP self-host. See email.email_enabled().
|
||||
@@ -626,6 +904,8 @@ def _register_account(email: str, password: str) -> None:
|
||||
password_hash=hash_password(password),
|
||||
is_active=False,
|
||||
email_verified=not email_ok,
|
||||
# Only a supported code is stored, so a forged body can't put junk in preferences.
|
||||
preferences={"language": lang} if lang in mailtext.SUPPORTED else {},
|
||||
)
|
||||
db.add(user)
|
||||
try:
|
||||
@@ -636,28 +916,81 @@ def _register_account(email: str, password: str) -> None:
|
||||
# no-op as the pre-check hit — roll back and stop.
|
||||
db.rollback()
|
||||
return
|
||||
upsert_pending_invite(db, email) # admin-approval gate (commits)
|
||||
upsert_pending_invite(db, email) # admin-approval gate
|
||||
# Persist the account ourselves. upsert_pending_invite commits only when it actually writes an
|
||||
# invite row — not when this address already has a pending or approved one — and _issue_token
|
||||
# below only runs with SMTP on. So for an address the admin had already approved (or one whose
|
||||
# request was still pending), on an instance with no working SMTP, NOTHING along this path
|
||||
# committed and the flushed user was silently rolled back when the session closed: the
|
||||
# would-be user got the uniform "ok" and no account, and re-registering did the same again.
|
||||
# Commit unconditionally; it is a no-op when upsert already committed the same transaction.
|
||||
db.commit()
|
||||
if email_ok:
|
||||
raw = _issue_token(db, user, "verify", VERIFY_TTL)
|
||||
# Fragment (#), not a query token: keeps the token out of proxy/access logs + Referer (SB3).
|
||||
email_mod.send_verify_email(email, f"{_app_base()}/#verify={raw}")
|
||||
admins = admin_notify_emails(db)
|
||||
if admins:
|
||||
email_mod.send_admin_new_request(admins, email)
|
||||
send_verification_link(db, user)
|
||||
# Fired unconditionally, unlike the other two callers which gate on a fresh invite row: for
|
||||
# an address the admin already approved, nothing lands in the Access-requests queue, so this
|
||||
# is the only place the account's existence is announced at all. Delivery is a separate
|
||||
# matter — with no working SMTP `_send` drops it, which is why the pre-approved registrant
|
||||
# still needs the admin to notice them (roadmap UX-25).
|
||||
notify_admins_of_request(db, None, email)
|
||||
|
||||
|
||||
def issue_verification_link(db: Session, user: User, ttl: timedelta | None = None) -> str:
|
||||
"""A fresh confirmation link for `user`, returned rather than sent — for a mail that carries the
|
||||
link inside a larger message (the branching approval/invitation mails, E9.5 S6).
|
||||
|
||||
Same token machinery and the same fragment (SB3) as the standalone confirmation mail, so the two
|
||||
kinds of link cannot drift apart in shape or in how they are consumed."""
|
||||
raw = _issue_token(db, user, "verify", ttl or VERIFY_TTL)
|
||||
return f"{_app_base()}/#verify={raw}"
|
||||
|
||||
|
||||
def send_verification_link(db: Session, user: User) -> bool:
|
||||
"""Issue a fresh verification token for `user` and mail them the link.
|
||||
|
||||
Always to the address ON THE ACCOUNT, never a spelling typed into some form: the account's
|
||||
address is the one its owner chose to be reachable at, and a `+tag` there is usually deliberate.
|
||||
The token rides the URL FRAGMENT (#) so it can never reach a proxy/access log or a Referer (SB3).
|
||||
|
||||
One definition, three callers — registration, the self-service resend, and the admin-side resend
|
||||
(E9.5 S5). The link's shape and TTL must not drift between them; a verification mail that differs
|
||||
by who triggered it is a bug waiting to be discovered by exactly one user.
|
||||
|
||||
Returns whether the mail actually went out, so a caller with a human waiting on the answer (the
|
||||
admin-side resend) can say so instead of reporting success for a message that was dropped.
|
||||
"""
|
||||
return email_mod.send_verify_email(
|
||||
user.email,
|
||||
mailtext.lang_for_user(user),
|
||||
issue_verification_link(db, user),
|
||||
hours=int(VERIFY_TTL.total_seconds() // 3600),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/verify")
|
||||
def verify_email_confirm(body: TokenIn, db: Session = Depends(get_db)) -> dict:
|
||||
"""Confirm an email-verification token the SPA read from the URL fragment (SB3). Uniform 400
|
||||
on any invalid/expired/used token."""
|
||||
on any invalid/expired/used token.
|
||||
|
||||
`password_reset_required` tells the SPA to send the user straight to the reset flow: SEC-10 had
|
||||
dropped the password this account registered with, so sign-in would answer with the deliberately
|
||||
uniform "Invalid email or password" — true, but unreadable to the person who just proved they own
|
||||
the mailbox. Saying so leaks nothing: holding a valid single-use verify token for this account
|
||||
already proves that much. Since E9.5 S1 the common source of that state is gone (admin approval no
|
||||
longer revokes), leaving accounts stripped before that change and the Google-adoption path, so this
|
||||
flag is rare now — but it must stay, because those users have no other way back in."""
|
||||
user = _consume_token(db, body.token, "verify")
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="This verification link is invalid or has expired."
|
||||
)
|
||||
raise HTTPException(status_code=400, detail={"code": "verify", "reason": "invalid"})
|
||||
user.email_verified = True
|
||||
needs_password = user.password_hash is None and user.password_revoked_at is not None
|
||||
email = user.email
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
# Naming the address lets the SPA say WHICH mailbox it just confirmed — the same reasoning as
|
||||
# password_reset_required above: holding a valid single-use verify token for this account already
|
||||
# proves that much, so echoing it back leaks nothing. It matters in a browser that holds a session
|
||||
# for a different account, where an unqualified "email confirmed" would be actively misleading.
|
||||
return {"ok": True, "password_reset_required": needs_password, "email": email}
|
||||
|
||||
|
||||
@router.post("/verify/resend")
|
||||
@@ -687,22 +1020,36 @@ def _resend_verify_if_eligible(email: str) -> None:
|
||||
if not email_mod.email_enabled():
|
||||
return
|
||||
with SessionLocal() as db:
|
||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
user = find_user_by_email(db, email)
|
||||
if user is not None and not user.email_verified and not user.is_demo:
|
||||
raw = _issue_token(db, user, "verify", VERIFY_TTL)
|
||||
email_mod.send_verify_email(email, f"{_app_base()}/#verify={raw}")
|
||||
send_verification_link(db, user)
|
||||
|
||||
|
||||
@router.get("/verify")
|
||||
def verify_email(token: str, db: Session = Depends(get_db)):
|
||||
"""Legacy query-token verification link (pre-SB3 emails still in flight). New emails use the
|
||||
fragment + POST flow above; this bounces to a friendly status for older links until they expire."""
|
||||
user = _consume_token(db, token, "verify")
|
||||
if user is None:
|
||||
return RedirectResponse(url="/?verify=invalid")
|
||||
user.email_verified = True
|
||||
db.commit()
|
||||
return RedirectResponse(url="/?verify=ok")
|
||||
def _login_refused(status: int, reason: str, user: User | None = None, **extra) -> HTTPException:
|
||||
"""Every refusal password_login can answer with, in the codebase's structured `{code, reason}`
|
||||
shape so the SPA can say it in the user's own language (the old bare English strings were
|
||||
unreadable under a bilingual UI, and unusable as anything but a one-line error).
|
||||
|
||||
The account-shaped reasons carry the address ON THE ACCOUNT plus both gate flags. That is what
|
||||
turns a flat 403 into an honest screen — it names the mailbox and says which of the two gates is
|
||||
still shut. It is not an enumeration leak: this branch is only reachable once the password has
|
||||
been proven, so the caller already knows the account exists. The two reasons that answer BEFORE
|
||||
that proof (`invalid`, `rate_limited`) carry nothing at all, so they stay byte-identical for a
|
||||
real account and an unknown one.
|
||||
|
||||
Since E9.5 S1 the two gates are order-independent, so `approved` really can be true while
|
||||
`email_verified` is false, and vice versa — the SPA renders whichever is actually outstanding
|
||||
rather than assuming a sequence.
|
||||
"""
|
||||
detail: dict = {"code": "login", "reason": reason}
|
||||
if user is not None:
|
||||
detail |= {
|
||||
"email": user.email,
|
||||
"email_verified": user.email_verified,
|
||||
"approved": user.is_active,
|
||||
}
|
||||
detail |= {k: v for k, v in extra.items() if v is not None}
|
||||
return HTTPException(status_code=status, detail=detail)
|
||||
|
||||
|
||||
@router.post("/password-login")
|
||||
@@ -716,29 +1063,23 @@ def password_login(
|
||||
Once the password is proven correct, the owner gets a specific reason if their account is
|
||||
still pending or suspended — that's not an enumeration leak (they already hold the password)."""
|
||||
if not _login_limiter.allow(_client_ip(request)):
|
||||
raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.")
|
||||
raise _login_refused(429, "rate_limited")
|
||||
email = (body.email or "").strip().lower()
|
||||
password = body.password or ""
|
||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
user = find_user_by_email(db, email)
|
||||
# Always run the KDF (against a decoy hash for an unknown / passwordless / demo account) so the
|
||||
# response time is the same whether or not the email exists — no timing-based enumeration.
|
||||
candidate_hash = user.password_hash if (user and not user.is_demo) else None
|
||||
password_ok = verify_password(password, candidate_hash or _DECOY_PASSWORD_HASH)
|
||||
if user is None or user.is_demo or not password_ok:
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password.")
|
||||
raise _login_refused(401, "invalid")
|
||||
if user.is_suspended:
|
||||
_notify_suspended(background, user.email)
|
||||
op = operator_contact()
|
||||
detail = "Your account has been suspended."
|
||||
if op:
|
||||
detail += f" If you have questions, contact the operator at {op}."
|
||||
raise HTTPException(status_code=403, detail=detail)
|
||||
_notify_suspended(background, user)
|
||||
raise _login_refused(403, "suspended", user, operator=operator_contact() or None)
|
||||
if not user.email_verified:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Please verify your email first (check your inbox)."
|
||||
)
|
||||
raise _login_refused(403, "email_unverified", user)
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="Your account is awaiting admin approval.")
|
||||
raise _login_refused(403, "pending_approval", user)
|
||||
_establish_session(request, user)
|
||||
log.info("Password login: %s (id=%s, role=%s)", email, user.id, user.role)
|
||||
return {"ok": True}
|
||||
@@ -767,12 +1108,43 @@ def _send_reset_if_eligible(email: str) -> None:
|
||||
"""Background worker for password_reset_request: issue a reset token + email the link, but ONLY
|
||||
for a real (non-demo) password account. Runs after the response with its own DB session, so the
|
||||
request's timing never reveals whether the account exists. Token rides the URL FRAGMENT (#) so it
|
||||
can't leak into proxy/access logs or a Referer (SB3)."""
|
||||
can't leak into proxy/access logs or a Referer (SB3).
|
||||
|
||||
"Password account" is `password_hash OR password_revoked_at` — an account SEC-10 stripped had a
|
||||
password and must be able to get a new one, or it is locked out permanently (no password to sign in
|
||||
with, and formerly no reset link either). A Google-only account that never had a password stays
|
||||
ineligible on purpose: its owner adds one from Settings (/auth/set-password) while signed in, which
|
||||
proves identity through the session rather than by mail."""
|
||||
with SessionLocal() as db:
|
||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
if user is not None and user.password_hash and not user.is_demo:
|
||||
user = find_user_by_email(db, email)
|
||||
if user is None or user.is_demo:
|
||||
return
|
||||
if user.password_hash or user.password_revoked_at:
|
||||
raw = _issue_token(db, user, "reset", RESET_TTL)
|
||||
email_mod.send_password_reset(email, f"{_app_base()}/#reset={raw}")
|
||||
# The account's own address, as above — never the spelling that was typed into the form.
|
||||
email_mod.send_password_reset(
|
||||
user.email, mailtext.lang_for_user(user), f"{_app_base()}/#reset={raw}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/password-reset/check")
|
||||
def password_reset_check(body: TokenIn, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
"""Is the reset link the SPA just opened still good? Answers without spending the token.
|
||||
|
||||
Why this exists: the card used to render "set a new password" purely because a `#reset=` fragment
|
||||
was present, never asking whether it could still succeed. Reported on 2026-09-05 after deleting an
|
||||
account and then opening its reset link — the user types a new password into a form that answers
|
||||
400. Opening a dead reset link is not an edge case: the token lives one hour, mail is slow or lands
|
||||
in spam, and whoever got two mails usually clicks the older one.
|
||||
|
||||
Not an enumeration oracle: probing requires already holding a 43-character random, and the answer
|
||||
is exactly what submitting it would reveal. Throttled per IP anyway — and a throttled caller is
|
||||
told `true`, never `false`: this check may only ever save a user a wasted form, so when it cannot
|
||||
answer it must degrade to the old behaviour instead of declaring a live link dead.
|
||||
"""
|
||||
if not _reset_check_limiter.allow(_client_ip(request)):
|
||||
return {"ok": True}
|
||||
return {"ok": any(_peek_token(db, body.token, k) is not None for k in _PASSWORD_TOKEN_KINDS)}
|
||||
|
||||
|
||||
@router.post("/password-reset/confirm")
|
||||
@@ -783,17 +1155,25 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
|
||||
if len(new_password) < PASSWORD_MIN_LEN:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
|
||||
detail={"code": "reset", "reason": "weak_password", "min": PASSWORD_MIN_LEN},
|
||||
)
|
||||
user = _consume_token(db, token, "reset")
|
||||
if user is None:
|
||||
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.")
|
||||
spent = _consume_password_token(db, token)
|
||||
if spent is None:
|
||||
raise HTTPException(status_code=400, detail={"code": "reset", "reason": "invalid"})
|
||||
user, kind = spent
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.password_revoked_at = None # the account has a password again — SEC-10's trace is spent
|
||||
# A valid reset link proves control of the mailbox, so completing a reset also verifies the
|
||||
# email — this rescues an account that registered but never clicked the verification link (it
|
||||
# can then sign in). The separate admin-approval gate (is_active) is deliberately NOT touched
|
||||
# here: a reset proves email ownership, not that an admin approved the account.
|
||||
user.email_verified = True
|
||||
#
|
||||
# An admin-issued `setpw` link proves NOTHING about the mailbox — the admin hands it over by
|
||||
# whatever means they choose — so it must not satisfy the email gate. Claiming otherwise would let
|
||||
# an admin confirm an address nobody ever proved, which is the same false attestation SEC-15 is
|
||||
# about. The person still confirms their address the normal way.
|
||||
if kind == "reset":
|
||||
user.email_verified = True
|
||||
bump_session_epoch(
|
||||
user
|
||||
) # SA4: a reset (often a compromise response) kills all existing sessions.
|
||||
@@ -801,7 +1181,7 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
|
||||
for row in db.execute(
|
||||
select(AuthToken).where(
|
||||
AuthToken.user_id == user.id,
|
||||
AuthToken.kind == "reset",
|
||||
AuthToken.kind.in_(_PASSWORD_TOKEN_KINDS),
|
||||
AuthToken.used_at.is_(None),
|
||||
)
|
||||
).scalars():
|
||||
@@ -828,12 +1208,33 @@ def bump_session_epoch(user: User) -> None:
|
||||
def revoke_preset_password_if_unverified(user: User) -> None:
|
||||
"""SEC-10 (account pre-emption): a password set on an account whose email was NEVER verified is
|
||||
untrusted — whoever registered the address (to pre-empt the real owner) could have chosen it. When
|
||||
ownership is finally attested (Google adoption, or admin approval), drop that password and bump the
|
||||
epoch to revoke any session it minted; the owner re-sets one via password-reset. No-op for an
|
||||
already-verified account, so a legit no-SMTP registration is unaffected. The single source for this
|
||||
rule — both the Google-callback and admin-activation paths call it so they can't drift."""
|
||||
the real owner attests ownership through Google, drop that password and bump the epoch to revoke any
|
||||
session it minted; the owner re-sets one via password-reset. No-op for an already-verified account,
|
||||
so a legit no-SMTP registration is unaffected.
|
||||
|
||||
The Google-adoption callback is the ONLY caller (E9.5 S1). Admin approval used to call this too, and
|
||||
dropping it was right for the reason S1 gave — the revoke destroyed the password a legitimate
|
||||
registrant had just chosen, and since the admin normally decides before the user reads their mail,
|
||||
that made the "your password was cleared" detour the DEFAULT path rather than an edge case.
|
||||
|
||||
S1 also claimed the revoke was REDUNDANT there, because `password_login` refuses an unverified
|
||||
account before it reads `is_active`. That part was wrong, and the epic's own review caught it: the
|
||||
gate holds only while the address is unverified, and S6's approval mail exists to get the real
|
||||
owner to end that state — at which point a pre-emptor's password works. Nothing replaced the
|
||||
revoke, and that is deliberate. The review tried "the newest registration owns an unverified
|
||||
account" and reverted it: the rule is symmetric, so an attacker can simply register last, and it
|
||||
would have handed anonymous callers a write onto a real account's credential. What the owner
|
||||
actually has is password-reset, which proves the mailbox, rewrites the hash and bumps the epoch.
|
||||
The residual is logged as SEC-16; re-adding a caller here would put "your password was cleared"
|
||||
back in front of every legitimate registrant, which is the whole reason S1 removed it.
|
||||
|
||||
`password_revoked_at` is what makes "the owner re-sets one via password-reset" actually true. The
|
||||
revoke leaves `password_hash IS NULL`, which is also what a Google-only account looks like — and
|
||||
_send_reset_if_eligible keyed off exactly that, so a revoked account silently got no reset link and
|
||||
was locked out for good (it could not sign in either). The stamp separates the two cases."""
|
||||
if not user.email_verified:
|
||||
user.password_hash = None
|
||||
user.password_revoked_at = datetime.now(timezone.utc)
|
||||
bump_session_epoch(user)
|
||||
|
||||
|
||||
@@ -903,6 +1304,7 @@ def set_password(
|
||||
if not verify_password(body.current_password or "", user.password_hash):
|
||||
raise HTTPException(status_code=403, detail="Current password is incorrect.")
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.password_revoked_at = None # same as the reset path: a password exists again
|
||||
# SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so
|
||||
# the person making the change stays signed in here. Commit BEFORE re-stamping so a failed commit
|
||||
# can't leave the cookie at a newer epoch than the DB (which would wrongly 401 THIS session).
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit
|
||||
from app import email as email_mod
|
||||
from app import mailtext
|
||||
from app import sysconfig
|
||||
from app.audit import AuditAction
|
||||
from app.auth import admin_user
|
||||
@@ -115,7 +116,7 @@ def send_test_email(
|
||||
lets the admin verify the settings actually work."""
|
||||
if not email_mod.email_enabled():
|
||||
raise HTTPException(status_code=400, detail="SMTP is not configured.")
|
||||
sent = email_mod.send_test(user.email)
|
||||
sent = email_mod.send_test(user.email, mailtext.lang_for_user(user))
|
||||
if not sent:
|
||||
raise HTTPException(status_code=422, detail="SMTP send failed — check the settings.")
|
||||
return {"sent": True, "to": user.email}
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.downloads import edit as editmod
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import quota, service, storage
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.downloads import urlguard
|
||||
from app.models import (
|
||||
DownloadJob,
|
||||
DownloadLink,
|
||||
@@ -65,7 +66,18 @@ def resolve_source(raw: str) -> tuple[str, str]:
|
||||
if _VIDEO_ID.match(raw):
|
||||
return "youtube", raw
|
||||
if raw.startswith(("http://", "https://")):
|
||||
u = urlparse(raw)
|
||||
# `urlparse` raises ValueError on a malformed authority ("http://[::1/x"), and this runs
|
||||
# BEFORE the urlguard check that would otherwise turn it into a structured refusal — so
|
||||
# unguarded, that typo escapes as a 500. Answered in urlguard's own shape, because it is the
|
||||
# same typo class: ":99999" is caught there and localized, and one of the two arriving as a
|
||||
# bare English string would be exactly the split the canonical-patterns table forbids.
|
||||
try:
|
||||
u = urlparse(raw)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"code": "url_blocked", "reason": urlguard.REASON_MALFORMED},
|
||||
) from exc
|
||||
host = u.netloc.lower()
|
||||
if "youtube.com" in host:
|
||||
if u.path.startswith(("/shorts/", "/embed/", "/live/")):
|
||||
@@ -352,6 +364,17 @@ def enqueue_download(
|
||||
body: EnqueueDownloadIn, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
source_kind, source_ref = resolve_source(body.source)
|
||||
# SEC-12: a non-YouTube source is a raw URL the worker will fetch server-side, so refuse the
|
||||
# ones pointing back into our own network BEFORE a job exists. The worker re-checks (DNS can
|
||||
# move between here and the download); see downloads/urlguard.py for what that does and does
|
||||
# not cover.
|
||||
if source_kind == "url":
|
||||
try:
|
||||
urlguard.check_url(source_ref)
|
||||
except urlguard.BlockedUrl as e:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "url_blocked", "reason": e.reason}
|
||||
) from e
|
||||
spec, profile_id = _resolve_spec(db, user, body.profile_id, body.spec)
|
||||
display_name = (body.display_name or "").strip() or None
|
||||
try:
|
||||
@@ -532,7 +555,12 @@ def _clean_url(raw) -> str | None:
|
||||
return None
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return None
|
||||
parsed = urlparse(url)
|
||||
# The third site in this module handing user input to `urlparse`, which raises on a malformed
|
||||
# authority. This one already has a "not a plausible URL" answer, so the typo belongs in it.
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
return None
|
||||
if not parsed.netloc:
|
||||
return None
|
||||
return url[:2048]
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import (
|
||||
count_admins,
|
||||
count_pending_activations,
|
||||
current_user,
|
||||
google_enabled,
|
||||
has_read_scope,
|
||||
@@ -13,6 +14,7 @@ from app.auth import (
|
||||
optional_current_user,
|
||||
purge_user,
|
||||
)
|
||||
from app import email as email_mod
|
||||
from app import sysconfig
|
||||
from app.db import get_db
|
||||
from app.models import Invite, User
|
||||
@@ -98,11 +100,21 @@ def get_me(
|
||||
if user is None:
|
||||
return {"authenticated": False}
|
||||
pending_invites = 0
|
||||
pending_activations = 0
|
||||
# Admin-only, because it drives an operator warning: email is fail-soft, so a half-filled SMTP
|
||||
# config costs verification links, password resets and access-request notices with nothing
|
||||
# visible anywhere. Surfacing it here puts the warning on the screens where onboarding happens.
|
||||
email_configured = True
|
||||
if user.role == "admin":
|
||||
pending_invites = (
|
||||
db.scalar(select(func.count()).select_from(Invite).where(Invite.status == "pending"))
|
||||
or 0
|
||||
)
|
||||
# Counted separately from pending_invites rather than folded into it: the two are different
|
||||
# facts (a request awaiting a decision vs a decided address whose sign-up never got let in),
|
||||
# and quietly widening one number would make an existing field mean something new.
|
||||
pending_activations = count_pending_activations(db)
|
||||
email_configured = email_mod.email_enabled(db) # this request's session; no second connect
|
||||
return {
|
||||
"authenticated": True,
|
||||
"id": user.id,
|
||||
@@ -112,6 +124,12 @@ def get_me(
|
||||
"role": user.role,
|
||||
"is_demo": user.is_demo,
|
||||
"has_google": user.google_sub is not None,
|
||||
# The address Google answers with, when it DIFFERS from the account's own. Same string for
|
||||
# most people, and then this is None so no surface has to decide whether to show it — the
|
||||
# comparison lives here, once, instead of in every consumer.
|
||||
"google_email": (
|
||||
user.google_email if user.google_email and user.google_email != user.email else None
|
||||
),
|
||||
"has_password": user.password_hash is not None,
|
||||
# Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides
|
||||
# YouTube-access affordances and the onboarding nudge when it isn't configured).
|
||||
@@ -121,6 +139,10 @@ def get_me(
|
||||
"can_read": has_read_scope(user),
|
||||
"can_write": has_write_scope(user),
|
||||
"pending_invites": pending_invites,
|
||||
# Sign-ups an admin still has to let in (roadmap UX-25): approved BEFORE they registered, so
|
||||
# nothing ever reached the Access-requests queue and the badge used to read zero.
|
||||
"pending_activations": pending_activations,
|
||||
"email_configured": email_configured,
|
||||
"preferences": user.preferences or {},
|
||||
}
|
||||
|
||||
|
||||
@@ -20,10 +20,30 @@ from app.sync.playlists import (
|
||||
repull_playlist,
|
||||
sync_user_playlists,
|
||||
)
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
|
||||
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
||||
|
||||
# SEC-14, same reasoning as the sync module's limiter: a playlist pull lists every playlist the
|
||||
# caller owns and then every item in each, against the instance-wide YouTube budget. Its own
|
||||
# limiter rather than a shared one — spending the manual subscription-import allowance must not
|
||||
# also close the playlist button, and vice versa.
|
||||
#
|
||||
# It guards EVERY route that spends under QuotaAction.PLAYLISTS_PULL, not just the bulk one: a
|
||||
# scripted loop over the single-playlist re-pull drains the same shared budget, and limiting one of
|
||||
# two routes with the same quota action is the asymmetric fix this codebase keeps re-learning.
|
||||
# Deliberately NOT on the push side (`push-plan` / `push`): those run on every push attempt,
|
||||
# including the no-op "already up to date" path, so a 3-per-10-minute allowance would refuse
|
||||
# legitimate work. Their exposure is tracked as SEC-18 instead.
|
||||
_playlist_pull_limiter = RateLimiter(max_events=3, window_seconds=600)
|
||||
|
||||
|
||||
def _check_pull_cooldown(user: User) -> None:
|
||||
"""Shared gate so the two PLAYLISTS_PULL routes cannot drift apart again."""
|
||||
if not _playlist_pull_limiter.allow(str(user.id)):
|
||||
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
||||
|
||||
|
||||
def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||
pl = db.get(Playlist, playlist_id)
|
||||
@@ -284,6 +304,7 @@ def sync_youtube(
|
||||
status_code=403,
|
||||
detail="Connect YouTube (read access) to sync your playlists.",
|
||||
)
|
||||
_check_pull_cooldown(user)
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||
return sync_user_playlists(db, user)
|
||||
|
||||
@@ -304,6 +325,7 @@ def revert_youtube(
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Connect YouTube (read access) to sync your playlists."
|
||||
)
|
||||
_check_pull_cooldown(user)
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||
return repull_playlist(db, user, pl)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Integer,
|
||||
String,
|
||||
and_,
|
||||
@@ -27,6 +28,7 @@ from app.plex import rollup
|
||||
from app.plex.cards import episode_card
|
||||
from app.plex.query import (
|
||||
FIELD_COLUMNS,
|
||||
PLAYBACK_CLASSES,
|
||||
TS_CONFIG,
|
||||
LibraryFilters,
|
||||
apply_meta_filters,
|
||||
@@ -36,6 +38,8 @@ from app.plex.query import (
|
||||
genres_col,
|
||||
meta_filter_conds,
|
||||
movie_status_case,
|
||||
playable_conds,
|
||||
playback_class_expr,
|
||||
show_status_case,
|
||||
split_csv,
|
||||
summary_col,
|
||||
@@ -135,6 +139,7 @@ def unified_library(
|
||||
PlexItem.originally_available_at.label("rel"),
|
||||
PlexItem.duration_s.label("duration_s"),
|
||||
PlexItem.playable.label("playable"),
|
||||
PlexItem.transcode_hw.label("transcode_hw"),
|
||||
cast(null(), Integer).label("season_count"),
|
||||
func.coalesce(st.position_seconds, 0).label("position_seconds"),
|
||||
m_status.label("status"),
|
||||
@@ -156,6 +161,8 @@ def unified_library(
|
||||
mq = mq.where(PlexItem.duration_s >= f.duration_min)
|
||||
if f.duration_max is not None:
|
||||
mq = mq.where(PlexItem.duration_s <= f.duration_max)
|
||||
# Playback-class filter (movie-only — a show is an aggregate with no single class).
|
||||
mq = mq.where(*playable_conds(split_csv(f.playable)))
|
||||
if tsq is not None:
|
||||
mq = mq.where(vector_match(PlexItem, lang, tsq))
|
||||
if recheck is not None:
|
||||
@@ -179,6 +186,7 @@ def unified_library(
|
||||
PlexShow.originally_available_at.label("rel"),
|
||||
cast(null(), Integer).label("duration_s"),
|
||||
cast(null(), String).label("playable"),
|
||||
cast(null(), Boolean).label("transcode_hw"),
|
||||
PlexShow.child_count.label("season_count"),
|
||||
literal(0).label("position_seconds"),
|
||||
s_status.label("status"),
|
||||
@@ -237,6 +245,7 @@ def unified_library(
|
||||
"duration_seconds": r.duration_s,
|
||||
"thumb": f"/api/plex/image/{r.rk}",
|
||||
"playable": r.playable,
|
||||
"transcode_hw": r.transcode_hw,
|
||||
"status": r.status,
|
||||
"position_seconds": r.position_seconds,
|
||||
}
|
||||
@@ -312,6 +321,7 @@ def facets(
|
||||
"genres": [],
|
||||
"content_ratings": [],
|
||||
"studios": [],
|
||||
"playable": [],
|
||||
"year_min": None,
|
||||
"year_max": None,
|
||||
"rating_max": None,
|
||||
@@ -338,6 +348,9 @@ def facets(
|
||||
query = query.where(model.duration_s >= pp["duration_min"])
|
||||
if with_dur and pp.get("duration_max") is not None:
|
||||
query = query.where(model.duration_s <= pp["duration_max"])
|
||||
# Playback-class filter (movie-only) — applied for the OTHER groups so an active class
|
||||
# narrows them; the playable facet itself passes pp with playable=None (self-exclusion).
|
||||
query = query.where(*playable_conds(split_csv(pp.get("playable"))))
|
||||
# Join watch-state and reuse the grid's exact movie state filter (unified_library) —
|
||||
# hidden movies never appear in the grid, so they must not inflate facet counts/bounds either.
|
||||
st = aliased(PlexState)
|
||||
@@ -356,6 +369,7 @@ def facets(
|
||||
genre_counts: dict[str, int] = {}
|
||||
cr_counts: dict[str, int] = {}
|
||||
studio_counts: dict[str, int] = {}
|
||||
playable_counts: dict[str, int] = {}
|
||||
years: list[int] = []
|
||||
ratings: list[float] = []
|
||||
durs: list[int] = []
|
||||
@@ -420,6 +434,23 @@ def facets(
|
||||
)
|
||||
for s, c in db.execute(stq).all():
|
||||
studio_counts[s] = studio_counts.get(s, 0) + c
|
||||
# Playback class (movie-only) — exclude its own so each class stays an addable OR option.
|
||||
if kind_movie:
|
||||
pcls = playback_class_expr()
|
||||
pcq = (
|
||||
wsq(
|
||||
select(pcls.label("cls"), func.count()),
|
||||
model,
|
||||
ids,
|
||||
{**p, "playable": None},
|
||||
kind_movie,
|
||||
True,
|
||||
)
|
||||
.where(pcls.isnot(None))
|
||||
.group_by(pcls)
|
||||
)
|
||||
for cls, c in db.execute(pcq).all():
|
||||
playable_counts[cls] = playable_counts.get(cls, 0) + c
|
||||
# Year bounds — exclude the year filter.
|
||||
by = db.execute(
|
||||
wsq(
|
||||
@@ -477,6 +508,11 @@ def facets(
|
||||
# Same for the actively-selected studios, so a selected studio stays removable even at zero matches.
|
||||
for s in split_csv(f.studios):
|
||||
studio_counts.setdefault(s, 0)
|
||||
# Keep an actively-selected playback class visible even at zero matches (the sidebar hides the whole
|
||||
# section when its facet list is empty, which would trap a zero-result selection with no way to undo).
|
||||
for c in split_csv(f.playable):
|
||||
if c in PLAYBACK_CLASSES:
|
||||
playable_counts.setdefault(c, 0)
|
||||
return {
|
||||
"genres": [
|
||||
{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0])
|
||||
@@ -488,6 +524,12 @@ def facets(
|
||||
{"value": s, "count": c}
|
||||
for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
],
|
||||
# Fixed good→heavy order (not by count), so the class list reads as a severity scale.
|
||||
"playable": [
|
||||
{"value": c, "count": playable_counts[c]}
|
||||
for c in PLAYBACK_CLASSES
|
||||
if c in playable_counts
|
||||
],
|
||||
"year_min": min(years) if years else None,
|
||||
"year_max": max(years) if years else None,
|
||||
"rating_max": max(ratings) if ratings else None,
|
||||
|
||||
@@ -411,6 +411,10 @@ def item_detail(
|
||||
"year": it.year,
|
||||
"duration_seconds": it.duration_s,
|
||||
"playable": it.playable,
|
||||
# Playback-class detail: HW/SW transcode split + the codec/bit-depth the info-page chip shows.
|
||||
"transcode_hw": it.transcode_hw,
|
||||
"codec_video": it.codec_video,
|
||||
"video_bit_depth": it.video_bit_depth,
|
||||
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||
"art": f"/api/plex/image/{it.rating_key}?variant=art",
|
||||
"library": _lib_key(db, it.library_id),
|
||||
|
||||
@@ -554,14 +554,16 @@ def stream_session(
|
||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||
# Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the
|
||||
# requested offset — the client's absolute clock and the subtitle cue-shift both key off this.
|
||||
# `transcode`/`hw` let the player show an honest cold-start ("Preparing stream…") and surface the
|
||||
# active mode + encoder in the stats overlay; both segment streams are otherwise identical.
|
||||
# `transcode`/`hw`/`tonemap` let the player show an honest cold-start ("Preparing stream…") and
|
||||
# surface the active mode + encoder in the stats overlay (`tonemap` = the HW HDR→SDR path); both
|
||||
# segment streams are otherwise identical.
|
||||
return {
|
||||
"mode": "hls",
|
||||
"start_s": s.media_start_s,
|
||||
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}",
|
||||
"transcode": s.transcode,
|
||||
"hw": s.hw,
|
||||
"tonemap": s.tonemap,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import email as email_mod
|
||||
from app import mailtext
|
||||
from app import state, sysconfig
|
||||
from app.utils import canonical_email
|
||||
from app.auth import PASSWORD_MIN_LEN
|
||||
from app.db import get_db
|
||||
from app.models import User
|
||||
@@ -69,7 +71,13 @@ def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dic
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Password must be at least {PASSWORD_MIN_LEN} characters."
|
||||
)
|
||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
# Resolve canonically, not by the literal string (DX-17). The wizard can be re-run, and E9.5's
|
||||
# whole point is that two spellings of one Gmail mailbox are one person — matching literally here
|
||||
# would let a second run under the other spelling create the DUPLICATE admin row the epic exists
|
||||
# to prevent. Every other lookup went canonical in S3; this one was outside that diff.
|
||||
user = db.execute(
|
||||
select(User).where(User.email_canonical == canonical_email(email))
|
||||
).scalar_one_or_none()
|
||||
if user is None:
|
||||
user = User(email=email, role="admin")
|
||||
db.add(user)
|
||||
@@ -103,6 +111,9 @@ def setup_config(settings_map: dict[str, Any], db: Session = Depends(require_set
|
||||
|
||||
class SetupTestEmailIn(BaseModel):
|
||||
to: str = ""
|
||||
# The wizard's page language. No account exists yet on a fresh instance, so this is the only
|
||||
# thing that can say what language to write in; anything unsupported falls back to English.
|
||||
lang: str | None = None
|
||||
|
||||
|
||||
@router.post("/test-email")
|
||||
@@ -111,7 +122,7 @@ def setup_test_email(body: SetupTestEmailIn, db: Session = Depends(require_setup
|
||||
to = body.to.strip()
|
||||
if "@" not in to:
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
if not email_mod.send_test(to):
|
||||
if not email_mod.send_test(to, mailtext.normalize(body.lang)):
|
||||
raise HTTPException(status_code=400, detail="Couldn't send — check the SMTP settings.")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -7,12 +7,20 @@ from app.audit import AuditAction
|
||||
from app.auth import admin_user, current_user, has_read_scope, require_human
|
||||
from app.db import get_db
|
||||
from app.models import Channel, Subscription, User, Video
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.sync.runner import estimate_deep_backfill
|
||||
from app.sync.subscriptions import import_subscriptions
|
||||
from app.scheduler import running_job_ids
|
||||
|
||||
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
||||
|
||||
# SEC-14: the YouTube API budget is SHARED across the whole instance, and a subscription import
|
||||
# walks every page of the caller's subscriptions — so one user holding down a manual sync button can
|
||||
# spend the day's units for everyone. Keyed by user id, not client IP: the cost belongs to the
|
||||
# account, and the same person on a second device is the same drain. 3 per 10 minutes leaves manual
|
||||
# re-syncing entirely usable (the scheduler does the routine work anyway) while removing the loop.
|
||||
_sync_limiter = RateLimiter(max_events=3, window_seconds=600)
|
||||
|
||||
|
||||
@router.post("/subscriptions")
|
||||
def sync_subscriptions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
|
||||
@@ -22,6 +30,8 @@ def sync_subscriptions(user: User = Depends(require_human), db: Session = Depend
|
||||
status_code=403,
|
||||
detail="Connect your YouTube account (read access) to import subscriptions.",
|
||||
)
|
||||
if not _sync_limiter.allow(str(user.id)):
|
||||
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
||||
with quota.measured(db, user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL) as m:
|
||||
result = import_subscriptions(db, user)
|
||||
result["quota_used_estimate"] = m.used
|
||||
|
||||
@@ -22,6 +22,39 @@ def valid_email(value: str | None) -> bool:
|
||||
return bool(value and _EMAIL_RE.match(value))
|
||||
|
||||
|
||||
# Gmail (and its googlemail.com alias) delivers to one mailbox regardless of dots in the local part
|
||||
# and of any `+tag`; nobody else is guaranteed to. So the canonical form below strips those for
|
||||
# Gmail only, and merely lowercases everything else.
|
||||
_GMAIL_DOMAINS = {"gmail.com", "googlemail.com"}
|
||||
|
||||
|
||||
def canonical_email(value: str | None) -> str:
|
||||
"""The form used to decide whether two addresses are the SAME MAILBOX. Never used for sending —
|
||||
mail always goes to the address as the user typed it.
|
||||
|
||||
Why this exists (observed on prod, 2026-09-03): someone registered as `npeter83.temu@gmail.com`
|
||||
and was approved, but Google's OAuth `email` claim came back as the account's stored primary,
|
||||
`npeter83temu@gmail.com`. The invite gate only lowercased, found no approved row for that literal
|
||||
string, and turned an approved user's sign-in into a silent SECOND access request — plus a second
|
||||
User row. The `+tag` case is worse and fails every time: a Google account cannot contain `+`, so
|
||||
OAuth can only ever return the base address, meaning anyone who registers with
|
||||
`user+siftlode@gmail.com` (a common throwaway habit) is guaranteed to be denied after approval.
|
||||
|
||||
Scope is deliberately narrow. The dot rule does NOT hold for Google Workspace custom domains
|
||||
unless the admin turned it on, and dots are significant on most providers, so anything outside
|
||||
gmail.com/googlemail.com is only lowercased. Approving one Gmail spelling always did mean
|
||||
approving that mailbox, so collapsing them is the honest reading of an invite, not a widening
|
||||
of it.
|
||||
"""
|
||||
email = (value or "").strip().lower()
|
||||
local, sep, domain = email.partition("@")
|
||||
if not sep or domain not in _GMAIL_DOMAINS:
|
||||
return email
|
||||
local = local.partition("+")[0].replace(".", "")
|
||||
# Normalise the alias domain too, so googlemail and gmail spellings meet.
|
||||
return f"{local}@gmail.com" if local else email
|
||||
|
||||
|
||||
def now_utc() -> datetime:
|
||||
"""Current time as a timezone-aware UTC datetime."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
@@ -189,3 +189,24 @@ def as_user(db, override_dep, _configured):
|
||||
return TestClient(app, headers={"x-test-user": str(user.id)})
|
||||
|
||||
return _as
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_rate_limiters():
|
||||
"""Every RateLimiter is a module-level singleton keyed by client IP, and TestClient always
|
||||
presents the same host — so without this the whole suite's login/register attempts pool into one
|
||||
300s window and, past the cap, some unrelated test starts failing with a 429 depending on order.
|
||||
Cleared before each test; a test that wants to exercise throttling can still exhaust one itself.
|
||||
|
||||
Sweeps every loaded `app.*` module rather than a hand-kept list, so a limiter added in a new
|
||||
module is covered the day it lands instead of silently pooling until something fails."""
|
||||
import sys
|
||||
|
||||
from app.ratelimit import RateLimiter
|
||||
|
||||
for name, mod in list(sys.modules.items()):
|
||||
if name != "app" and not name.startswith("app."):
|
||||
continue
|
||||
for obj in vars(mod).values():
|
||||
if isinstance(obj, RateLimiter):
|
||||
obj.reset()
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""E12 · S3 — the route-level halves of SEC-12 and SEC-14.
|
||||
|
||||
`test_urlguard.py` pins the address judgement itself; this pins that the ENQUEUE route actually
|
||||
consults it and answers in the structured `{code, reason}` shape the frontend localizes, and that a
|
||||
manual sync trigger stops spending the instance-wide YouTube budget when held down.
|
||||
|
||||
DB-backed (the `as_user` fixture needs real rows) — skipped when no database is reachable.
|
||||
"""
|
||||
|
||||
from app.downloads import urlguard
|
||||
|
||||
|
||||
def test_enqueueing_a_loopback_url_is_refused_with_a_structured_detail(
|
||||
db, as_user, make_user, monkeypatch
|
||||
):
|
||||
user = make_user()
|
||||
db.commit()
|
||||
monkeypatch.setattr(
|
||||
urlguard.socket, "getaddrinfo", lambda *a, **kw: [(None, None, None, "", ("127.0.0.1", 0))]
|
||||
)
|
||||
r = as_user(user).post("/api/downloads", json={"source": "http://redis.internal:6379/x"})
|
||||
assert r.status_code == 422
|
||||
# The shape matters as much as the refusal: a bare English string under a bilingual UI is the
|
||||
# thing the canonical-patterns table forbids.
|
||||
assert r.json()["detail"] == {"code": "url_blocked", "reason": urlguard.REASON_PRIVATE}
|
||||
|
||||
|
||||
def test_a_public_url_gets_past_the_guard(db, as_user, make_user, monkeypatch):
|
||||
# Mutation check for the test above: with a public answer the request must fail LATER (or
|
||||
# succeed), never with url_blocked — otherwise the guard would be refusing everything and the
|
||||
# first test would still pass.
|
||||
user = make_user()
|
||||
db.commit()
|
||||
monkeypatch.setattr(
|
||||
urlguard.socket,
|
||||
"getaddrinfo",
|
||||
lambda *a, **kw: [(None, None, None, "", ("93.184.216.34", 0))],
|
||||
)
|
||||
r = as_user(user).post("/api/downloads", json={"source": "https://example.com/v"})
|
||||
detail = r.json().get("detail") if r.status_code >= 400 else None
|
||||
assert not (isinstance(detail, dict) and detail.get("code") == "url_blocked")
|
||||
|
||||
|
||||
def test_youtube_sources_never_consult_the_guard(db, as_user, make_user, monkeypatch):
|
||||
# A YouTube job resolves to a fixed public host from an 11-char id, so there is no
|
||||
# user-controlled destination — resolving one would be pure latency on every enqueue.
|
||||
def boom(*a, **kw):
|
||||
raise AssertionError("the URL guard resolved DNS for a YouTube source")
|
||||
|
||||
user = make_user()
|
||||
db.commit()
|
||||
monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom)
|
||||
as_user(user).post("/api/downloads", json={"source": "dQw4w9WgXcQ"})
|
||||
|
||||
|
||||
def test_manual_subscription_sync_goes_on_cooldown(db, as_user, make_user, monkeypatch):
|
||||
from app.routes import sync as syncroutes
|
||||
|
||||
user = make_user()
|
||||
db.commit()
|
||||
# Neither the YouTube grant nor the import itself is under test — the limiter is.
|
||||
monkeypatch.setattr(syncroutes, "has_read_scope", lambda u: True)
|
||||
monkeypatch.setattr(syncroutes, "import_subscriptions", lambda d, u: {"imported": 0})
|
||||
|
||||
c = as_user(user)
|
||||
codes = [c.post("/api/sync/subscriptions").status_code for _ in range(4)]
|
||||
# 3 per 10 minutes: the first three pass, the fourth is refused.
|
||||
assert codes[:3] == [200, 200, 200]
|
||||
assert codes[3] == 429
|
||||
assert c.post("/api/sync/subscriptions").json()["detail"] == {"code": "sync_cooldown"}
|
||||
|
||||
|
||||
def test_the_cooldown_is_per_user_not_global(db, as_user, make_user, monkeypatch):
|
||||
# Keyed by user id on purpose: the shared YouTube budget is spent per account, and one user
|
||||
# exhausting their allowance must not lock everyone else out of their own sync button.
|
||||
from app.routes import sync as syncroutes
|
||||
|
||||
a, b = make_user(), make_user()
|
||||
db.commit()
|
||||
monkeypatch.setattr(syncroutes, "has_read_scope", lambda u: True)
|
||||
monkeypatch.setattr(syncroutes, "import_subscriptions", lambda d, u: {"imported": 0})
|
||||
|
||||
ca = as_user(a)
|
||||
for _ in range(4):
|
||||
ca.post("/api/sync/subscriptions")
|
||||
assert ca.post("/api/sync/subscriptions").status_code == 429
|
||||
assert as_user(b).post("/api/sync/subscriptions").status_code == 200
|
||||
|
||||
|
||||
def test_the_single_playlist_repull_shares_the_pull_cooldown(db, as_user, make_user, monkeypatch):
|
||||
# SEC-14, the sweep half: `revert-youtube` spends under the SAME QuotaAction.PLAYLISTS_PULL key
|
||||
# as the bulk `sync-youtube`, so limiting only one of them left the identical drain open one
|
||||
# route over. Both must draw on the one allowance.
|
||||
from app.models import Playlist
|
||||
from app.routes import playlists as plroutes
|
||||
|
||||
user = make_user()
|
||||
pl = Playlist(user_id=user.id, name="Linked", source="youtube", yt_playlist_id="PL123")
|
||||
db.add(pl)
|
||||
db.commit()
|
||||
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
|
||||
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: {"ok": True})
|
||||
monkeypatch.setattr(plroutes, "sync_user_playlists", lambda d, u: {"ok": True})
|
||||
|
||||
c = as_user(user)
|
||||
codes = [c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code for _ in range(4)]
|
||||
assert codes[:3] == [200, 200, 200]
|
||||
assert codes[3] == 429
|
||||
# One allowance, not one each: the bulk route is now on cooldown too.
|
||||
r = c.post("/api/playlists/sync-youtube")
|
||||
assert r.status_code == 429
|
||||
assert r.json()["detail"] == {"code": "sync_cooldown"}
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Deleting a whitelist / access-request entry (admin).
|
||||
|
||||
Deny records an auditable decision and keeps the row; delete forgets the address outright, for an
|
||||
approval added by mistake or one that is no longer wanted. Neither stops the person asking again —
|
||||
`upsert_pending_invite` reopens a denied row as pending, and a deleted one comes back as a fresh
|
||||
request. The one hard rule is that an entry backed by an account is NOT deletable here: this row is
|
||||
that account's approval gate, and orphaning it would strand someone who could then neither sign in
|
||||
nor be approved.
|
||||
"""
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import Invite, User
|
||||
|
||||
|
||||
def _invites(client):
|
||||
r = client.get("/api/admin/invites")
|
||||
assert r.status_code == 200
|
||||
return {i["email"]: i for i in r.json()}
|
||||
|
||||
|
||||
def test_admin_deletes_a_whitelist_entry_that_never_became_an_account(db, make_user, as_user):
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
db.add(Invite(email="mistake@example.com", status="approved", decided_by=admin.email))
|
||||
db.commit()
|
||||
client = as_user(admin)
|
||||
listed = _invites(client)["mistake@example.com"]
|
||||
assert listed["has_account"] is False # drives the delete affordance in the UI
|
||||
|
||||
r = client.delete(f"/api/admin/invites/{listed['id']}")
|
||||
|
||||
assert r.status_code == 200 and r.json()["deleted"] == listed["id"]
|
||||
db.expire_all()
|
||||
assert (
|
||||
db.execute(select(Invite).where(Invite.email == "mistake@example.com")).scalar_one_or_none()
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_deleting_an_entry_that_has_an_account_is_refused(db, make_user, as_user):
|
||||
# The guard that matters: this row is what is_allowed() reads, so deleting it would revoke the
|
||||
# user's access without touching the account or saying so.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
member = make_user(email="member@example.com")
|
||||
db.add(Invite(email=member.email, status="approved", decided_by=admin.email))
|
||||
db.commit()
|
||||
client = as_user(admin)
|
||||
listed = _invites(client)[member.email]
|
||||
assert listed["has_account"] is True
|
||||
|
||||
r = client.delete(f"/api/admin/invites/{listed['id']}")
|
||||
|
||||
assert r.status_code == 409
|
||||
db.expire_all()
|
||||
assert (
|
||||
db.execute(select(Invite).where(Invite.email == member.email)).scalar_one_or_none()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def test_a_pending_request_from_a_registration_is_not_deletable(db, make_user, as_user):
|
||||
# The shape the ordinary sign-up produces: _register_account writes the User (inactive) AND the
|
||||
# pending Invite together. The invite is that account's approval gate, so deleting it would
|
||||
# strand the person — they could neither sign in nor be approved, and re-registering is a silent
|
||||
# no-op. Managed from Users & roles instead, which is what the UI and the 409 both say.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
make_user(email="signup@example.com", is_active=False, email_verified=False)
|
||||
db.add(Invite(email="signup@example.com", status="pending"))
|
||||
db.commit()
|
||||
client = as_user(admin)
|
||||
listed = _invites(client)["signup@example.com"]
|
||||
assert listed["has_account"] is True # the UI shows a disabled control, not a missing one
|
||||
|
||||
r = client.delete(f"/api/admin/invites/{listed['id']}")
|
||||
|
||||
assert r.status_code == 409
|
||||
db.expire_all()
|
||||
assert (
|
||||
db.execute(select(Invite).where(Invite.email == "signup@example.com")).scalar_one_or_none()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def test_deleting_an_env_allow_listed_address_reports_that_it_kept_access(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
# Removing the row is NOT always a revoke: is_allowed falls back to the env bootstrap lists once
|
||||
# no invite exists. The admin has to be told, or they think they removed access and did not.
|
||||
from app.config import settings
|
||||
|
||||
# Patch the raw env field, not the derived property: that keeps the split/strip/lower parsing
|
||||
# under test, and matches how the rest of the suite stubs settings.
|
||||
monkeypatch.setattr(settings, "allowed_emails", " OPS@Example.com , someone@else.test ")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
db.add(Invite(email="ops@example.com", status="approved", decided_by=admin.email))
|
||||
db.commit()
|
||||
client = as_user(admin)
|
||||
listed = _invites(client)["ops@example.com"]
|
||||
# The LIST carries it too, so the confirm dialog can warn before the delete happens.
|
||||
assert listed["still_allowed_by_env"] is True
|
||||
|
||||
r = client.delete(f"/api/admin/invites/{listed['id']}")
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json()["still_allowed_by_env"] is True
|
||||
|
||||
|
||||
def test_an_admin_emails_address_is_also_reported(db, make_user, as_user, monkeypatch):
|
||||
# ADMIN_EMAILS is the sharper case: deleting the row not only keeps access, the next Google
|
||||
# sign-in re-promotes the address to admin.
|
||||
from app.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "admin_emails", "boss@example.com")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
db.add(Invite(email="boss@example.com", status="approved", decided_by=admin.email))
|
||||
db.commit()
|
||||
client = as_user(admin)
|
||||
|
||||
assert _invites(client)["boss@example.com"]["still_allowed_by_env"] is True
|
||||
|
||||
|
||||
def test_deleting_a_pending_request_forgets_it(db, make_user, as_user):
|
||||
# A pending row is deletable too — "drop this without recording a decision" is a different
|
||||
# choice from Deny, which stores a decision (status, decided_by) and mails the requester.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
db.add(Invite(email="asker@example.com", status="pending"))
|
||||
db.commit()
|
||||
client = as_user(admin)
|
||||
|
||||
r = client.delete(f"/api/admin/invites/{_invites(client)['asker@example.com']['id']}")
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json()["still_allowed_by_env"] is False # nothing in the env allow-lists
|
||||
assert "asker@example.com" not in _invites(client)
|
||||
|
||||
|
||||
def test_deleting_a_missing_invite_is_404(db, make_user, as_user):
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
assert as_user(admin).delete("/api/admin/invites/9999").status_code == 404
|
||||
|
||||
|
||||
def test_a_regular_user_cannot_delete_an_invite(db, make_user, as_user):
|
||||
make_user(email="admin@example.com", role="admin")
|
||||
plain = make_user(email="plain@example.com")
|
||||
db.add(Invite(email="mistake@example.com", status="approved"))
|
||||
db.commit()
|
||||
inv_id = db.execute(select(Invite.id).where(Invite.email == "mistake@example.com")).scalar_one()
|
||||
|
||||
assert as_user(plain).delete(f"/api/admin/invites/{inv_id}").status_code == 403
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(Invite, inv_id) is not None
|
||||
|
||||
|
||||
def test_deleting_the_user_also_clears_the_way_to_re_invite(db, make_user, as_user):
|
||||
# The refusal above tells the admin to delete the account instead; purge_user takes the invite
|
||||
# with it, so the address really is forgotten and a fresh request starts over as pending.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
member = make_user(email="member@example.com")
|
||||
db.add(Invite(email=member.email, status="approved", decided_by=admin.email))
|
||||
db.commit()
|
||||
client = as_user(admin)
|
||||
|
||||
assert client.delete(f"/api/admin/users/{member.id}").status_code == 200
|
||||
|
||||
db.expire_all()
|
||||
assert db.execute(select(User).where(User.email == member.email)).scalar_one_or_none() is None
|
||||
assert (
|
||||
db.execute(select(Invite).where(Invite.email == member.email)).scalar_one_or_none() is None
|
||||
)
|
||||
@@ -0,0 +1,203 @@
|
||||
"""The admin lifeline for a sign-up that is stuck outside (E9.5 S5, roadmap UX-25), DB-lane.
|
||||
|
||||
Approve an address BEFORE the person registers and their sign-up produces no queue row at all —
|
||||
`_decide` only takes a still-pending invite — so the Access-requests screen stayed empty while they
|
||||
sat locked out, and the badge read zero. The only working move was re-typing the address into the
|
||||
whitelist box, which nobody would guess.
|
||||
|
||||
Two earlier attempts at this were reverted after review, and both moved AUTHORITY rather than
|
||||
visibility: auto-activating on registration, and reopening the approved invite to pending. So the
|
||||
tests here pin the boundary as much as the feature — an admin click, gated on an address that is
|
||||
already allowed, and nothing an unauthenticated caller can reach.
|
||||
"""
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import AuditLog, AuthToken, Invite, User
|
||||
|
||||
_PW = "correct horse battery"
|
||||
|
||||
|
||||
def _pending_access(client):
|
||||
r = client.get("/api/admin/pending-access")
|
||||
assert r.status_code == 200
|
||||
return {u["email"]: u for u in r.json()}
|
||||
|
||||
|
||||
def _stuck_registrant(db, make_user, admin):
|
||||
"""The UX-25 shape: the admin approved the address first, the account arrived afterwards."""
|
||||
db.add(Invite(email="later@example.com", status="approved", decided_by=admin.email))
|
||||
db.commit()
|
||||
return make_user(email="later@example.com", password=_PW, is_active=False)
|
||||
|
||||
|
||||
def test_a_pre_approved_registrant_is_listed_and_offers_activation(db, make_user, as_user):
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
_stuck_registrant(db, make_user, admin)
|
||||
|
||||
row = _pending_access(as_user(admin))["later@example.com"]
|
||||
|
||||
assert row["is_active"] is False
|
||||
assert (
|
||||
row["can_activate"] is True
|
||||
) # the address is already allowed — the click just finishes it
|
||||
|
||||
|
||||
def test_an_ordinary_registrant_awaiting_a_decision_is_not_listed_twice(db, make_user, as_user):
|
||||
# They already have a pending invite with Approve on it in the queue above, and their address is
|
||||
# not allowed YET — the decision is the queue's to own. Caught in UAT on 2026-09-05: an earlier
|
||||
# filter keyed on "unconfirmed" put every fresh registration in both lists at once.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
db.add(Invite(email="normal@example.com", status="pending"))
|
||||
db.commit()
|
||||
make_user(email="normal@example.com", password=_PW, is_active=False)
|
||||
|
||||
assert "normal@example.com" not in _pending_access(as_user(admin))
|
||||
|
||||
|
||||
def test_a_fresh_registration_stays_in_the_queue_and_out_of_this_list(db, make_user, as_user):
|
||||
# The realistic version of the case above: a brand-new sign-up is inactive AND unconfirmed. It
|
||||
# still belongs only to the queue — the admin's next act is the decision, not a re-send.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
db.add(Invite(email="fresh@example.com", status="pending"))
|
||||
db.commit()
|
||||
make_user(email="fresh@example.com", password=_PW, is_active=False, email_verified=False)
|
||||
|
||||
assert "fresh@example.com" not in _pending_access(as_user(admin))
|
||||
|
||||
|
||||
def test_activating_lets_them_in_and_is_recorded(db, make_user, as_user):
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
stuck = _stuck_registrant(db, make_user, admin)
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/users/{stuck.id}/activate")
|
||||
|
||||
assert r.status_code == 200 and r.json()["is_active"] is True
|
||||
db.expire_all()
|
||||
assert db.get(User, stuck.id).is_active is True
|
||||
actions = db.execute(select(AuditLog.action)).scalars().all()
|
||||
assert (
|
||||
"user_activate" in actions
|
||||
) # the admin acted on someone's access; it is not a silent read
|
||||
|
||||
|
||||
def test_activating_an_address_that_is_not_allowed_is_refused(db, make_user, as_user):
|
||||
# The guard that keeps this button from manufacturing an inconsistent account: password sign-in
|
||||
# only checks is_active and would let them in, while their Google sign-in would be turned away
|
||||
# and silently reopened as a fresh access request. Whitelisting is the move that says "let them
|
||||
# in"; this one only finishes a decision already made.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
nobody = make_user(email="nobody@example.com", password=_PW, is_active=False)
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/users/{nobody.id}/activate")
|
||||
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"]["reason"] == "not_allowed"
|
||||
db.expire_all()
|
||||
assert db.get(User, nobody.id).is_active is False
|
||||
|
||||
|
||||
def test_activating_twice_is_not_an_error(db, make_user, as_user):
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
stuck = _stuck_registrant(db, make_user, admin)
|
||||
client = as_user(admin)
|
||||
|
||||
assert client.post(f"/api/admin/users/{stuck.id}/activate").status_code == 200
|
||||
assert client.post(f"/api/admin/users/{stuck.id}/activate").status_code == 200
|
||||
|
||||
|
||||
def test_a_suspended_account_is_not_offered_as_stuck(db, make_user, as_user):
|
||||
# It cannot sign in either, but that is a decision already made — not a person waiting on one.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
db.add(Invite(email="banned@example.com", status="approved", decided_by=admin.email))
|
||||
db.commit()
|
||||
make_user(email="banned@example.com", password=_PW, is_active=False, is_suspended=True)
|
||||
|
||||
assert "banned@example.com" not in _pending_access(as_user(admin))
|
||||
|
||||
|
||||
# --- the confirmation resend ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_an_unconfirmed_account_is_listed_and_can_be_sent_a_fresh_link(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
from app.routes import admin as admin_routes
|
||||
from app.routes import auth as auth_routes
|
||||
|
||||
monkeypatch.setattr(admin_routes.email_mod, "email_enabled", lambda db=None: True)
|
||||
sent: list[tuple[str, str]] = []
|
||||
monkeypatch.setattr(
|
||||
auth_routes.email_mod,
|
||||
"send_verify_email",
|
||||
lambda to, lang, link, **kw: sent.append((to, link)) is None,
|
||||
)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
# Active but unconfirmed — a real state since E9.5 S1 made the two gates order-independent. It is
|
||||
# not the admin's to decide, but the mail may simply never have arrived, so it is theirs to help
|
||||
# with. The approved invite is what puts them on this screen at all: the list is "the address may
|
||||
# sign in, this account still can't", so an undecided request stays with the queue that owns it.
|
||||
db.add(Invite(email="unconfirmed@example.com", status="approved", decided_by=admin.email))
|
||||
db.commit()
|
||||
u = make_user(email="unconfirmed@example.com", password=_PW, email_verified=False)
|
||||
|
||||
assert "unconfirmed@example.com" in _pending_access(as_user(admin))
|
||||
r = as_user(admin).post(f"/api/admin/users/{u.id}/resend-verification")
|
||||
|
||||
assert r.status_code == 200 and r.json()["email"] == "unconfirmed@example.com"
|
||||
assert [to for to, _ in sent] == ["unconfirmed@example.com"]
|
||||
tokens = db.execute(select(AuthToken).where(AuthToken.user_id == u.id)).scalars().all()
|
||||
assert [t.kind for t in tokens] == ["verify"]
|
||||
|
||||
|
||||
def test_the_resend_says_so_when_the_instance_cannot_send_mail(db, make_user, as_user, monkeypatch):
|
||||
from app.routes import admin as admin_routes
|
||||
|
||||
monkeypatch.setattr(admin_routes.email_mod, "email_enabled", lambda db=None: False)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
u = make_user(email="unconfirmed@example.com", password=_PW, email_verified=False)
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/users/{u.id}/resend-verification")
|
||||
|
||||
# Mail is fail-soft everywhere else, which is exactly what left the 0.78.5 sign-ups with nothing
|
||||
# to fall back on. An admin pressing a button gets the failure, not a success toast.
|
||||
assert r.status_code == 409 and r.json()["detail"]["reason"] == "email_off"
|
||||
|
||||
|
||||
def test_resending_to_an_already_confirmed_account_is_refused(db, make_user, as_user):
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
u = make_user(email="fine@example.com", password=_PW)
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/users/{u.id}/resend-verification")
|
||||
|
||||
assert r.status_code == 409 and r.json()["detail"]["reason"] == "not_applicable"
|
||||
|
||||
|
||||
# --- the badge -----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_badge_counts_stuck_sign_ups_but_not_ones_the_queue_already_shows(
|
||||
db, make_user, client
|
||||
):
|
||||
# Signed in for real, not via the as_user override: /api/me resolves its user through
|
||||
# optional_current_user, which that override does not cover (see test_me_email_flag).
|
||||
admin = make_user(email="admin@example.com", password=_PW, role="admin")
|
||||
_stuck_registrant(db, make_user, admin) # approved first → counts
|
||||
db.add(Invite(email="normal@example.com", status="pending")) # already in the queue → doesn't
|
||||
db.commit()
|
||||
make_user(email="normal@example.com", password=_PW, is_active=False)
|
||||
make_user(email="unconfirmed@example.com", password=_PW, email_verified=False) # not ours
|
||||
|
||||
assert (
|
||||
client.post(
|
||||
"/auth/password-login", json={"email": admin.email, "password": _PW}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
me = client.get("/api/me").json()
|
||||
assert me["authenticated"] is True
|
||||
|
||||
# An unconfirmed account waits on its OWNER, not on the admin — listing it is help, badging it
|
||||
# would be nagging about something the admin did not cause.
|
||||
assert me["pending_activations"] == 1
|
||||
assert me["pending_invites"] == 1 # unchanged meaning: still just the undecided requests
|
||||
@@ -97,28 +97,85 @@ def test_reset_confirm_is_single_use_and_kills_existing_sessions(client, make_us
|
||||
assert replay.status_code == 400
|
||||
|
||||
|
||||
def test_activating_an_unverified_account_drops_its_preset_password(db, make_user):
|
||||
# SEC-10 (account pre-emption): an attacker registers victim@x with a password before the approved
|
||||
# owner arrives. The address was never verified, so admin approval must NOT hand that pre-set
|
||||
# password a working login — it is dropped and any session it minted is revoked.
|
||||
def test_approving_an_unverified_account_keeps_its_password(db, make_user):
|
||||
# E9.5 S1, inverting an earlier rule. Approval used to drop a password set while the email was
|
||||
# unverified (SEC-10, account pre-emption). It was dropped because it destroyed the password a
|
||||
# legitimate registrant had just chosen, in what is the NORMAL ordering — the admin decides
|
||||
# before the user reads their mail — making "your password was cleared for security" the default
|
||||
# experience.
|
||||
#
|
||||
# S1 ALSO argued the revoke was redundant, because password_login refuses an unverified account
|
||||
# before it reads is_active. The epic's closing review refuted that: the gate holds only while
|
||||
# the address is unverified, and S6's approval mail exists to get the real owner to end that
|
||||
# state. Do not restore that reasoning from this comment — the residual is SEC-16, and round 2
|
||||
# of the review tried and reverted a guard for it (see test_review_round.py).
|
||||
from app.routes.admin import _activate_user_by_email
|
||||
|
||||
victim = make_user(
|
||||
email="victim@example.com",
|
||||
password="attacker-chosen",
|
||||
user = make_user(
|
||||
email="approved-first@example.com",
|
||||
password="chosen at registration",
|
||||
email_verified=False,
|
||||
is_active=False,
|
||||
)
|
||||
epoch0 = victim.session_epoch or 0
|
||||
_activate_user_by_email(db, "victim@example.com")
|
||||
assert victim.is_active is True
|
||||
assert victim.password_hash is None
|
||||
assert (victim.session_epoch or 0) == epoch0 + 1
|
||||
epoch0 = user.session_epoch or 0
|
||||
|
||||
_activate_user_by_email(db, "approved-first@example.com")
|
||||
|
||||
assert user.is_active is True
|
||||
assert user.password_hash is not None
|
||||
assert user.password_revoked_at is None
|
||||
assert (user.session_epoch or 0) == epoch0 # nothing to revoke, so no epoch bump either
|
||||
|
||||
|
||||
def test_an_approved_but_unverified_account_still_cannot_sign_in(client, make_user):
|
||||
# The gate that stops a pre-emptor BEFORE the address is confirmed, whatever the admin decided.
|
||||
# It is not the whole story — confirming the address opens it, which is SEC-16 — but it is the
|
||||
# invariant this test owns, and a change that let an unverified account sign in would be a
|
||||
# straightforwardly worse bug than the residual.
|
||||
make_user(
|
||||
email="notyet@example.com",
|
||||
password="attacker-chosen",
|
||||
email_verified=False,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
r = _login(client, "notyet@example.com", "attacker-chosen")
|
||||
|
||||
assert r.status_code == 403
|
||||
assert r.json()["detail"]["reason"] == "email_unverified"
|
||||
|
||||
|
||||
def test_verifying_afterwards_lets_the_original_password_in(client, db, make_user):
|
||||
# The payoff for the legitimate registrant: the password chosen at registration still works once
|
||||
# the mailbox is proven — no "your password was cleared for security" detour, no reset round-trip.
|
||||
from app.routes.auth import _issue_token
|
||||
|
||||
user = make_user(
|
||||
email="ordering@example.com",
|
||||
password="chosen at registration",
|
||||
email_verified=False,
|
||||
is_active=False,
|
||||
)
|
||||
from app.routes.admin import _activate_user_by_email
|
||||
|
||||
_activate_user_by_email(db, "ordering@example.com") # admin decides FIRST — the common ordering
|
||||
raw = _issue_token(db, user, "verify", timedelta(hours=1))
|
||||
db.commit()
|
||||
|
||||
verified = client.post("/auth/verify", json={"token": raw})
|
||||
|
||||
assert verified.status_code == 200
|
||||
assert verified.json() == {
|
||||
"ok": True,
|
||||
"password_reset_required": False,
|
||||
"email": "ordering@example.com",
|
||||
}
|
||||
assert _login(client, "ordering@example.com", "chosen at registration").status_code == 200
|
||||
|
||||
|
||||
def test_activating_a_verified_account_keeps_its_password(db, make_user):
|
||||
# A legit account whose email IS verified (e.g. a no-SMTP registration) keeps its password on
|
||||
# approval — the guard must not break the normal flow.
|
||||
# The no-SMTP shape: registration marks such an account verified up front, so it was never in
|
||||
# scope for the SEC-10 guard even before E9.5 S1. Kept as the other half of the pair.
|
||||
from app.routes.admin import _activate_user_by_email
|
||||
|
||||
user = make_user(
|
||||
@@ -127,3 +184,374 @@ def test_activating_a_verified_account_keeps_its_password(db, make_user):
|
||||
_activate_user_by_email(db, "legit@example.com")
|
||||
assert user.is_active is True
|
||||
assert user.password_hash is not None
|
||||
|
||||
|
||||
def _register_and_fetch(db, email: str):
|
||||
"""Run the /register background worker, then read the users/invites rows back through a FRESH
|
||||
query. The worker owns its own session, so anything it failed to commit is simply not there."""
|
||||
from sqlalchemy import select as _select
|
||||
|
||||
from app.models import Invite, User
|
||||
from app.routes.auth import _register_account
|
||||
|
||||
_register_account(email, _PW)
|
||||
db.expire_all()
|
||||
user = db.execute(_select(User).where(User.email == email)).scalar_one_or_none()
|
||||
invite = db.execute(_select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
||||
return user, invite
|
||||
|
||||
|
||||
def _no_smtp(monkeypatch):
|
||||
"""Put the worker on the no-SMTP path and capture the admin notice instead of sending it."""
|
||||
from app.routes import auth as auth_mod
|
||||
|
||||
notified: list[str] = []
|
||||
monkeypatch.setattr(auth_mod.email_mod, "email_enabled", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
auth_mod.email_mod,
|
||||
"send_admin_new_request",
|
||||
lambda admins, lang, to: notified.append(to) or True,
|
||||
)
|
||||
return notified
|
||||
|
||||
|
||||
def test_registration_of_a_pre_approved_email_persists_the_account_without_smtp(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
# THE regression. An admin whitelists an address, the person registers, and the account silently
|
||||
# never existed: upsert_pending_invite leaves an already-approved row alone and returns WITHOUT
|
||||
# committing, and the only other commit on this path (_issue_token) is gated on SMTP — so with no
|
||||
# working mail the flushed user was rolled back at session close. The sign-up answered "ok",
|
||||
# created nothing, and re-registering did the same again.
|
||||
from app.models import Invite
|
||||
|
||||
notified = _no_smtp(monkeypatch)
|
||||
make_user(email="admin@example.com", role="admin")
|
||||
db.add(Invite(email="roland@example.com", status="approved", decided_by="admin@example.com"))
|
||||
db.commit()
|
||||
|
||||
user, invite = _register_and_fetch(db, "roland@example.com")
|
||||
|
||||
assert user is not None, "pre-approved registration was rolled back — the account is gone"
|
||||
assert user.password_hash is not None
|
||||
assert user.email_verified is True # no SMTP => ownership can't be proven by link
|
||||
assert (
|
||||
user.is_active is False
|
||||
) # approval is bound to the account by an admin, not by signing up
|
||||
# The admin's standing decision is left exactly as it was — is_allowed() still reads "approved",
|
||||
# so their Google sign-in keeps working and an anonymous sign-up cannot revoke access.
|
||||
assert invite.status == "approved"
|
||||
assert invite.decided_by == "admin@example.com"
|
||||
# The notify path still fires for an already-approved address — nothing lands in the queue, so
|
||||
# it is the only announcement there is. (Whether it is DELIVERED is separate: on a real no-SMTP
|
||||
# instance `_send` drops it, which is the open half of the problem, roadmap UX-25.)
|
||||
assert notified == ["roland@example.com"]
|
||||
|
||||
|
||||
def test_registration_with_an_already_pending_request_persists_the_account(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
# The same rollback, reached the other way: someone uses Request access, then registers before
|
||||
# the admin has decided. upsert_pending_invite leaves the pending row alone and does not commit.
|
||||
from app.models import Invite
|
||||
|
||||
notified = _no_smtp(monkeypatch)
|
||||
make_user(email="admin@example.com", role="admin")
|
||||
db.add(Invite(email="pia@example.com", status="pending"))
|
||||
db.commit()
|
||||
|
||||
user, invite = _register_and_fetch(db, "pia@example.com")
|
||||
|
||||
assert user is not None, "registration was rolled back — the account is gone"
|
||||
assert user.is_active is False
|
||||
assert invite.status == "pending"
|
||||
assert notified == ["pia@example.com"]
|
||||
|
||||
|
||||
def test_an_admin_can_activate_a_pre_approved_registrant(db, monkeypatch):
|
||||
# The recovery path for the account above, pinned so it cannot quietly break: re-entering the
|
||||
# address in the Access-requests whitelist box (POST /api/admin/invites) runs
|
||||
# _activate_user_by_email.
|
||||
from app.models import Invite
|
||||
from app.routes.admin import _activate_user_by_email
|
||||
|
||||
_no_smtp(monkeypatch)
|
||||
db.add(Invite(email="roland@example.com", status="approved", decided_by="admin@example.com"))
|
||||
db.commit()
|
||||
user, _ = _register_and_fetch(db, "roland@example.com")
|
||||
|
||||
_activate_user_by_email(db, "roland@example.com")
|
||||
|
||||
db.expire_all()
|
||||
assert user.is_active is True
|
||||
assert user.password_hash is not None # approval never touches the password (E9.5 S1)
|
||||
|
||||
|
||||
def test_registration_of_an_unknown_email_still_persists_account_and_pending_invite(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
# Guard, not a regression test: this branch always worked (upsert INSERTs the invite and commits
|
||||
# the same transaction). It must keep working — and it is the case that DOES notify the admin.
|
||||
notified = _no_smtp(monkeypatch)
|
||||
make_user(email="admin@example.com", role="admin")
|
||||
|
||||
user, invite = _register_and_fetch(db, "stranger@example.com")
|
||||
|
||||
assert user is not None
|
||||
assert user.is_active is False
|
||||
assert invite is not None and invite.status == "pending"
|
||||
assert notified == ["stranger@example.com"]
|
||||
|
||||
|
||||
def test_registration_of_a_previously_denied_email_revives_the_request(db, make_user, monkeypatch):
|
||||
# Guard: a denied person may ask again — the invite flips back to pending (and commits) so the
|
||||
# admin sees a fresh request. The stale decision must not leak into the new account's state.
|
||||
from app.models import Invite
|
||||
|
||||
notified = _no_smtp(monkeypatch)
|
||||
make_user(email="admin@example.com", role="admin")
|
||||
db.add(Invite(email="denis@example.com", status="denied", decided_by="admin@example.com"))
|
||||
db.commit()
|
||||
|
||||
user, invite = _register_and_fetch(db, "denis@example.com")
|
||||
|
||||
assert user is not None
|
||||
assert user.is_active is False
|
||||
assert invite.status == "pending"
|
||||
assert invite.decided_at is None and invite.decided_by is None
|
||||
assert notified == ["denis@example.com"]
|
||||
|
||||
|
||||
def test_registration_of_a_pre_approved_email_persists_the_account_with_smtp(db, monkeypatch):
|
||||
# Guard, not a regression test: with SMTP on, _issue_token's own commit already persisted the
|
||||
# user, so this branch was never broken and must stay unbroken.
|
||||
from app.models import Invite
|
||||
from app.routes import auth as auth_mod
|
||||
|
||||
sent: list[str] = []
|
||||
monkeypatch.setattr(auth_mod.email_mod, "email_enabled", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
auth_mod.email_mod, "send_verify_email", lambda to, lang, link, **kw: sent.append(to)
|
||||
)
|
||||
monkeypatch.setattr(auth_mod.email_mod, "send_admin_new_request", lambda admins, lang, to: True)
|
||||
db.add(Invite(email="rita@example.com", status="approved", decided_by="admin@example.com"))
|
||||
db.commit()
|
||||
|
||||
user, invite = _register_and_fetch(db, "rita@example.com")
|
||||
|
||||
assert user is not None
|
||||
assert user.email_verified is False # must confirm the address
|
||||
assert user.is_active is False
|
||||
assert invite.status == "approved"
|
||||
assert sent == ["rita@example.com"]
|
||||
|
||||
|
||||
def test_verifying_an_email_never_grants_access_on_its_own(client, db, make_user):
|
||||
# Verification attests the address; it does not decide access. The two gates stay separate.
|
||||
from app.models import Invite
|
||||
from app.routes.auth import _issue_token
|
||||
|
||||
user = make_user(email="rita@example.com", password=_PW, email_verified=False, is_active=False)
|
||||
db.add(Invite(email="rita@example.com", status="approved", decided_by="admin@example.com"))
|
||||
db.commit()
|
||||
raw = _issue_token(db, user, "verify", timedelta(hours=1))
|
||||
|
||||
assert client.post("/auth/verify", json={"token": raw}).status_code == 200
|
||||
|
||||
db.expire_all()
|
||||
assert user.email_verified is True
|
||||
assert user.is_active is False
|
||||
assert _login(client, "rita@example.com", _PW).status_code == 403
|
||||
|
||||
|
||||
def test_register_route_creates_the_account_end_to_end(client, db, monkeypatch):
|
||||
# The tests above call _register_account directly, so nothing covered the ROUTE: its
|
||||
# allow_registration gate, its rate limiter and the background.add_task wiring that makes the
|
||||
# whole flow reachable at all. TestClient runs BackgroundTasks after the response, so a single
|
||||
# POST exercises the real path end to end.
|
||||
from sqlalchemy import select as _select
|
||||
|
||||
from app.models import User
|
||||
|
||||
_no_smtp(monkeypatch)
|
||||
|
||||
r = client.post("/auth/register", json={"email": "newbie@example.com", "password": _PW})
|
||||
|
||||
assert r.status_code == 200 and r.json() == {"status": "ok"}
|
||||
db.expire_all()
|
||||
user = db.execute(_select(User).where(User.email == "newbie@example.com")).scalar_one_or_none()
|
||||
assert user is not None, "POST /auth/register answered ok but created nothing"
|
||||
assert user.password_hash is not None
|
||||
|
||||
|
||||
def test_register_route_is_refused_when_registration_is_closed(client, db, monkeypatch):
|
||||
# The gate is an explicit 403 with nothing created — not a uniform "ok" that silently drops it.
|
||||
from sqlalchemy import select as _select
|
||||
|
||||
from app import sysconfig
|
||||
from app.models import User
|
||||
|
||||
_no_smtp(monkeypatch)
|
||||
sysconfig.set_value(db, "allow_registration", False)
|
||||
|
||||
r = client.post("/auth/register", json={"email": "closed@example.com", "password": _PW})
|
||||
|
||||
assert r.status_code == 403
|
||||
db.expire_all()
|
||||
assert (
|
||||
db.execute(_select(User).where(User.email == "closed@example.com")).scalar_one_or_none()
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
# --- SEC-10 lockout: a revoked preset password must stay RECOVERABLE ---------------------------
|
||||
#
|
||||
# Regression pack for the prod incident of 2026-09-02. An account registered with a password, then
|
||||
# stripped of it by the SEC-10 guard, ended up with password_hash = NULL. That is also what a
|
||||
# Google-only account looks like, and reset eligibility keyed off `password_hash` alone — so the
|
||||
# account could neither sign in (no password) nor obtain a reset link, and every response on the way
|
||||
# was a deliberately uniform "ok"/401. `password_revoked_at` is the trace that tells the two apart;
|
||||
# these pin each step of the way back in.
|
||||
#
|
||||
# E9.5 S1 removed the admin-approval caller, so Google adoption is the only path that still produces
|
||||
# this state — plus every account stripped on prod before that change, which is why the whole recovery
|
||||
# route below must keep working. These tests manufacture the state through the surviving producer.
|
||||
|
||||
|
||||
def _revoke_as_google_adoption(db, user) -> None:
|
||||
"""Put an account into the SEC-10-revoked state the way the ONLY remaining producer does: the
|
||||
Google callback adopting an account whose email was never verified."""
|
||||
from app.routes.auth import revoke_preset_password_if_unverified
|
||||
|
||||
revoke_preset_password_if_unverified(user)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _capture_reset_links(monkeypatch) -> list[str]:
|
||||
"""Patch the reset mailer and collect the recipients it is asked to write to."""
|
||||
from app.routes import auth as auth_mod
|
||||
|
||||
sent: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
auth_mod.email_mod, "send_password_reset", lambda to, lang, link: sent.append(to)
|
||||
)
|
||||
return sent
|
||||
|
||||
|
||||
def test_the_google_adoption_revoke_stamps_password_revoked_at(db, make_user):
|
||||
# The guard still fires where it earns its keep: the real owner has just proven ownership through
|
||||
# Google, so a password set on the address while it was unverified must not survive.
|
||||
victim = make_user(
|
||||
email="stamped@example.com",
|
||||
password="attacker-chosen",
|
||||
email_verified=False,
|
||||
is_active=False,
|
||||
)
|
||||
assert victim.password_revoked_at is None
|
||||
epoch0 = victim.session_epoch or 0
|
||||
|
||||
_revoke_as_google_adoption(db, victim)
|
||||
|
||||
assert victim.password_hash is None
|
||||
assert victim.password_revoked_at is not None # …so the reset flow can tell it HAD a password
|
||||
assert (victim.session_epoch or 0) == epoch0 + 1 # any session that password minted is killed
|
||||
|
||||
|
||||
def test_an_account_whose_password_was_revoked_can_still_get_a_reset_link(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
# THE lockout bug: without the stamp this sent nothing, and the account was unrecoverable.
|
||||
from app.routes.auth import _send_reset_if_eligible
|
||||
|
||||
victim = make_user(
|
||||
email="lockedout@example.com",
|
||||
password="chosen at registration",
|
||||
email_verified=False,
|
||||
is_active=False,
|
||||
)
|
||||
_revoke_as_google_adoption(db, victim)
|
||||
sent = _capture_reset_links(monkeypatch)
|
||||
|
||||
_send_reset_if_eligible("lockedout@example.com")
|
||||
|
||||
assert sent == ["lockedout@example.com"]
|
||||
|
||||
|
||||
def test_a_google_only_account_is_still_not_reset_eligible(db, make_user, monkeypatch):
|
||||
# The other side of the rule: an account that NEVER had a password gets no reset link — its owner
|
||||
# adds one from Settings while signed in (/auth/set-password), which proves identity by session.
|
||||
from app.routes.auth import _send_reset_if_eligible
|
||||
|
||||
make_user(email="googler@example.com", password=None, google_sub="sub-123")
|
||||
sent = _capture_reset_links(monkeypatch)
|
||||
|
||||
_send_reset_if_eligible("googler@example.com")
|
||||
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_completing_a_reset_clears_the_revocation_stamp(client, db, make_user):
|
||||
from app.routes.auth import _issue_token
|
||||
|
||||
user = make_user(
|
||||
email="recovered@example.com",
|
||||
password="chosen at registration",
|
||||
email_verified=False,
|
||||
is_active=True, # already approved; reset-confirm verifies the email but never activates
|
||||
)
|
||||
_revoke_as_google_adoption(db, user)
|
||||
raw = _issue_token(db, user, "reset", timedelta(hours=1))
|
||||
db.commit()
|
||||
|
||||
ok = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": raw, "password": "a brand new one"}
|
||||
)
|
||||
|
||||
assert ok.status_code == 200
|
||||
db.expire_all()
|
||||
assert user.password_hash is not None
|
||||
assert user.password_revoked_at is None # spent — not a permanently reset-eligible account
|
||||
assert _login(client, "recovered@example.com", "a brand new one").status_code == 200
|
||||
|
||||
|
||||
def test_verify_tells_a_revoked_account_that_it_needs_a_new_password(client, db, make_user):
|
||||
# The banner's input. Saying so leaks nothing: the caller just proved mailbox control by holding a
|
||||
# valid single-use verify token. Without it the user meets only the uniform "Invalid email or
|
||||
# password" and has no way to learn why the password they chose stopped working.
|
||||
from app.routes.auth import _issue_token
|
||||
|
||||
user = make_user(
|
||||
email="needspw@example.com",
|
||||
password="chosen at registration",
|
||||
email_verified=False,
|
||||
is_active=False,
|
||||
)
|
||||
_revoke_as_google_adoption(db, user)
|
||||
raw = _issue_token(db, user, "verify", timedelta(hours=1))
|
||||
db.commit()
|
||||
|
||||
r = client.post("/auth/verify", json={"token": raw})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {
|
||||
"ok": True,
|
||||
"password_reset_required": True,
|
||||
"email": "needspw@example.com",
|
||||
}
|
||||
|
||||
|
||||
def test_verify_reports_no_reset_needed_for_an_intact_account(client, db, make_user):
|
||||
from app.routes.auth import _issue_token
|
||||
|
||||
user = make_user(email="intact@example.com", email_verified=False, is_active=False)
|
||||
raw = _issue_token(db, user, "verify", timedelta(hours=1))
|
||||
db.commit()
|
||||
|
||||
r = client.post("/auth/verify", json={"token": raw})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {
|
||||
"ok": True,
|
||||
"password_reset_required": False,
|
||||
"email": "intact@example.com",
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Category classification of raw yt-dlp / ffmpeg download-failure strings (S2)."""
|
||||
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads.errors import classify
|
||||
|
||||
|
||||
@@ -54,3 +55,22 @@ def test_login_beats_generic_unavailable_when_both_present():
|
||||
"This video is unavailable"
|
||||
)
|
||||
assert classify(msg) == "login_required"
|
||||
|
||||
|
||||
def test_our_own_worker_refusals_classify_as_themselves():
|
||||
# The worker raises these itself (not yt-dlp), and the rule that catches each one matches only a
|
||||
# fragment of the text. Pinning message and rule together is what stops an edit to the wording
|
||||
# from silently orphaning the category — the UI would then fall back to "Download failed".
|
||||
assert classify(dlerrors.QUOTA_FULL_MESSAGE) == "quota_exceeded"
|
||||
|
||||
|
||||
def test_a_url_jobs_network_failure_is_redacted_but_youtube_keeps_its_text():
|
||||
# SEC-12: the raw transport text for a user-chosen host:port is a port-scanner readout, so it is
|
||||
# replaced for `url` jobs. Everything else must survive verbatim, or a real diagnosis is lost.
|
||||
raw = "ERROR: Unable to download webpage: <urlopen error [Errno 111] Connection refused>"
|
||||
assert dlerrors.user_error_text("url", raw, "network") == dlerrors.NETWORK_REDACTED_MESSAGE
|
||||
# Same failure, but the destination was never user-chosen — nothing to leak, keep the detail.
|
||||
assert dlerrors.user_error_text("youtube", raw, "network") == raw
|
||||
# A user-chosen host, but a category that says something real about the CONTENT, not the network.
|
||||
login = "ERROR: [facebook] 123: You must be logged in to see this post"
|
||||
assert dlerrors.user_error_text("url", login, "login_required") == login
|
||||
|
||||
@@ -10,6 +10,8 @@ Uses the `db` fixture (real Postgres) — skipped when no database is reachable.
|
||||
its OWN session, so every test COMMITS its setup before calling it.
|
||||
"""
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import pipeline, quota
|
||||
from app.models import DownloadJob, DownloadQuota, MediaAsset, User
|
||||
from app.worker import _claim_job
|
||||
|
||||
@@ -169,3 +171,110 @@ def test_list_downloads_is_capped(db, monkeypatch):
|
||||
monkeypatch.setattr(dl, "_MAX_LIST", 2)
|
||||
rows = dl.list_downloads(user=u, db=db)
|
||||
assert len(rows) == 2 # bounded by the cap, not the 3 rows on disk
|
||||
|
||||
|
||||
# --- E12 · S3 · the worker-side max_bytes remainder ------------------------------------------
|
||||
|
||||
|
||||
def _sized_asset(db, size_bytes, ref):
|
||||
a = MediaAsset(
|
||||
source_kind="url",
|
||||
source_ref=ref,
|
||||
format_sig="s",
|
||||
status="ready",
|
||||
ref_count=1,
|
||||
size_bytes=size_bytes,
|
||||
)
|
||||
db.add(a)
|
||||
db.flush()
|
||||
return a
|
||||
|
||||
|
||||
def test_remaining_bytes_is_none_for_an_unlimited_user(db):
|
||||
u = User(email="unlimited@example.com")
|
||||
db.add(u)
|
||||
db.flush()
|
||||
_quota(db, u.id, max_concurrent=2, unlimited=True)
|
||||
db.flush()
|
||||
# None, not a number: "no cap" and "no budget left" must never be the same value downstream —
|
||||
# build_ydl_opts leaves max_filesize unset for both, so only the caller can tell them apart.
|
||||
assert quota.remaining_bytes(db, u.id) is None
|
||||
|
||||
|
||||
def test_remaining_bytes_is_the_unspent_part_of_the_cap(db):
|
||||
u = User(email="partial@example.com")
|
||||
db.add(u)
|
||||
db.flush()
|
||||
db.add(
|
||||
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False)
|
||||
)
|
||||
a = _sized_asset(db, 400, "partial-1")
|
||||
_job(db, u.id, queue_pos=0, status="done", asset_id=a.id)
|
||||
db.flush()
|
||||
assert quota.remaining_bytes(db, u.id) == 600
|
||||
|
||||
|
||||
def test_remaining_bytes_is_zero_once_the_cap_is_met(db):
|
||||
# THE case this exists for: `check_enqueue` let these jobs in while the user was under the cap,
|
||||
# and an earlier one has filled it since. Zero is what makes the worker refuse; if this ever
|
||||
# returned the raw negative or None instead, the user already over quota would be the only one
|
||||
# downloading with no size ceiling at all.
|
||||
u = User(email="atcap@example.com")
|
||||
db.add(u)
|
||||
db.flush()
|
||||
db.add(
|
||||
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False)
|
||||
)
|
||||
a = _sized_asset(db, 1500, "atcap-1") # overshot: one job finished larger than the remainder
|
||||
_job(db, u.id, queue_pos=0, status="done", asset_id=a.id)
|
||||
db.flush()
|
||||
assert quota.remaining_bytes(db, u.id) == 0
|
||||
|
||||
|
||||
def test_a_refusal_for_one_user_does_not_poison_the_shared_asset(db, monkeypatch, tmp_path):
|
||||
"""A user at their cap fails their own job, and another user's queued job for the same video
|
||||
still downloads.
|
||||
|
||||
MediaAssets are deduped across users, and the generic failure handler writes the message onto
|
||||
that shared row — correct for an upstream failure, wrong for "YOUR storage is full", which
|
||||
`worker._process_job` would then hand verbatim to someone with an empty library.
|
||||
"""
|
||||
monkeypatch.setattr(settings, "download_root", str(tmp_path))
|
||||
over, other = User(email="over@example.com"), User(email="other@example.com")
|
||||
db.add_all([over, other])
|
||||
db.flush()
|
||||
db.add(
|
||||
DownloadQuota(
|
||||
user_id=over.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False
|
||||
)
|
||||
)
|
||||
full = _sized_asset(db, 1500, "already-held")
|
||||
_job(db, over.id, queue_pos=0, status="done", asset_id=full.id)
|
||||
|
||||
# `downloading` is the state the worker leaves it in — `_claim_asset` compare-and-sets it before
|
||||
# the child runs — and `_reset_asset_pending` only acts on that status. Seeded as `pending` this
|
||||
# assertion would pass with the release deleted.
|
||||
shared = MediaAsset(
|
||||
source_kind="youtube",
|
||||
source_ref="x" * 11,
|
||||
format_sig="s",
|
||||
status="downloading",
|
||||
ref_count=2,
|
||||
)
|
||||
db.add(shared)
|
||||
db.flush()
|
||||
mine = _job(db, over.id, queue_pos=1, status="running", asset_id=shared.id)
|
||||
theirs = _job(db, other.id, queue_pos=2, asset_id=shared.id)
|
||||
db.commit()
|
||||
|
||||
# The refusal happens before extraction, so nothing needs stubbing — if it ever stops happening
|
||||
# first, this test starts reaching the network, which is its own signal.
|
||||
pipeline._download(mine.id, shared.id, "youtube", "x" * 11, {}, over.id)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, mine.id).status == "error"
|
||||
assert db.get(DownloadJob, mine.id).error_code == "quota_exceeded"
|
||||
# The asset is released, NOT marked error — otherwise the next line's job inherits the refusal.
|
||||
assert db.get(MediaAsset, shared.id).status == "pending"
|
||||
assert db.get(MediaAsset, shared.id).error is None
|
||||
assert db.get(DownloadJob, theirs.id).status == "queued"
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""An undelivered email leaves a trace in the admins' inbox.
|
||||
|
||||
Sending is deliberately fail-soft — a missing SMTP setting or a rejecting relay logs and returns
|
||||
False so nothing user-facing breaks. The cost of that used to be total silence: an instance whose
|
||||
SMTP was never filled in swallowed verification links, password resets and access-request notices,
|
||||
and the operator's first clue was somebody saying nothing arrived.
|
||||
"""
|
||||
|
||||
import smtplib
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import email as email_mod
|
||||
from app.models import Notification
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_smtp_config(monkeypatch):
|
||||
"""Every test here drives the real `_send`, so make sure it never reaches a socket."""
|
||||
monkeypatch.setattr(
|
||||
email_mod,
|
||||
"_smtp",
|
||||
lambda: {"host": "", "port": 587, "user": "", "password": "", "from": ""},
|
||||
)
|
||||
|
||||
|
||||
def _notifications(db, user_id: int) -> list[Notification]:
|
||||
return list(
|
||||
db.execute(
|
||||
select(Notification)
|
||||
.where(Notification.user_id == user_id, Notification.type == "email_undelivered")
|
||||
.order_by(Notification.id)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def test_a_skipped_email_notifies_every_admin(db, make_user):
|
||||
admin_a = make_user(email="admin-a@example.com", role="admin")
|
||||
admin_b = make_user(email="admin-b@example.com", role="admin")
|
||||
plain = make_user(email="plain@example.com")
|
||||
|
||||
assert email_mod.send_test("someone@example.com", "en") is False # fail-soft, as before
|
||||
|
||||
db.expire_all()
|
||||
for admin in (admin_a, admin_b):
|
||||
(n,) = _notifications(db, admin.id)
|
||||
assert n.data["reason"] == "not_configured"
|
||||
assert n.data["subject"] == "Siftlode SMTP test"
|
||||
assert n.data["recipients"] == ["someone@example.com"]
|
||||
assert _notifications(db, plain.id) == [] # not an operator's problem to see
|
||||
|
||||
|
||||
def test_the_notice_is_throttled_so_a_dead_relay_cannot_flood_the_inbox(db, make_user):
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
|
||||
for _ in range(5):
|
||||
email_mod.send_test("someone@example.com", "en")
|
||||
|
||||
db.expire_all()
|
||||
assert len(_notifications(db, admin.id)) == 1
|
||||
|
||||
|
||||
def test_a_send_failure_is_reported_separately_from_a_missing_config(db, make_user, monkeypatch):
|
||||
# A configured-but-rejecting relay is a different operator problem from an empty config, and the
|
||||
# inbox copy differs — so the reason has to survive to the notification.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
monkeypatch.setattr(
|
||||
email_mod,
|
||||
"_smtp",
|
||||
lambda: {
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"user": "u",
|
||||
"password": "p",
|
||||
"from": "Siftlode <u@example.com>",
|
||||
},
|
||||
)
|
||||
|
||||
def _boom(*a, **kw):
|
||||
raise smtplib.SMTPException("nope")
|
||||
|
||||
monkeypatch.setattr(email_mod.smtplib, "SMTP", _boom)
|
||||
|
||||
assert email_mod.send_test("someone@example.com", "en") is False
|
||||
|
||||
db.expire_all()
|
||||
(n,) = _notifications(db, admin.id)
|
||||
assert n.data["reason"] == "send_failed"
|
||||
|
||||
|
||||
def test_no_admins_means_no_notification_and_no_error(db, make_user):
|
||||
# A fresh instance can have no admin yet (the wizard has not run). The alert must stay silent
|
||||
# rather than blowing up inside the fail-soft path it is supposed to make visible.
|
||||
plain = make_user(email="plain@example.com")
|
||||
|
||||
assert email_mod.send_test("someone@example.com", "en") is False
|
||||
|
||||
db.expire_all()
|
||||
assert _notifications(db, plain.id) == []
|
||||
|
||||
|
||||
def test_a_drop_with_nobody_to_tell_does_not_burn_the_hour(db, make_user):
|
||||
# The throttle window must only be spent on a notice that was actually written. Otherwise a drop
|
||||
# during setup — before any admin exists — silences the next hour for the admin created a minute
|
||||
# later, which is exactly the blind spot this alert exists to remove.
|
||||
email_mod.send_test("someone@example.com", "en") # no admins yet
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
|
||||
assert email_mod.send_test("someone@example.com", "en") is False
|
||||
|
||||
db.expire_all()
|
||||
assert len(_notifications(db, admin.id)) == 1
|
||||
|
||||
|
||||
def test_a_failed_alert_does_not_burn_the_hour_either(db, make_user, monkeypatch):
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
boom = {"on": True}
|
||||
|
||||
def _maybe_boom(*a, **kw):
|
||||
if boom["on"]:
|
||||
raise RuntimeError("notification store is down")
|
||||
from app.notifications import create_notification as real
|
||||
|
||||
return real(*a, **kw)
|
||||
|
||||
monkeypatch.setattr(email_mod, "create_notification", _maybe_boom)
|
||||
email_mod.send_test("someone@example.com", "en") # the write blows up; nothing recorded
|
||||
boom["on"] = False
|
||||
|
||||
assert email_mod.send_test("someone@example.com", "en") is False
|
||||
|
||||
db.expire_all()
|
||||
assert len(_notifications(db, admin.id)) == 1
|
||||
|
||||
|
||||
def test_a_broken_alert_never_breaks_the_send(db, make_user, monkeypatch):
|
||||
# The alert is the fallback path; if IT fails, the caller must still get the ordinary fail-soft
|
||||
# False rather than an exception surfacing from a background task.
|
||||
make_user(email="admin@example.com", role="admin")
|
||||
|
||||
def _boom(*a, **kw):
|
||||
raise RuntimeError("notification store is down")
|
||||
|
||||
monkeypatch.setattr(email_mod, "create_notification", _boom)
|
||||
|
||||
assert email_mod.send_test("someone@example.com", "en") is False
|
||||
@@ -0,0 +1,372 @@
|
||||
"""One mailbox, many spellings — the canonical-email identity rule (E9.5 S3).
|
||||
|
||||
Gmail delivers `x.y@gmail.com`, `xy@gmail.com` and `xy+tag@gmail.com` to ONE inbox. The invite gate
|
||||
used to compare literal strings, so on 2026-09-03 an approved user's Google sign-in became a silent
|
||||
SECOND access request: they had registered as `npeter83.temu@gmail.com`, and OAuth returned the
|
||||
account's stored primary `npeter83temu@gmail.com`. The `+tag` case fails every time — a Google account
|
||||
cannot contain `+`, so OAuth can only ever answer with the base address.
|
||||
|
||||
The rule is narrow on purpose: dots are significant on most providers, and the Gmail dot rule does not
|
||||
hold for Google Workspace custom domains unless their admin enabled it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select, text
|
||||
|
||||
_UAT_PW = "correct horse battery"
|
||||
|
||||
from app.auth import is_allowed
|
||||
from app.models import Invite, User
|
||||
from app.utils import canonical_email
|
||||
|
||||
# (input, expected canonical) — the matrix the sprint promised, plus the shapes that must NOT fold.
|
||||
CASES = [
|
||||
# Gmail: dots and +tags are noise…
|
||||
("npeter83.temu@gmail.com", "npeter83temu@gmail.com"),
|
||||
("npeter83temu@gmail.com", "npeter83temu@gmail.com"),
|
||||
("n.p.e.t.e.r@gmail.com", "npeter@gmail.com"),
|
||||
("user+siftlode@gmail.com", "user@gmail.com"),
|
||||
("u.ser+a+b@gmail.com", "user@gmail.com"),
|
||||
("USer.Name@GMail.COM", "username@gmail.com"),
|
||||
# …and googlemail is the same mailbox as gmail.
|
||||
("user.name@googlemail.com", "username@gmail.com"),
|
||||
# Everyone else: lowercase ONLY. Dots stay significant.
|
||||
("first.last@example.com", "first.last@example.com"),
|
||||
("First.Last@Example.COM", "first.last@example.com"),
|
||||
("first+tag@example.com", "first+tag@example.com"),
|
||||
# A Workspace domain is not Gmail, however Gmail-ish it looks.
|
||||
("first.last@gmail.company.com", "first.last@gmail.company.com"),
|
||||
# Degenerate input must not explode or invent an address.
|
||||
("", ""),
|
||||
(None, ""),
|
||||
("not-an-email", "not-an-email"),
|
||||
(".@gmail.com", ".@gmail.com"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", CASES)
|
||||
def test_canonical_email(raw, expected):
|
||||
assert canonical_email(raw) == expected
|
||||
|
||||
|
||||
def test_the_migration_sql_agrees_with_the_helper(db):
|
||||
"""The 0079 backfill reimplements canonical_email in SQL (a migration must not import app code
|
||||
that keeps changing). That duplication is only safe while the two agree, so pin them together —
|
||||
a drift here means rows backfilled into a form the application will never look up."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0079_email_canonical.py"
|
||||
spec = importlib.util.spec_from_file_location("m0079", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
sql = module._CANON_SQL.format(col=":addr")
|
||||
for raw, expected in CASES:
|
||||
if raw is None:
|
||||
continue # the column is NOT NULL; the backfill skips NULL emails explicitly
|
||||
got = db.execute(text(f"SELECT {sql}"), {"addr": raw}).scalar_one()
|
||||
assert got == expected, f"SQL and helper disagree on {raw!r}: {got!r} != {expected!r}"
|
||||
|
||||
|
||||
def test_the_column_is_kept_in_sync_by_writing_the_email(db):
|
||||
"""No write site has to remember: the model validator derives it. If this breaks, every lookup
|
||||
below silently stops matching."""
|
||||
user = User(email="Dotted.Name+tag@GoogleMail.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
assert user.email == "Dotted.Name+tag@GoogleMail.com" # stored AS TYPED — mail goes here
|
||||
assert user.email_canonical == "dottedname@gmail.com" # …matched on this
|
||||
|
||||
user.email = "someone.else@example.com"
|
||||
db.commit()
|
||||
assert user.email_canonical == "someone.else@example.com"
|
||||
|
||||
|
||||
def test_an_approval_covers_every_spelling_of_the_same_gmail_mailbox(db):
|
||||
# THE 2026-09-03 lockout, in one assertion: approved under the dotted spelling, signing in with
|
||||
# the primary Google returns.
|
||||
db.add(Invite(email="npeter83.temu@gmail.com", status="approved"))
|
||||
db.commit()
|
||||
|
||||
assert is_allowed(db, "npeter83temu@gmail.com") is True
|
||||
assert is_allowed(db, "npeter83.temu@gmail.com") is True
|
||||
assert is_allowed(db, "npeter83.temu+throwaway@gmail.com") is True
|
||||
|
||||
|
||||
def test_a_plus_tagged_registration_is_not_locked_out_after_approval(db):
|
||||
# The case that ALWAYS broke: a Google account cannot contain `+`, so OAuth can only return the
|
||||
# base address, and a literal match could never find the invite.
|
||||
db.add(Invite(email="someone+siftlode@gmail.com", status="approved"))
|
||||
db.commit()
|
||||
|
||||
assert is_allowed(db, "someone@gmail.com") is True
|
||||
|
||||
|
||||
def test_dots_stay_significant_outside_gmail(db):
|
||||
db.add(Invite(email="first.last@example.com", status="approved"))
|
||||
db.commit()
|
||||
|
||||
assert is_allowed(db, "first.last@example.com") is True
|
||||
assert is_allowed(db, "firstlast@example.com") is False # a DIFFERENT mailbox elsewhere
|
||||
|
||||
|
||||
def test_an_approved_row_wins_over_a_denied_sibling(db):
|
||||
# Prod carries approved pairs for one mailbox (oldest 2026-06-16). The API cannot flip an approved
|
||||
# invite to denied, so approved-beside-denied means the admin approved that mailbox under another
|
||||
# spelling — not that they revoked it. Revoking is deleting the row or suspending the account.
|
||||
db.add(Invite(email="two.spellings@gmail.com", status="denied"))
|
||||
db.add(Invite(email="twospellings@gmail.com", status="approved"))
|
||||
db.commit()
|
||||
|
||||
assert is_allowed(db, "twospellings@gmail.com") is True
|
||||
|
||||
|
||||
def test_a_denied_mailbox_stays_denied(db):
|
||||
db.add(Invite(email="no.thanks@gmail.com", status="denied"))
|
||||
db.commit()
|
||||
|
||||
assert is_allowed(db, "nothanks@gmail.com") is False
|
||||
|
||||
|
||||
def test_a_second_spelling_does_not_open_a_second_access_request(db):
|
||||
"""The other half of the 2026-09-03 report: the admin queue got a duplicate request for a user
|
||||
who was already approved."""
|
||||
from app.routes.auth import upsert_pending_invite
|
||||
|
||||
db.add(Invite(email="already.approved@gmail.com", status="approved"))
|
||||
db.commit()
|
||||
|
||||
req = upsert_pending_invite(db, "alreadyapproved@gmail.com")
|
||||
|
||||
assert req.is_new is False # nothing new to notify an admin about
|
||||
assert req.invite is not None and req.invite.email == "already.approved@gmail.com"
|
||||
assert len(db.execute(select(Invite)).scalars().all()) == 1
|
||||
|
||||
|
||||
def test_manually_whitelisting_another_spelling_reuses_the_row(db):
|
||||
"""How prod ended up with hartmann.peter1986@ AND hartmannpeter1986@ both approved on the same
|
||||
day: the admin typed the other spelling and got a second row instead of a no-op."""
|
||||
from app.routes.admin import add_invite, AddInviteIn
|
||||
|
||||
admin = User(email="admin@example.com", role="admin", is_active=True, email_verified=True)
|
||||
db.add(admin)
|
||||
db.add(Invite(email="hartmann.peter1986@gmail.com", status="approved"))
|
||||
db.commit()
|
||||
|
||||
add_invite(AddInviteIn(email="hartmannpeter1986@gmail.com"), admin=admin, db=db)
|
||||
|
||||
rows = db.execute(select(Invite)).scalars().all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].email == "hartmann.peter1986@gmail.com" # the original spelling is preserved
|
||||
|
||||
|
||||
def _capture_reset_links(monkeypatch) -> list[str]:
|
||||
"""Patch the reset mailer and collect the recipients it is asked to write to."""
|
||||
from app.routes import auth as auth_mod
|
||||
|
||||
sent: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
auth_mod.email_mod, "send_password_reset", lambda to, lang, link: sent.append(to)
|
||||
)
|
||||
return sent
|
||||
|
||||
|
||||
# --- signing in and recovering an account, whichever spelling of it the person types -------------
|
||||
#
|
||||
# The three flows that look an account up BY TYPED ADDRESS used to match literally, so a Gmail user
|
||||
# who registered `x.y@` and typed `xy@` met "Invalid email or password" at sign-in — and, worse,
|
||||
# total silence from password-reset and verify-resend, which answer uniformly by design.
|
||||
|
||||
|
||||
def test_the_password_works_with_any_spelling_of_the_same_mailbox(db):
|
||||
from app.auth import find_user_by_email
|
||||
|
||||
make = User(
|
||||
email="t.e.st+tag@gmail.com", email_verified=True, is_active=True, password_hash="x"
|
||||
)
|
||||
db.add(make)
|
||||
db.commit()
|
||||
|
||||
for typed in (
|
||||
"t.e.st+tag@gmail.com", # as registered
|
||||
"test@gmail.com", # the mailbox itself
|
||||
"t.e.st@gmail.com", # dots only
|
||||
"TEST@googlemail.com", # the alias domain
|
||||
):
|
||||
assert find_user_by_email(db, typed) is make, typed
|
||||
|
||||
|
||||
def test_an_unrelated_address_still_finds_nothing(db):
|
||||
from app.auth import find_user_by_email
|
||||
|
||||
db.add(User(email="someone@gmail.com", email_verified=True, is_active=True))
|
||||
db.commit()
|
||||
|
||||
assert find_user_by_email(db, "someoneelse@gmail.com") is None
|
||||
assert find_user_by_email(db, "some.one@example.com") is None # dots matter off Gmail
|
||||
|
||||
|
||||
def test_the_exact_spelling_wins_when_two_accounts_share_a_mailbox(db):
|
||||
# Prod carries such a pair (ids 13 and 14). Whoever types their own address in full must land on
|
||||
# their own account, not on whichever row happens to come first.
|
||||
from app.auth import find_user_by_email
|
||||
|
||||
dotted = User(email="npeter83.temu@gmail.com", email_verified=True, is_active=True)
|
||||
plain = User(email="npeter83temu@gmail.com", email_verified=True, is_active=True)
|
||||
db.add_all([dotted, plain])
|
||||
db.commit()
|
||||
|
||||
assert find_user_by_email(db, "npeter83.temu@gmail.com") is dotted
|
||||
assert find_user_by_email(db, "npeter83temu@gmail.com") is plain
|
||||
|
||||
|
||||
def test_an_ambiguous_spelling_refuses_rather_than_guessing(db):
|
||||
# Neither spelling is exact, and both are the same mailbox. Handing a password sign-in one of
|
||||
# them at random would let someone into the wrong account, so this answers "no such account" —
|
||||
# the same uniform reply the caller already gives.
|
||||
from app.auth import find_user_by_email
|
||||
|
||||
db.add(User(email="npeter83.temu@gmail.com", email_verified=True, is_active=True))
|
||||
db.add(User(email="npeter83temu@gmail.com", email_verified=True, is_active=True))
|
||||
db.commit()
|
||||
|
||||
assert find_user_by_email(db, "n.peter83.temu+x@gmail.com") is None
|
||||
|
||||
|
||||
def test_a_reset_link_goes_to_the_address_on_the_account(db, monkeypatch):
|
||||
"""Typing another spelling must still reach the owner — and the mail must go to the address they
|
||||
registered with, tag and all, not to the one typed into the form."""
|
||||
from app.routes.auth import _send_reset_if_eligible
|
||||
|
||||
db.add(
|
||||
User(
|
||||
email="me+siftlode@gmail.com",
|
||||
email_verified=True,
|
||||
is_active=True,
|
||||
password_hash="x",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
sent = _capture_reset_links(monkeypatch)
|
||||
|
||||
_send_reset_if_eligible("me@gmail.com")
|
||||
|
||||
assert sent == ["me+siftlode@gmail.com"]
|
||||
|
||||
|
||||
# --- a Google sign-in must not rewrite the address its owner chose ------------------------------
|
||||
|
||||
|
||||
def test_a_google_sign_in_keeps_a_plus_tagged_address(db):
|
||||
"""People register as `me+siftlode@gmail.com` so a later leak from this site is identifiable.
|
||||
Google returns the account's primary address, which can never contain `+`, so following it
|
||||
blindly destroyed the tag on the first Google sign-in — and, since sign-in matches the stored
|
||||
address first, stopped them signing in with the address they had chosen."""
|
||||
from app.routes.auth import _sync_google_email
|
||||
|
||||
user = User(email="me+siftlode@gmail.com", email_verified=True, is_active=True)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
_sync_google_email(db, user, "me@gmail.com")
|
||||
|
||||
assert user.email == "me+siftlode@gmail.com"
|
||||
|
||||
|
||||
def test_a_google_sign_in_follows_a_real_mailbox_change(db):
|
||||
# The case the sync exists for: the Google account genuinely moved to a different mailbox.
|
||||
from app.routes.auth import _sync_google_email
|
||||
|
||||
user = User(email="old.address@gmail.com", email_verified=True, is_active=True)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
_sync_google_email(db, user, "brand-new@example.com")
|
||||
|
||||
assert user.email == "brand-new@example.com"
|
||||
|
||||
|
||||
def test_a_mailbox_change_is_refused_when_another_account_owns_it(db):
|
||||
from app.routes.auth import _sync_google_email
|
||||
|
||||
db.add(User(email="taken@example.com", email_verified=True, is_active=True))
|
||||
user = User(email="mover@gmail.com", email_verified=True, is_active=True)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
_sync_google_email(db, user, "taken@example.com")
|
||||
|
||||
assert user.email == "mover@gmail.com" # unique constraint would have 500'd the sign-in
|
||||
|
||||
|
||||
# --- showing BOTH addresses, once there are two -------------------------------------------------
|
||||
|
||||
|
||||
def test_the_google_address_is_recorded_beside_the_registered_one(db):
|
||||
"""Since the sign-in no longer overwrites the registered address, the account has two true
|
||||
addresses and only one was stored. This is the other half, for the UI to name."""
|
||||
from app.routes.auth import _sync_google_email
|
||||
|
||||
user = User(email="me+siftlode@gmail.com", email_verified=True, is_active=True)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
user.google_email = "me@gmail.com"
|
||||
_sync_google_email(db, user, "me@gmail.com")
|
||||
db.commit()
|
||||
|
||||
assert user.email == "me+siftlode@gmail.com" # still theirs
|
||||
assert user.google_email == "me@gmail.com" # …and now we know the other one
|
||||
|
||||
|
||||
def test_me_reports_the_google_address_only_when_it_differs(client, db, make_user):
|
||||
"""The comparison lives in one place so no surface has to repeat it — and so the account menu
|
||||
stays exactly as it is for the majority, whose two addresses are the same string."""
|
||||
same = make_user(email="plain@gmail.com", password=_UAT_PW)
|
||||
same.google_email = "plain@gmail.com"
|
||||
db.commit()
|
||||
assert (
|
||||
client.post(
|
||||
"/auth/password-login", json={"email": "plain@gmail.com", "password": _UAT_PW}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert client.get("/api/me").json()["google_email"] is None
|
||||
|
||||
client.post("/auth/logout")
|
||||
tagged = make_user(email="me+siftlode@gmail.com", password=_UAT_PW)
|
||||
tagged.google_email = "me@gmail.com"
|
||||
db.commit()
|
||||
assert (
|
||||
client.post(
|
||||
"/auth/password-login", json={"email": "me+siftlode@gmail.com", "password": _UAT_PW}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert client.get("/api/me").json()["google_email"] == "me@gmail.com"
|
||||
|
||||
|
||||
# --- DX-17 · the setup wizard resolves its admin canonically ---------------------------------
|
||||
|
||||
|
||||
def test_rerunning_the_wizard_with_another_gmail_spelling_updates_the_same_admin(db):
|
||||
"""The wizard can be re-run, and E9.5's premise is that two spellings of one Gmail mailbox are one
|
||||
person. Resolving by the literal string here would create a SECOND admin row — exactly the
|
||||
duplicate the epic exists to prevent. This site was outside S3's sweep (DX-17).
|
||||
|
||||
Calls the handler directly: `require_setup` is an instance-state gate, not the behaviour under
|
||||
test, and the test fixtures present an already-configured instance."""
|
||||
from app.routes.setup import SetupAdminIn, setup_admin
|
||||
|
||||
setup_admin(SetupAdminIn(email="a.b.c@gmail.com", password=_UAT_PW), db)
|
||||
setup_admin(SetupAdminIn(email="abc@gmail.com", password="another good passphrase"), db)
|
||||
|
||||
admins = db.execute(select(User).where(User.role == "admin")).scalars().all()
|
||||
assert len(admins) == 1, [u.email for u in admins]
|
||||
# The address stays AS TYPED on the row it matched; only identity is canonical.
|
||||
assert admins[0].email == "a.b.c@gmail.com"
|
||||
# And the second run's password is the live one — it updated, it did not fork.
|
||||
from app.security import verify_password
|
||||
|
||||
assert verify_password("another good passphrase", admins[0].password_hash)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Every outbound mail carries an HTML alternative, so its link is actually clickable.
|
||||
|
||||
The bodies are plain text and stay that way — but a bare URL in a text/plain mail is only a link if
|
||||
the CLIENT chooses to linkify it. Gmail/Outlook/Apple Mail do; lighter web clients render the body
|
||||
verbatim and leave the verification or password-reset link as dead text, which is the entire point of
|
||||
those two messages. So `_send` now attaches an HTML part generated from the same plain body.
|
||||
|
||||
These are pure-logic tests except the last one, which drives the real `_send` against a fake SMTP so
|
||||
the wire format itself is pinned, not just the helper.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app import email as email_mod
|
||||
|
||||
_SMTP_OK = {
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"user": "u",
|
||||
"password": "p",
|
||||
"from": "Siftlode <u@example.com>",
|
||||
}
|
||||
|
||||
|
||||
def test_a_bare_url_becomes_a_real_link():
|
||||
# THE regression: this is what a client that does not linkify plain text was leaving as dead text.
|
||||
out = email_mod._html_body(
|
||||
"Confirm your email:\n\nhttps://siftlode.example/#verify=tok123\n\n— Siftlode"
|
||||
)
|
||||
assert '<a href="https://siftlode.example/#verify=tok123">' in out
|
||||
|
||||
|
||||
def test_interpolated_content_cannot_inject_markup():
|
||||
# Bodies carry untrusted values — a requester's address in the admin notice, the operator contact.
|
||||
# Escaping must run BEFORE linkification, or a crafted address becomes markup in every admin's mail.
|
||||
out = email_mod._html_body('A request from <script>alert("x")</script> & co')
|
||||
assert "<script>" not in out
|
||||
assert "<script>" in out
|
||||
assert "& co" in out
|
||||
|
||||
|
||||
def test_a_query_string_keeps_its_escaped_ampersand_inside_the_href():
|
||||
# `&` is escaped to `&` before the linkifier sees it, which is also what an HTML attribute
|
||||
# wants — so the href must carry the escaped form and nothing may be trimmed off it.
|
||||
out = email_mod._html_body("https://siftlode.example/x?a=1&b=2")
|
||||
assert (
|
||||
'<a href="https://siftlode.example/x?a=1&b=2">https://siftlode.example/x?a=1&b=2</a>'
|
||||
in out
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body,href,trail",
|
||||
[
|
||||
("See https://siftlode.example/x.", "https://siftlode.example/x", "."),
|
||||
("See https://siftlode.example/x, then sign in", "https://siftlode.example/x", ","),
|
||||
("See (https://siftlode.example/x)", "https://siftlode.example/x", ")"),
|
||||
],
|
||||
)
|
||||
def test_sentence_punctuation_stays_out_of_the_href(body, href, trail):
|
||||
out = email_mod._html_body(body)
|
||||
assert f'<a href="{href}">{href}</a>{trail}' in out
|
||||
|
||||
|
||||
def test_a_token_ending_in_a_url_safe_character_is_not_trimmed():
|
||||
# token_urlsafe output can end in `-` or `_`; trimming either would hand the user a dead link.
|
||||
for tok in ("abc-", "abc_"):
|
||||
out = email_mod._html_body(f"https://siftlode.example/#reset={tok}")
|
||||
assert f'href="https://siftlode.example/#reset={tok}"' in out
|
||||
|
||||
|
||||
def test_blank_lines_become_paragraphs_and_single_breaks_stay_inline():
|
||||
out = email_mod._html_body("Hi,\n\nline one\nline two\n\n— Siftlode")
|
||||
assert out.count("<p>") == 3
|
||||
assert "<p>line one<br>line two</p>" in out
|
||||
|
||||
|
||||
def test_the_sent_message_is_multipart_with_the_plain_body_intact(monkeypatch):
|
||||
# The wire format, not just the helper: text/plain stays the primary part and the source of
|
||||
# truth, with text/html alongside it.
|
||||
monkeypatch.setattr(email_mod, "_smtp", lambda: _SMTP_OK)
|
||||
sent = []
|
||||
|
||||
class _FakeSMTP:
|
||||
def __init__(self, *a, **kw):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def starttls(self, *a, **kw):
|
||||
pass
|
||||
|
||||
def login(self, *a, **kw):
|
||||
pass
|
||||
|
||||
def send_message(self, msg):
|
||||
sent.append(msg)
|
||||
|
||||
monkeypatch.setattr(email_mod.smtplib, "SMTP", _FakeSMTP)
|
||||
|
||||
assert (
|
||||
email_mod.send_verify_email(
|
||||
"someone@example.com", "en", "https://siftlode.example/#verify=tok"
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
(msg,) = sent
|
||||
assert msg.get_content_type() == "multipart/alternative"
|
||||
plain = msg.get_body(preferencelist=("plain",))
|
||||
rich = msg.get_body(preferencelist=("html",))
|
||||
assert plain is not None and rich is not None
|
||||
# The plain part must be byte-for-byte the template's own body — the HTML part is derived from
|
||||
# it, never a second copy that could drift.
|
||||
assert "https://siftlode.example/#verify=tok" in plain.get_content()
|
||||
assert "<a href=" not in plain.get_content()
|
||||
assert '<a href="https://siftlode.example/#verify=tok">' in rich.get_content()
|
||||
@@ -109,3 +109,52 @@ def test_browser_transcode_flags_universal_playability():
|
||||
assert F("h264", "aac", False) == (True, False, False)
|
||||
# A failed probe / missing stream is never re-encoded.
|
||||
assert F(None, None, True) == (False, False, False)
|
||||
|
||||
|
||||
# --- SEC-13 · one enqueue is one bounded download -------------------------------------------
|
||||
|
||||
|
||||
def test_a_playlist_url_can_only_yield_one_item():
|
||||
# `noplaylist` alone only demotes a video-inside-a-playlist URL to that video; a URL that is
|
||||
# purely a playlist or channel has no such video, so without this it expands into every entry.
|
||||
opts = _opts({})
|
||||
assert opts["noplaylist"] is True
|
||||
assert opts["playlist_items"] == "1"
|
||||
|
||||
|
||||
def test_the_remaining_quota_becomes_a_hard_size_ceiling():
|
||||
opts = formats.build_ydl_opts({}, "/tmp/x", lambda d: None, max_bytes=5_000_000)
|
||||
assert opts["max_filesize"] == 5_000_000
|
||||
|
||||
|
||||
def test_no_budget_means_no_size_filter():
|
||||
# Unlimited users pass None; a non-positive remainder is the quota layer's refusal to make at
|
||||
# enqueue, not an impossible yt-dlp filter to express here.
|
||||
for budget in (None, 0, -1):
|
||||
assert "max_filesize" not in formats.build_ydl_opts(
|
||||
{}, "/tmp/x", lambda d: None, max_bytes=budget
|
||||
)
|
||||
|
||||
|
||||
def test_a_source_adapter_cannot_raise_the_size_ceiling():
|
||||
# The adapter merge happens after max_filesize is set, so this pins that no adapter option can
|
||||
# widen a storage cap — the mirror of the cookiefile rule, in the opposite direction.
|
||||
opts = formats.build_ydl_opts(
|
||||
{}, "/tmp/x", lambda d: None, extra={"max_filesize": 999_999_999}, max_bytes=5_000_000
|
||||
)
|
||||
assert opts["max_filesize"] == 5_000_000
|
||||
|
||||
|
||||
def test_a_join_cut_list_is_capped():
|
||||
# An accurate join emits two filters per segment into one ffmpeg filter_complex, so an unbounded
|
||||
# list is an unbounded command line. The surplus is dropped; the legitimate head survives.
|
||||
segs = [{"start_s": i, "end_s": i + 1} for i in range(500)]
|
||||
out = editmod.normalize_edit_spec({"segments": segs})
|
||||
assert len(out["segments"]) == editmod._MAX_SEGMENTS
|
||||
assert out["segments"][0] == {"start_s": 0.0, "end_s": 1.0}
|
||||
|
||||
|
||||
def test_a_normal_cut_list_is_untouched():
|
||||
segs = [{"start_s": 0, "end_s": 5}, {"start_s": 10, "end_s": 12}]
|
||||
out = editmod.normalize_edit_spec({"segments": segs})
|
||||
assert len(out["segments"]) == 2
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""What a refused sign-in SAYS (E9.5 S4), DB-lane.
|
||||
|
||||
The states behind a 403 were true and useless: "Your account is awaiting admin approval." never
|
||||
revealed WHICH mailbox the account was on. On 2026-09-03 a registration as `npeter83.temu@gmail.com`
|
||||
met a Google claim of `npeter83temu@gmail.com`, and that missing address hid the mismatch for a day.
|
||||
So these pin the DETAIL, not just the status code — the address, and both gates, since E9.5 S1 made
|
||||
the two order-independent and the screen must report whichever is actually outstanding.
|
||||
|
||||
The two refusals that answer BEFORE the password is proven carry nothing, and that is the security
|
||||
half of the same contract: `invalid` must stay byte-identical for a real account and an unknown one.
|
||||
"""
|
||||
|
||||
from app.config import settings
|
||||
from app.models import Invite
|
||||
from app.routes.auth import _access_notice_url, upsert_pending_invite
|
||||
|
||||
_PW = "correct horse battery"
|
||||
|
||||
|
||||
def _login(client, email, password=_PW):
|
||||
return client.post("/auth/password-login", json={"email": email, "password": password})
|
||||
|
||||
|
||||
def test_pending_approval_names_the_address_the_account_is_on(client, make_user):
|
||||
# The typed spelling and the stored one are the same mailbox; the screen must show the STORED
|
||||
# one, because that is the mismatch a user cannot otherwise see.
|
||||
make_user(email="np.temu@gmail.com", password=_PW, is_active=False)
|
||||
|
||||
detail = _login(client, "nptemu@gmail.com").json()["detail"]
|
||||
|
||||
assert detail["reason"] == "pending_approval"
|
||||
assert detail["email"] == "np.temu@gmail.com"
|
||||
assert detail["email_verified"] is True and detail["approved"] is False
|
||||
|
||||
|
||||
def test_an_already_approved_account_still_reports_its_missing_verification(client, make_user):
|
||||
# S1 made the gates order-independent, so this really happens: approved, not yet verified. A
|
||||
# screen that assumed a sequence would tell this user to wait for an admin who already decided.
|
||||
make_user(
|
||||
email="approved.first@example.com", password=_PW, email_verified=False, is_active=True
|
||||
)
|
||||
|
||||
detail = _login(client, "approved.first@example.com").json()["detail"]
|
||||
|
||||
assert detail["reason"] == "email_unverified"
|
||||
assert detail["email_verified"] is False and detail["approved"] is True
|
||||
|
||||
|
||||
def test_a_suspended_account_is_told_who_to_contact(client, make_user, monkeypatch):
|
||||
monkeypatch.setattr(settings, "admin_emails", "ops@example.com")
|
||||
make_user(email="banned@example.com", password=_PW, is_suspended=True)
|
||||
|
||||
r = _login(client, "banned@example.com")
|
||||
|
||||
assert r.status_code == 403
|
||||
assert r.json()["detail"]["reason"] == "suspended"
|
||||
assert r.json()["detail"]["operator"] == "ops@example.com"
|
||||
|
||||
|
||||
def test_a_refusal_before_the_password_is_proven_carries_no_account_detail(client, make_user):
|
||||
make_user(email="real@example.com", password=_PW, is_active=False)
|
||||
|
||||
wrong_pw = _login(client, "real@example.com", "not the password").json()["detail"]
|
||||
unknown = _login(client, "nobody@example.com").json()["detail"]
|
||||
|
||||
# No address, no flags — the pending state above must not leak through the wrong-password door.
|
||||
assert wrong_pw == unknown == {"code": "login", "reason": "invalid"}
|
||||
|
||||
|
||||
# --- the access-request notice -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_access_notice_names_the_address_and_rides_the_fragment():
|
||||
url = _access_notice_url("requested", "someone@example.com")
|
||||
|
||||
# A fragment never reaches a proxy access log or a Referer header — the same reasoning that put
|
||||
# the verify/reset tokens there (SB3), applied to an address.
|
||||
assert url == "/#access=requested&email=someone%40example.com"
|
||||
|
||||
|
||||
def test_a_plus_tagged_address_survives_the_round_trip():
|
||||
# URLSearchParams reads a bare `+` in a fragment as a SPACE, so the encoder must emit %2B — or
|
||||
# the very address family this epic exists to protect arrives mangled.
|
||||
url = _access_notice_url("requested", "me+siftlode@gmail.com")
|
||||
|
||||
assert "me%2Bsiftlode%40gmail.com" in url and "+siftlode" not in url
|
||||
|
||||
|
||||
def test_a_request_already_on_file_under_another_spelling_says_so(db):
|
||||
db.add(Invite(email="hartmann.peter@gmail.com", status="pending"))
|
||||
db.commit()
|
||||
|
||||
req = upsert_pending_invite(db, "hartmannpeter@gmail.com")
|
||||
url = _access_notice_url("requested", "hartmannpeter@gmail.com", req.invite)
|
||||
|
||||
assert req.is_new is False # one mailbox, one request — no second row for the admin queue
|
||||
assert "linked=hartmann.peter%40gmail.com" in url
|
||||
|
||||
|
||||
def test_the_same_spelling_twice_is_just_a_repeat_and_says_nothing_extra(db):
|
||||
db.add(Invite(email="same@example.com", status="pending"))
|
||||
db.commit()
|
||||
|
||||
req = upsert_pending_invite(db, "same@example.com")
|
||||
|
||||
# Nothing to explain: telling this user we "linked" their request to itself would be noise.
|
||||
assert "linked" not in _access_notice_url("requested", "same@example.com", req.invite)
|
||||
@@ -0,0 +1,289 @@
|
||||
"""E9.5 S7 — every outbound mail is written in the reader's language.
|
||||
|
||||
Two halves. The first is the catalog invariant: HU/EN parity with identical placeholders, enforced
|
||||
here because a mail is the one user-facing surface nobody can re-render by switching the UI language
|
||||
— it arrives once, in whatever language we picked. The second is the resolution chain: which
|
||||
language a given recipient actually gets, and where that signal was captured, since for an address
|
||||
with no account it exists only at the moment they asked us.
|
||||
"""
|
||||
|
||||
import re
|
||||
import string
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import mailtext
|
||||
from app.models import Invite, User
|
||||
from app.routes import auth as auth_routes
|
||||
|
||||
_PW = "correct horse battery"
|
||||
|
||||
|
||||
def _placeholders(template: str) -> set[str]:
|
||||
return {name for _, name, _, _ in string.Formatter().parse(template) if name}
|
||||
|
||||
|
||||
# --- the catalog itself ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", sorted(mailtext.MAIL_TEXT))
|
||||
def test_every_mail_string_exists_in_both_languages(key):
|
||||
assert set(mailtext.MAIL_TEXT[key]) == set(mailtext.SUPPORTED)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", sorted(mailtext.MAIL_TEXT))
|
||||
def test_every_translation_takes_the_same_placeholders(key):
|
||||
# The parity that actually bites: a translation that drops `{link}` renders a mail with no link
|
||||
# in it, and a translation that invents one raises KeyError inside a background task nobody is
|
||||
# watching. Both are silent in production and loud here.
|
||||
variants = mailtext.MAIL_TEXT[key]
|
||||
expected = _placeholders(variants[mailtext.DEFAULT])
|
||||
for lang, template in variants.items():
|
||||
assert _placeholders(template) == expected, f"{key} [{lang}]"
|
||||
|
||||
|
||||
def _app_sources_without_the_catalog() -> str:
|
||||
"""Every app source, with the catalog LITERAL cut out of mailtext.py.
|
||||
|
||||
Cutting it is the whole point: the dict spells each key as a quoted literal, so a scan that
|
||||
included it reported every key as used no matter what the catalog held. Cutting the whole FILE
|
||||
is too much, though — `link_expiry` legitimately asks for `duration.*`, and those keys would
|
||||
then look orphaned."""
|
||||
from pathlib import Path
|
||||
|
||||
app_dir = Path(__file__).resolve().parents[1] / "app"
|
||||
out = []
|
||||
for f in app_dir.rglob("*.py"):
|
||||
src = f.read_text(encoding="utf-8")
|
||||
if f.name == "mailtext.py":
|
||||
src = src[: src.index("MAIL_TEXT: dict")]
|
||||
out.append(src)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def test_no_catalog_entry_is_orphaned():
|
||||
"""Every key is referenced from the app, so a mail that stopped being sent can't leave its text
|
||||
behind to be 'kept up to date' by the next person who greps for it."""
|
||||
sources = _app_sources_without_the_catalog()
|
||||
assert [k for k in mailtext.MAIL_TEXT if f'"{k}"' not in sources] == []
|
||||
|
||||
|
||||
def test_every_key_the_app_asks_for_exists():
|
||||
"""The other direction, and the one that raises in production: `text()` looks the key up in a
|
||||
dict, so a renamed or mistyped key is a KeyError inside a background task with no user in sight."""
|
||||
asked = set(re.findall(r'text\(\s*"([a-z_]+\.[a-z_]+)"', _app_sources_without_the_catalog()))
|
||||
assert asked, "the scan found no mailtext.text(...) call sites — the pattern has rotted"
|
||||
assert sorted(asked - set(mailtext.MAIL_TEXT)) == []
|
||||
|
||||
|
||||
def test_an_unknown_language_falls_back_to_english_instead_of_raising():
|
||||
# `preferences.language` is free-form JSON written by older builds and by locales we may stop
|
||||
# shipping. A send that raised on one would take out a background task with no user in sight.
|
||||
assert mailtext.text("test.subject", "de") == mailtext.text("test.subject", "en")
|
||||
assert mailtext.text("test.subject", None) == mailtext.text("test.subject", "en")
|
||||
|
||||
|
||||
# --- who gets which language ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_users_own_preference_decides_their_language(make_user):
|
||||
hu = make_user(email="hu@example.com", preferences={"language": "hu"})
|
||||
en = make_user(email="en@example.com", preferences={"language": "en"})
|
||||
none = make_user(email="none@example.com", preferences=None)
|
||||
|
||||
assert mailtext.lang_for_user(hu) == "hu"
|
||||
assert mailtext.lang_for_user(en) == "en"
|
||||
assert mailtext.lang_for_user(none) == "en"
|
||||
|
||||
|
||||
def test_an_address_with_no_account_falls_back_to_the_language_it_asked_in(db):
|
||||
db.add(Invite(email="asked@example.com", status="pending", lang="hu"))
|
||||
db.commit()
|
||||
|
||||
assert mailtext.lang_for_email(db, "asked@example.com") == "hu"
|
||||
|
||||
|
||||
def test_the_account_wins_over_the_invite_row(db, make_user):
|
||||
make_user(email="both@example.com", preferences={"language": "en"})
|
||||
db.add(Invite(email="both@example.com", status="approved", lang="hu"))
|
||||
db.commit()
|
||||
|
||||
# The invite records how they asked once; the account records what they have since chosen.
|
||||
assert mailtext.lang_for_email(db, "both@example.com") == "en"
|
||||
|
||||
|
||||
def test_a_row_that_never_recorded_a_language_is_walked_past(db, make_user):
|
||||
# Prod carries TWO accounts for one Gmail mailbox (the 2026-09-03 pair). Stopping at the first
|
||||
# row would answer English for a person whose other account says Hungarian.
|
||||
make_user(email="dup.one@gmail.com", preferences={})
|
||||
make_user(email="dupone@gmail.com", preferences={"language": "hu"})
|
||||
|
||||
assert mailtext.lang_for_email(db, "dup.one@gmail.com") == "hu"
|
||||
|
||||
|
||||
def test_the_lookup_is_canonical_like_every_other_identity_match(db, make_user):
|
||||
make_user(email="canon.match@gmail.com", preferences={"language": "hu"})
|
||||
|
||||
assert mailtext.lang_for_email(db, "canonmatch+tag@gmail.com") == "hu"
|
||||
|
||||
|
||||
# --- capturing the signal at the one moment it exists ---------------------------------------------
|
||||
|
||||
|
||||
def test_registration_records_the_language_of_the_sign_up_page(db, client):
|
||||
r = client.post(
|
||||
"/auth/register", json={"email": "signup@example.com", "password": _PW, "lang": "hu"}
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
user = db.execute(select(User).where(User.email == "signup@example.com")).scalar_one()
|
||||
assert (user.preferences or {}).get("language") == "hu"
|
||||
|
||||
|
||||
def test_an_access_request_records_the_language_it_was_made_in(db, client):
|
||||
assert (
|
||||
client.post(
|
||||
"/auth/request-access", json={"email": "asking@example.com", "lang": "hu"}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
|
||||
assert inv.lang == "hu"
|
||||
|
||||
|
||||
def test_asking_again_from_another_language_updates_the_row(db, client):
|
||||
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "en"})
|
||||
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"})
|
||||
|
||||
db.expire_all()
|
||||
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
|
||||
# The newest thing they told us wins — and there is still exactly one row (S3's canonical upsert).
|
||||
assert inv.lang == "hu"
|
||||
assert db.execute(select(Invite)).scalars().all() == [inv]
|
||||
|
||||
|
||||
def test_an_unsupported_language_is_ignored_rather_than_stored(db, client):
|
||||
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "../../etc"})
|
||||
|
||||
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
|
||||
assert inv.lang is None
|
||||
|
||||
|
||||
# --- the mails themselves -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bodies(monkeypatch):
|
||||
"""Capture what `_send` would have put on the wire, rather than the arguments a caller passed —
|
||||
the language only matters if it reaches the body."""
|
||||
from app import email as email_mod
|
||||
|
||||
out: list[tuple[list[str], str, str]] = []
|
||||
monkeypatch.setattr(
|
||||
email_mod,
|
||||
"_send",
|
||||
lambda to, subject, body, reply_to=None: out.append((to, subject, body)) is None,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def test_the_confirmation_mail_is_written_in_the_accounts_language(db, client, monkeypatch):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
sent = _bodies(monkeypatch)
|
||||
|
||||
client.post("/auth/register", json={"email": "hu@example.com", "password": _PW, "lang": "hu"})
|
||||
|
||||
(_, subject, body) = sent[0]
|
||||
assert subject == mailtext.MAIL_TEXT["verify_email.subject"]["hu"]
|
||||
assert "#verify=" in body
|
||||
|
||||
|
||||
def test_the_approval_mail_answers_in_the_language_the_request_was_made_in(
|
||||
db, client, make_user, as_user, monkeypatch
|
||||
):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
sent = _bodies(monkeypatch)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"})
|
||||
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
|
||||
sent.clear() # drop the admin notification; this test is about the reply to the requester
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/approve")
|
||||
|
||||
assert r.status_code == 200
|
||||
(to, subject, _) = sent[0]
|
||||
assert to == ["asking@example.com"]
|
||||
assert subject == mailtext.MAIL_TEXT["access_approved.subject"]["hu"]
|
||||
|
||||
|
||||
def test_a_denial_answers_in_that_language_too(db, client, make_user, as_user, monkeypatch):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
sent = _bodies(monkeypatch)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"})
|
||||
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
|
||||
sent.clear()
|
||||
|
||||
as_user(admin).post(f"/api/admin/invites/{inv.id}/deny")
|
||||
|
||||
assert sent[0][1] == mailtext.MAIL_TEXT["access_denied.subject"]["hu"]
|
||||
|
||||
|
||||
def test_the_admins_pick_decides_an_invitation_and_is_remembered(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
sent = _bodies(monkeypatch)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
# Hand-whitelisted: nobody has ever told us anything about this mailbox, which is exactly why
|
||||
# the send button asks.
|
||||
inv = Invite(email="stranger@example.com", status="approved")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite", json={"lang": "hu"})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert sent[0][1] == mailtext.MAIL_TEXT["access_invitation.subject"]["hu"]
|
||||
db.expire_all()
|
||||
# Recorded, so a later mail to this address matches the one the admin already sent.
|
||||
assert db.get(Invite, inv.id).lang == "hu"
|
||||
|
||||
|
||||
def test_an_invitation_without_a_pick_still_goes_out(db, make_user, as_user, monkeypatch):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
sent = _bodies(monkeypatch)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="stranger@example.com", status="approved")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
|
||||
|
||||
assert r.status_code == 200
|
||||
assert sent[0][1] == mailtext.MAIL_TEXT["access_invitation.subject"]["en"]
|
||||
db.expire_all()
|
||||
# Nothing was picked, so nothing is remembered: the column means "what this person told us",
|
||||
# not "what we last defaulted to".
|
||||
assert db.get(Invite, inv.id).lang is None
|
||||
|
||||
|
||||
def test_admins_are_notified_in_their_own_languages(db, make_user):
|
||||
make_user(email="hu.admin@example.com", role="admin", preferences={"language": "hu"})
|
||||
make_user(email="en.admin@example.com", role="admin", preferences={"language": "en"})
|
||||
|
||||
groups = auth_routes.admin_notify_groups(db)
|
||||
|
||||
# One mail per language, not one blast that has to be wrong for somebody.
|
||||
assert groups == {"hu": ["hu.admin@example.com"], "en": ["en.admin@example.com"]}
|
||||
@@ -0,0 +1,58 @@
|
||||
"""/api/me carries whether this instance can actually send email.
|
||||
|
||||
It drives the operator warnings on the Configuration and Access-requests screens. Admin-only,
|
||||
because it is an operator concern and a regular user has nothing to do with it.
|
||||
"""
|
||||
|
||||
from app import email as email_mod
|
||||
|
||||
_PW = "correct horse battery"
|
||||
|
||||
|
||||
def _signed_in(client, make_user, **kw):
|
||||
"""/api/me resolves its user through optional_current_user, which the as_user override does not
|
||||
cover — so sign in for real and read the bootstrap payload the SPA actually gets."""
|
||||
user = make_user(password=_PW, **kw)
|
||||
assert (
|
||||
client.post("/auth/password-login", json={"email": user.email, "password": _PW}).status_code
|
||||
== 200
|
||||
)
|
||||
r = client.get("/api/me")
|
||||
assert r.status_code == 200 and r.json()["authenticated"] is True
|
||||
return r.json()
|
||||
|
||||
|
||||
def test_an_admin_is_told_when_email_is_not_configured(client, make_user, monkeypatch):
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: False)
|
||||
|
||||
me = _signed_in(client, make_user, email="admin@example.com", role="admin")
|
||||
|
||||
assert me["email_configured"] is False
|
||||
|
||||
|
||||
def test_an_admin_sees_nothing_wrong_when_email_works(client, make_user, monkeypatch):
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
|
||||
me = _signed_in(client, make_user, email="admin@example.com", role="admin")
|
||||
|
||||
assert me["email_configured"] is True
|
||||
|
||||
|
||||
def test_a_regular_user_is_never_warned_about_the_mail_setup(client, make_user, monkeypatch):
|
||||
# Not their problem, and the flag must not become a way to probe the instance's configuration.
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: False)
|
||||
|
||||
me = _signed_in(client, make_user, email="plain@example.com")
|
||||
|
||||
assert me["email_configured"] is True
|
||||
|
||||
|
||||
def test_the_flag_reuses_the_request_session(client, make_user, monkeypatch):
|
||||
# /api/me is polled once a minute per open tab; reading the SMTP settings decrypts a Fernet
|
||||
# value, so it must not also check out a second pooled connection each time.
|
||||
seen: list[object] = []
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: seen.append(db) or True)
|
||||
|
||||
_signed_in(client, make_user, email="admin@example.com", role="admin")
|
||||
|
||||
assert seen and seen[0] is not None
|
||||
@@ -0,0 +1,495 @@
|
||||
"""The playback-class filter (E7): filter the Plex feed by Direct Play / Direct Stream / Transcode·HW /
|
||||
Transcode·SW. Covers the shared classifier + Stream parsing (unit), the sync derivation + codec-change
|
||||
reset, the bit-depth backfill, and the /library filter + /facets counts (DB lane).
|
||||
|
||||
The HW/SW split is derived from the stored `transcode_hw` facet (codec at sync, refined by the bit-depth
|
||||
backfill). These tests pin that a 10-bit/HDR HEVC lands in SW even though its codec is HW-decodable —
|
||||
the exact case the runtime VAAPI path (plex.stream) also special-cases.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, User
|
||||
from app.plex import sync
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class TestTranscodeHwCapable:
|
||||
"""The single classifier both the catalog facet and (by codec) the runtime encoder choice key off."""
|
||||
|
||||
def test_non_transcode_items_are_none(self):
|
||||
assert sync.transcode_hw_capable("direct", "h264") is None
|
||||
assert sync.transcode_hw_capable("remux", "h264") is None
|
||||
assert sync.transcode_hw_capable(None, "hevc") is None
|
||||
|
||||
def test_hw_decodable_8bit_sdr_is_hw(self):
|
||||
assert sync.transcode_hw_capable("transcode", "hevc") is True
|
||||
assert sync.transcode_hw_capable("transcode", "vp9", bit_depth=8) is True
|
||||
|
||||
def test_non_decodable_codec_is_sw(self):
|
||||
# AV1 / MPEG-4 ASP (XviD) can't be HW-decoded on Gen 9.5 → software regardless of bit depth.
|
||||
assert sync.transcode_hw_capable("transcode", "av1") is False
|
||||
assert sync.transcode_hw_capable("transcode", "mpeg4", bit_depth=8) is False
|
||||
|
||||
def test_10bit_or_hdr_hevc_is_sw(self):
|
||||
# A HW-decodable codec that is 10-bit or HDR still breaks the VAAPI nv12 pipeline → software.
|
||||
assert sync.transcode_hw_capable("transcode", "hevc", bit_depth=10) is False
|
||||
assert sync.transcode_hw_capable("transcode", "hevc", bit_depth=8, hdr=True) is False
|
||||
|
||||
|
||||
class TestVideoStreamProps:
|
||||
def test_reads_bit_depth_and_hdr_from_the_video_stream(self):
|
||||
meta = {
|
||||
"Media": [
|
||||
{
|
||||
"Part": [
|
||||
{
|
||||
"Stream": [
|
||||
{"streamType": 2, "codec": "eac3"}, # audio — skipped
|
||||
{"streamType": 1, "bitDepth": 10, "colorTrc": "smpte2084"},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
assert sync._video_stream_props(meta) == (10, True)
|
||||
|
||||
def test_8bit_sdr_stream(self):
|
||||
meta = {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]}
|
||||
assert sync._video_stream_props(meta) == (8, False)
|
||||
|
||||
def test_absent_stream_is_none_false(self):
|
||||
assert sync._video_stream_props({}) == (None, False)
|
||||
assert sync._video_stream_props({"Media": [{"Part": [{}]}]}) == (None, False)
|
||||
|
||||
|
||||
class TestSyncDerivation:
|
||||
"""`_apply_item` sets transcode_hw codec-only at sync (bit depth unknown from the listing), and a
|
||||
changed video codec resets the probe watermark so a stale bit-depth can't linger."""
|
||||
|
||||
def _meta(self, vcodec, *, key="/parts/1/file.mkv", file="/m.mkv"):
|
||||
return {
|
||||
"title": "X",
|
||||
"Media": [{"videoCodec": vcodec, "Part": [{"file": file, "key": key}]}],
|
||||
}
|
||||
|
||||
def test_codec_only_derivation_at_sync(self):
|
||||
row = PlexItem(rating_key="x", kind="movie")
|
||||
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc"))
|
||||
assert row.playable == "transcode" and row.transcode_hw is True # decodable, 8-bit assumed
|
||||
row2 = PlexItem(rating_key="y", kind="movie")
|
||||
sync._apply_item(row2, lib_id=1, kind="movie", meta=self._meta("av1"))
|
||||
assert row2.transcode_hw is False # av1 not HW-decodable
|
||||
|
||||
def test_probed_value_survives_resync_when_codec_unchanged(self):
|
||||
row = PlexItem(rating_key="x", kind="movie")
|
||||
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc"))
|
||||
# Simulate the backfill downgrading this 10-bit HEVC to software.
|
||||
row.transcode_hw = False
|
||||
row.video_bit_depth = 10
|
||||
row.media_probed_at = sync.datetime.now(sync.timezone.utc)
|
||||
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc"))
|
||||
assert row.transcode_hw is False # NOT re-derived back to True
|
||||
assert row.video_bit_depth == 10 and row.media_probed_at is not None
|
||||
|
||||
def test_codec_change_resets_the_probe(self):
|
||||
row = PlexItem(rating_key="x", kind="movie")
|
||||
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc"))
|
||||
row.transcode_hw = False
|
||||
row.video_bit_depth = 10
|
||||
row.media_probed_at = sync.datetime.now(sync.timezone.utc)
|
||||
# The file was replaced with an h264 remux — the stale 10-bit probe must be cleared.
|
||||
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("h264"))
|
||||
assert row.video_bit_depth is None and row.media_probed_at is None
|
||||
|
||||
def test_same_codec_reencode_with_a_new_part_resets_the_probe(self):
|
||||
# A same-codec bit-depth re-encode (e.g. 8-bit→10-bit HEVC) keeps codec 'hevc' but replaces the
|
||||
# physical file, so its Plex Part key changes. Codec-only reset would miss this and keep the stale
|
||||
# 8-bit/HW verdict forever (the backfill only re-probes media_probed_at IS NULL rows) — the
|
||||
# part-key move must reset the watermark so the new bit depth is re-probed.
|
||||
row = PlexItem(rating_key="x", kind="movie")
|
||||
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc", key="/parts/1/f.mkv"))
|
||||
row.transcode_hw = True
|
||||
row.video_bit_depth = 8
|
||||
row.media_probed_at = sync.datetime.now(sync.timezone.utc)
|
||||
# Re-encoded in place: same codec 'hevc', but a new Part key (new file).
|
||||
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc", key="/parts/2/f.mkv"))
|
||||
assert row.video_bit_depth is None and row.media_probed_at is None # re-probe scheduled
|
||||
|
||||
|
||||
def test_migration_codec_set_matches_hw_decodable_codecs():
|
||||
# Migration 0077 hardcodes the HW-decodable codec set in raw SQL and its comment mandates it stay
|
||||
# identical to plex.sync.HW_DECODABLE_CODECS. A comment isn't enforcement (house rule) — pin the
|
||||
# equality so adding/removing a codec from the frozenset without editing the migration fails here
|
||||
# (else already-migrated prod rows keep a wrong transcode_hw the runtime derivation wouldn't catch).
|
||||
mig = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "alembic"
|
||||
/ "versions"
|
||||
/ "0077_plex_playback_class.py"
|
||||
)
|
||||
m = re.search(r"lower\(codec_video\)\s+IN\s+\(([^)]*)\)", mig.read_text())
|
||||
assert m, "could not find the codec IN(...) list in migration 0077"
|
||||
codecs = {c.strip().strip("'\"").lower() for c in m.group(1).split(",")}
|
||||
assert codecs == {c.lower() for c in sync.HW_DECODABLE_CODECS}
|
||||
|
||||
|
||||
class _FakePlex:
|
||||
"""Stands in for PlexClient.metadata — returns a canned full-metadata dict per rating_key."""
|
||||
|
||||
def __init__(self, by_key):
|
||||
self.by_key = by_key
|
||||
|
||||
def metadata(self, rating_key, markers=False):
|
||||
return self.by_key.get(rating_key)
|
||||
|
||||
|
||||
def test_probe_media_batch_refines_hw_to_sw_for_10bit(db):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
hevc8 = PlexItem(
|
||||
rating_key="h8",
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
title="8bit",
|
||||
playable="transcode",
|
||||
codec_video="hevc",
|
||||
transcode_hw=True,
|
||||
)
|
||||
hevc10 = PlexItem(
|
||||
rating_key="h10",
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
title="10bit",
|
||||
playable="transcode",
|
||||
codec_video="hevc",
|
||||
transcode_hw=True,
|
||||
)
|
||||
xvid = PlexItem(
|
||||
rating_key="xv",
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
title="xvid",
|
||||
playable="transcode",
|
||||
codec_video="mpeg4",
|
||||
transcode_hw=False,
|
||||
)
|
||||
db.add_all([hevc8, hevc10, xvid])
|
||||
db.commit()
|
||||
plex = _FakePlex(
|
||||
{
|
||||
"h8": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]},
|
||||
"h10": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 10}]}]}]},
|
||||
}
|
||||
)
|
||||
probed = sync._probe_media_batch(db, plex)
|
||||
db.refresh(hevc8)
|
||||
db.refresh(hevc10)
|
||||
db.refresh(xvid)
|
||||
# Only the two HW-decodable HEVC items are probed; the XviD is SW-by-codec and never fetched.
|
||||
assert probed == 2
|
||||
assert hevc8.video_bit_depth == 8 and hevc8.transcode_hw is True and hevc8.media_probed_at
|
||||
assert hevc10.video_bit_depth == 10 and hevc10.transcode_hw is False # downgraded to software
|
||||
assert xvid.media_probed_at is None # not in the work-list
|
||||
# A second run has nothing left to do (watermarks set).
|
||||
assert sync._probe_media_batch(db, plex) == 0
|
||||
|
||||
|
||||
def test_probe_stamps_even_when_plex_has_no_stream(db):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
item = PlexItem(
|
||||
rating_key="ns",
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
title="nostream",
|
||||
playable="transcode",
|
||||
codec_video="hevc",
|
||||
transcode_hw=True,
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
plex = _FakePlex({"ns": {"Media": [{"Part": [{}]}]}}) # no Stream array
|
||||
assert sync._probe_media_batch(db, plex) == 1
|
||||
db.refresh(item)
|
||||
# Probed (watermark set so it isn't re-fetched forever); bit depth unknown → stays codec-HW.
|
||||
assert item.media_probed_at is not None and item.video_bit_depth is None
|
||||
assert item.transcode_hw is True
|
||||
|
||||
|
||||
def test_probe_media_batch_is_bounded_and_oldest_first(db):
|
||||
# The per-run bound (limit) caps Plex load; oldest-id-first means successive runs cover the whole
|
||||
# backlog without starving any item. Seed 3 eligible items, probe with limit=2.
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
items = [
|
||||
PlexItem(
|
||||
rating_key=f"h{i}",
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
title=f"m{i}",
|
||||
playable="transcode",
|
||||
codec_video="hevc",
|
||||
transcode_hw=True,
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
db.add_all(items) # inserted in order → ascending ids
|
||||
db.commit()
|
||||
plex = _FakePlex(
|
||||
{
|
||||
f"h{i}": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]}
|
||||
for i in range(3)
|
||||
}
|
||||
)
|
||||
assert sync._probe_media_batch(db, plex, limit=2) == 2 # exactly the bound
|
||||
for it in items:
|
||||
db.refresh(it)
|
||||
# The two LOWEST-id rows were probed; the third waits for the next run.
|
||||
assert items[0].media_probed_at is not None and items[1].media_probed_at is not None
|
||||
assert items[2].media_probed_at is None
|
||||
# The next run continues with the remaining item.
|
||||
assert sync._probe_media_batch(db, plex, limit=2) == 1
|
||||
db.refresh(items[2])
|
||||
assert items[2].media_probed_at is not None
|
||||
|
||||
|
||||
class _RaisingPlex:
|
||||
"""metadata() raises for `bad_key`, returns a canned dict otherwise — to exercise the transient
|
||||
fetch-failure branch (leave the watermark NULL so the item retries next run)."""
|
||||
|
||||
def __init__(self, by_key, bad_key):
|
||||
self.by_key = by_key
|
||||
self.bad_key = bad_key
|
||||
|
||||
def metadata(self, rating_key, markers=False):
|
||||
if rating_key == self.bad_key:
|
||||
raise RuntimeError("plex timeout")
|
||||
return self.by_key.get(rating_key)
|
||||
|
||||
|
||||
def test_probe_media_batch_leaves_a_transient_failure_unstamped_to_retry(db):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
good = PlexItem(
|
||||
rating_key="g",
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
title="good",
|
||||
playable="transcode",
|
||||
codec_video="hevc",
|
||||
transcode_hw=True,
|
||||
)
|
||||
bad = PlexItem(
|
||||
rating_key="b",
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
title="bad",
|
||||
playable="transcode",
|
||||
codec_video="hevc",
|
||||
transcode_hw=True,
|
||||
)
|
||||
db.add_all([good, bad])
|
||||
db.commit()
|
||||
plex = _RaisingPlex(
|
||||
{"g": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 10}]}]}]}}, bad_key="b"
|
||||
)
|
||||
# The raise on 'b' must not abort the batch: 'g' is still probed and counted; 'b' stays NULL.
|
||||
assert sync._probe_media_batch(db, plex) == 1
|
||||
db.refresh(good)
|
||||
db.refresh(bad)
|
||||
assert good.media_probed_at is not None and good.transcode_hw is False # 10-bit → SW
|
||||
assert bad.media_probed_at is None # transient failure → retried next run, not stamped
|
||||
|
||||
|
||||
def _seed_classes(db):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
PlexItem(
|
||||
rating_key="d1", library_id=lib.id, kind="movie", title="Direct", playable="direct"
|
||||
),
|
||||
PlexItem(
|
||||
rating_key="r1", library_id=lib.id, kind="movie", title="Remux", playable="remux"
|
||||
),
|
||||
PlexItem(
|
||||
rating_key="thw",
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
title="HW",
|
||||
playable="transcode",
|
||||
codec_video="hevc",
|
||||
transcode_hw=True,
|
||||
),
|
||||
PlexItem(
|
||||
rating_key="tsw",
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
title="SW",
|
||||
playable="transcode",
|
||||
codec_video="mpeg4",
|
||||
transcode_hw=False,
|
||||
),
|
||||
]
|
||||
)
|
||||
return lib
|
||||
|
||||
|
||||
def _client(db, user, monkeypatch):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_library_filters_by_playback_class(db, monkeypatch):
|
||||
_seed_classes(db)
|
||||
user = User(email="pclass@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
client = _client(db, user, monkeypatch)
|
||||
try:
|
||||
# Software-only transcodes.
|
||||
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
|
||||
assert sw.status_code == 200, sw.text
|
||||
assert {i["id"] for i in sw.json()["items"]} == {"tsw"}
|
||||
# HW transcode + Direct Play, OR-combined.
|
||||
both = client.get("/api/plex/library?scope=movie&playable=transcode_hw,direct")
|
||||
assert {i["id"] for i in both.json()["items"]} == {"thw", "d1"}
|
||||
# The HW card carries the transcode_hw flag for the 4-way badge.
|
||||
card = next(i for i in both.json()["items"] if i["id"] == "thw")
|
||||
assert card["transcode_hw"] is True
|
||||
# No filter → everything.
|
||||
allm = client.get("/api/plex/library?scope=movie")
|
||||
assert {i["id"] for i in allm.json()["items"]} == {"d1", "r1", "thw", "tsw"}
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_facets_count_playback_classes_in_severity_order(db, monkeypatch):
|
||||
_seed_classes(db)
|
||||
user = User(email="pclassfacet@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
client = _client(db, user, monkeypatch)
|
||||
try:
|
||||
resp = client.get("/api/plex/facets?scope=movie")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert resp.status_code == 200, resp.text
|
||||
playable = resp.json()["playable"]
|
||||
# Fixed good→heavy order (not by count), one per seeded class.
|
||||
assert [p["value"] for p in playable] == ["direct", "remux", "transcode_hw", "transcode_sw"]
|
||||
assert {p["value"]: p["count"] for p in playable} == {
|
||||
"direct": 1,
|
||||
"remux": 1,
|
||||
"transcode_hw": 1,
|
||||
"transcode_sw": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_unprobed_transcode_folds_into_sw(db, monkeypatch):
|
||||
# transcode_hw NULL is the COMMON initial state (before the bit-depth backfill runs). The query's
|
||||
# CASE has no explicit NULL arm — it relies on the `playable=='transcode'` else folding NULL in with
|
||||
# software. Pin that fold for BOTH the filter and the facet, so a reorder can't silently drop the
|
||||
# item from the feed (an honest-state violation).
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
db.add(
|
||||
PlexItem(
|
||||
rating_key="tnull",
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
title="Unprobed",
|
||||
playable="transcode",
|
||||
codec_video="hevc",
|
||||
transcode_hw=None,
|
||||
)
|
||||
)
|
||||
user = User(email="pnull@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
client = _client(db, user, monkeypatch)
|
||||
try:
|
||||
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
|
||||
assert {i["id"] for i in sw.json()["items"]} == {"tnull"} # NULL → SW bucket
|
||||
hw = client.get("/api/plex/library?scope=movie&playable=transcode_hw")
|
||||
assert hw.json()["items"] == [] # never HW
|
||||
facets = client.get("/api/plex/facets?scope=movie")
|
||||
counts = {p["value"]: p["count"] for p in facets.json()["playable"]}
|
||||
assert counts.get("transcode_sw") == 1 and counts.get("transcode_hw", 0) == 0
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_facet_playable_self_excludes_the_active_selection(db, monkeypatch):
|
||||
# A selected playable class must NOT narrow the playable facet's OWN counts (facet self-exclusion),
|
||||
# otherwise picking one class would collapse the list and you couldn't OR-add a second. Selecting
|
||||
# 'direct' must still return all four classes at their full, unnarrowed counts.
|
||||
_seed_classes(db)
|
||||
user = User(email="pself@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
client = _client(db, user, monkeypatch)
|
||||
try:
|
||||
resp = client.get("/api/plex/facets?scope=movie&playable=direct")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert resp.status_code == 200, resp.text
|
||||
counts = {p["value"]: p["count"] for p in resp.json()["playable"]}
|
||||
assert counts == {"direct": 1, "remux": 1, "transcode_hw": 1, "transcode_sw": 1}
|
||||
|
||||
|
||||
def test_active_playable_narrows_the_other_facets(db, monkeypatch):
|
||||
# The flip side of self-exclusion: an active playable class DOES narrow the OTHER facet groups (so
|
||||
# e.g. the year range reflects only the selected class). Seed a Direct movie (2000) and a
|
||||
# software-transcode movie (2020); selecting playable=direct must collapse the year facet to 2000.
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
PlexItem(
|
||||
rating_key="d2000",
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
title="Direct2000",
|
||||
playable="direct",
|
||||
year=2000,
|
||||
),
|
||||
PlexItem(
|
||||
rating_key="s2020",
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
title="SW2020",
|
||||
playable="transcode",
|
||||
codec_video="mpeg4",
|
||||
transcode_hw=False,
|
||||
year=2020,
|
||||
),
|
||||
]
|
||||
)
|
||||
user = User(email="pnarrow@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
client = _client(db, user, monkeypatch)
|
||||
try:
|
||||
wide = client.get("/api/plex/facets?scope=movie").json()
|
||||
narrow = client.get("/api/plex/facets?scope=movie&playable=direct").json()
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert (wide["year_min"], wide["year_max"]) == (2000, 2020) # both classes span the range
|
||||
assert (narrow["year_min"], narrow["year_max"]) == (2000, 2000) # narrowed to the Direct subset
|
||||
@@ -58,7 +58,7 @@ def _post(db, user, monkeypatch, rating_key, *, captured):
|
||||
def fake_start(db_, it, start, audio, aoff, multi, transcode=False):
|
||||
captured["transcode"] = transcode
|
||||
return SimpleNamespace(
|
||||
media_start_s=start, entry="index.m3u8", transcode=transcode, hw=False
|
||||
media_start_s=start, entry="index.m3u8", transcode=transcode, hw=False, tonemap=False
|
||||
)
|
||||
|
||||
monkeypatch.setattr(plex_stream, "start_session", fake_start)
|
||||
|
||||
@@ -267,50 +267,206 @@ class TestTranscodeCommand:
|
||||
)
|
||||
assert cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12" # not a software scale
|
||||
|
||||
def test_hdr_tonemap_uses_the_opencl_graph(self):
|
||||
# The HDR/10-bit HW path: VAAPI decode → OpenCL tonemap → VAAPI encode. Gen 9.5 can't tonemap in
|
||||
# the VAAPI VPP, so this is the only HW route for a 10-bit/HDR source.
|
||||
cmd = stream._ffmpeg_cmd(
|
||||
self.SRC, 0.0, self.OUT, None, transcode=True, hw=True, tonemap=True
|
||||
)
|
||||
assert _venc(cmd) == "h264_vaapi"
|
||||
assert "libx264" not in cmd
|
||||
# Named devices + an OpenCL device DERIVED from the VAAPI one, all before -i.
|
||||
assert cmd[cmd.index("-init_hw_device") + 1] == f"vaapi=va:{stream._VAAPI_DEVICE}"
|
||||
assert "opencl=ocl@va" in cmd
|
||||
assert cmd[cmd.index("-hwaccel_device") + 1] == "va"
|
||||
assert cmd.index("-init_hw_device") < cmd.index("-i")
|
||||
# The filter graph runs on the derived OpenCL device and tone-maps HDR→SDR to 8-bit nv12.
|
||||
assert cmd[cmd.index("-filter_hw_device") + 1] == "ocl"
|
||||
vf = cmd[cmd.index("-vf") + 1]
|
||||
assert "hwmap=derive_device=opencl" in vf
|
||||
assert "tonemap_opencl=" in vf and "format=nv12" in vf
|
||||
assert "hwmap=derive_device=vaapi:reverse=1" in vf
|
||||
assert "scale_vaapi" not in vf # the plain 8-bit path's filter must NOT be used here
|
||||
assert (
|
||||
cmd[cmd.index("-force_key_frames") + 1] == f"expr:gte(t,n_forced*{stream._SEG_SECONDS})"
|
||||
)
|
||||
|
||||
|
||||
class TestWantHw:
|
||||
"""VAAPI is used only where the render node was passed in AND (in auto) the source is HW-decodable,
|
||||
with an admin `plex_transcode_hw` policy (auto/on/off) on top."""
|
||||
with an admin `plex_transcode_hw` policy (auto/on/off) on top. A HW session on a 10-bit/HDR source
|
||||
tone-maps on the GPU via OpenCL (tonemap=True) when the OpenCL runtime is present, else drops to
|
||||
software — see the `_select_encode` cases below."""
|
||||
|
||||
SRC = Path("/media/movie.mkv")
|
||||
|
||||
@staticmethod
|
||||
def _policy(monkeypatch, mode):
|
||||
monkeypatch.setattr(stream.sysconfig, "get_str", lambda db, k: mode)
|
||||
|
||||
@staticmethod
|
||||
def _kind(monkeypatch, kind):
|
||||
# Pin the source classifier so the tests don't shell out to ffprobe. `kind` is one of
|
||||
# stream._VK_HWSAFE / _VK_HDR / _VK_SDR10.
|
||||
monkeypatch.setattr(stream, "_probe_video_kind", lambda src: kind)
|
||||
|
||||
@staticmethod
|
||||
def _hwsafe(monkeypatch, safe):
|
||||
# Back-compat convenience for the hw-gate tests that only care about 8-bit-SDR vs not: True →
|
||||
# _VK_HWSAFE, False → _VK_SDR10 (a non-HDR non-hwsafe class, so the hw half still resolves).
|
||||
monkeypatch.setattr(
|
||||
stream, "_probe_video_kind", lambda src: stream._VK_HWSAFE if safe else stream._VK_SDR10
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _opencl(monkeypatch, present):
|
||||
# Pin the OpenCL-runtime probe so the routing doesn't depend on the CI box's /etc/OpenCL.
|
||||
monkeypatch.setattr(stream, "_opencl_present", lambda: present)
|
||||
|
||||
def test_no_hw_when_not_transcoding(self, monkeypatch):
|
||||
self._policy(monkeypatch, "auto")
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=False) is False
|
||||
assert (
|
||||
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=False, src=self.SRC)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_no_hw_without_a_render_node(self, monkeypatch):
|
||||
self._policy(monkeypatch, "auto")
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: False)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False
|
||||
assert (
|
||||
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_auto_uses_hw_for_a_decodable_source_with_the_render_node(self, monkeypatch):
|
||||
def test_auto_uses_hw_for_a_decodable_8bit_source_with_the_render_node(self, monkeypatch):
|
||||
self._policy(monkeypatch, "auto")
|
||||
self._hwsafe(monkeypatch, True)
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is True
|
||||
assert (
|
||||
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_auto_falls_back_to_software_for_a_non_decodable_source(self, monkeypatch):
|
||||
# AV1 / MPEG-4 ASP (XviD) aren't decodable on Gen 9.5 → the GPU can't feed its own encoder.
|
||||
self._policy(monkeypatch, "auto")
|
||||
self._hwsafe(monkeypatch, True) # decodability gate must fire BEFORE the probe
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True) is False
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="mpeg4"), transcode=True) is False
|
||||
assert (
|
||||
stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True, src=self.SRC)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
stream._want_hw(None, _FakeItem(codec_video="mpeg4"), transcode=True, src=self.SRC)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_auto_hdr_source_falls_back_to_software_without_opencl(self, monkeypatch):
|
||||
# A decodable HEVC that is true-HDR (PQ/HLG) can't use scale_vaapi=format=nv12 (Gen 9.5), and with
|
||||
# no OpenCL runtime to tonemap on the GPU it drops to software libx264 (BUG 1's behaviour).
|
||||
self._policy(monkeypatch, "auto")
|
||||
self._kind(monkeypatch, stream._VK_HDR)
|
||||
self._opencl(monkeypatch, False)
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._select_encode(
|
||||
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||
) == (False, False)
|
||||
|
||||
def test_auto_hdr_source_uses_hw_tonemap_with_opencl(self, monkeypatch):
|
||||
# With the OpenCL runtime present, a true-HDR HEVC tone-maps on the GPU (VAAPI decode → OpenCL
|
||||
# tonemap → VAAPI encode) instead of dropping to CPU — the HW HDR path.
|
||||
self._policy(monkeypatch, "auto")
|
||||
self._kind(monkeypatch, stream._VK_HDR)
|
||||
self._opencl(monkeypatch, True)
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._select_encode(
|
||||
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||
) == (True, True)
|
||||
|
||||
def test_auto_10bit_SDR_source_uses_software_not_tonemap(self, monkeypatch):
|
||||
# F1 regression: a 10-bit *SDR* (bt709) source is NOT HDR, so it must NOT get the hable HDR tone
|
||||
# curve even when the OpenCL runtime is present — that would visibly alter an SDR picture. It takes
|
||||
# software libx264 (an exact 10→8-bit downconvert), never (True, True).
|
||||
self._policy(monkeypatch, "auto")
|
||||
self._kind(monkeypatch, stream._VK_SDR10)
|
||||
self._opencl(monkeypatch, True)
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._select_encode(
|
||||
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||
) == (False, False)
|
||||
|
||||
def test_auto_8bit_source_uses_plain_hw_not_tonemap(self, monkeypatch):
|
||||
# An 8-bit SDR source takes the plain full-GPU scale path — never the tonemap graph — even where
|
||||
# OpenCL is available.
|
||||
self._policy(monkeypatch, "auto")
|
||||
self._kind(monkeypatch, stream._VK_HWSAFE)
|
||||
self._opencl(monkeypatch, True)
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._select_encode(
|
||||
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||
) == (True, False)
|
||||
|
||||
def test_off_forces_software_even_for_a_decodable_source(self, monkeypatch):
|
||||
self._policy(monkeypatch, "off")
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False
|
||||
assert (
|
||||
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_on_forces_hw_even_for_a_non_decodable_source(self, monkeypatch):
|
||||
# The admin override trusts the GPU to decode it; skips the decodability gate (render node still
|
||||
# required — HW is impossible without it).
|
||||
def test_on_forces_hw_and_skips_both_source_gates(self, monkeypatch):
|
||||
# The admin override trusts the GPU; skips the decodability AND the bit-depth gate (the runtime
|
||||
# HW→SW fallback still backstops it). Render node still required — HW is impossible without it.
|
||||
self._policy(monkeypatch, "on")
|
||||
self._hwsafe(monkeypatch, False) # would veto in auto; "on" ignores it
|
||||
self._opencl(
|
||||
monkeypatch, False
|
||||
) # pin: tonemap-half is irrelevant here, keep it deterministic
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True) is True
|
||||
assert (
|
||||
stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True, src=self.SRC)
|
||||
is True
|
||||
)
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: False)
|
||||
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False
|
||||
assert (
|
||||
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_on_still_picks_the_tonemap_graph_for_an_hdr_source(self, monkeypatch):
|
||||
# "on" trusts the GPU but must still route a true-HDR source through the tonemap graph (plain
|
||||
# scale_vaapi would hit the VPP wall) when OpenCL is present.
|
||||
self._policy(monkeypatch, "on")
|
||||
self._kind(monkeypatch, stream._VK_HDR)
|
||||
self._opencl(monkeypatch, True)
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._select_encode(
|
||||
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||
) == (True, True)
|
||||
|
||||
def test_on_10bit_SDR_uses_plain_hw_not_tonemap(self, monkeypatch):
|
||||
# F1 regression under "on": a 10-bit SDR source is NOT HDR, so even with OpenCL present "on" must
|
||||
# pick plain HW (True, False) — the HDR tone curve must never touch an SDR picture.
|
||||
self._policy(monkeypatch, "on")
|
||||
self._kind(monkeypatch, stream._VK_SDR10)
|
||||
self._opencl(monkeypatch, True)
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._select_encode(
|
||||
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||
) == (True, False)
|
||||
|
||||
def test_on_without_opencl_is_plain_hw(self, monkeypatch):
|
||||
# "on" with no OpenCL runtime → plain HW, tonemap=False, regardless of source class (the runtime
|
||||
# HW→SW fallback backstops a source the plain path can't actually handle). Pins the tonemap half
|
||||
# of the "on" tuple, which test_on_forces_hw_and_skips_both_source_gates only checks via _want_hw.
|
||||
self._policy(monkeypatch, "on")
|
||||
self._kind(monkeypatch, stream._VK_HDR) # even HDR: no runtime → can't tonemap on the GPU
|
||||
self._opencl(monkeypatch, False)
|
||||
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
|
||||
assert stream._select_encode(
|
||||
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
|
||||
) == (True, False)
|
||||
|
||||
|
||||
class TestColdStartWarmup:
|
||||
@@ -333,7 +489,7 @@ class TestColdStartWarmup:
|
||||
def test_warmup_waits_for_the_first_few_segments(self, fake_env, monkeypatch):
|
||||
waited = []
|
||||
|
||||
def spy(path, timeout=20.0):
|
||||
def spy(path, timeout=20.0, proc=None):
|
||||
waited.append(path.name)
|
||||
return True
|
||||
|
||||
@@ -346,7 +502,7 @@ class TestColdStartWarmup:
|
||||
def test_warmup_stops_at_the_first_segment_that_isnt_ready(self, fake_env, monkeypatch):
|
||||
waited = []
|
||||
|
||||
def spy(path, timeout=20.0):
|
||||
def spy(path, timeout=20.0, proc=None):
|
||||
waited.append(path.name)
|
||||
return path.name != "seg_1.ts" # seg_1 never appears → warm-up gives up there
|
||||
|
||||
@@ -360,7 +516,7 @@ class TestColdStartWarmup:
|
||||
# still returned so the client's own segment requests can drive it, as before.
|
||||
waited = []
|
||||
|
||||
def spy(path, timeout=20.0):
|
||||
def spy(path, timeout=20.0, proc=None):
|
||||
waited.append(path.name)
|
||||
return False
|
||||
|
||||
@@ -398,7 +554,7 @@ class TestTranscodeStartSession:
|
||||
"get_str",
|
||||
lambda db, k: {"plex_transcode_preset": "slow"}.get(k, "auto"),
|
||||
)
|
||||
monkeypatch.setattr(stream, "wait_for", lambda path, timeout=20.0: True)
|
||||
monkeypatch.setattr(stream, "wait_for", lambda path, timeout=20.0, proc=None: True)
|
||||
_CapturingProc.calls = []
|
||||
monkeypatch.setattr(stream.subprocess, "Popen", _CapturingProc)
|
||||
return tmp_path
|
||||
@@ -406,7 +562,9 @@ class TestTranscodeStartSession:
|
||||
def test_software_transcode_threads_the_knobs_into_the_command(
|
||||
self, capturing_env, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode: False)
|
||||
monkeypatch.setattr(
|
||||
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
|
||||
)
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="mpeg4"), start_s=0.0, transcode=True
|
||||
)
|
||||
@@ -421,25 +579,292 @@ class TestTranscodeStartSession:
|
||||
assert kwargs.get("preexec_fn") is stream._sw_child_preexec
|
||||
|
||||
def test_hardware_transcode_uses_the_gpu_and_never_nices(self, capturing_env, monkeypatch):
|
||||
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode: True)
|
||||
monkeypatch.setattr(
|
||||
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
|
||||
)
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
)
|
||||
assert s is not None and s.transcode is True and s.hw is True
|
||||
assert s is not None and s.transcode is True and s.hw is True and s.tonemap is False
|
||||
assert len(_CapturingProc.calls) == 1 # first segment arrived — no HW→SW fallback respawn
|
||||
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||
assert _venc(cmd) == "h264_vaapi"
|
||||
assert "libx264" not in cmd
|
||||
assert (
|
||||
"-vf" not in cmd or cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12"
|
||||
) # no SW cap
|
||||
# Plain-HW path unconditionally emits the nv12 downconvert (a disjunction on "-vf not in cmd"
|
||||
# would silently pass if the filter were dropped — assert it is actually present).
|
||||
assert cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12" # no SW cap
|
||||
assert kwargs.get("preexec_fn") is None # GPU encode is cheap — never niced
|
||||
|
||||
def test_hdr_tonemap_transcode_threads_the_opencl_graph_and_flags_the_session(
|
||||
self, capturing_env, monkeypatch
|
||||
):
|
||||
# A HW tonemap session (10-bit/HDR + OpenCL) must build the OpenCL graph in the argv and carry
|
||||
# tonemap=True on the session, still on the GPU (hw=True) and never niced.
|
||||
monkeypatch.setattr(stream, "_select_encode", lambda db, item, transcode, src: (True, True))
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
)
|
||||
assert s is not None and s.transcode is True and s.hw is True and s.tonemap is True
|
||||
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||
assert _venc(cmd) == "h264_vaapi"
|
||||
assert "opencl=ocl@va" in cmd and cmd[cmd.index("-filter_hw_device") + 1] == "ocl"
|
||||
assert "tonemap_opencl=" in cmd[cmd.index("-vf") + 1]
|
||||
assert kwargs.get("preexec_fn") is None
|
||||
|
||||
def test_a_hw_session_that_produces_nothing_falls_back_to_software(
|
||||
self, capturing_env, monkeypatch
|
||||
):
|
||||
# BUG 1 backstop: _want_hw picked HW but the VAAPI session emits no first segment (a VAProfile
|
||||
# refusal the gates didn't catch). start_session must respawn on libx264 and report hw=False so
|
||||
# the item still plays and the route/player honestly show CPU.
|
||||
monkeypatch.setattr(
|
||||
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
|
||||
)
|
||||
seen = {"n": 0}
|
||||
|
||||
def flaky_wait(path, timeout=20.0, proc=None):
|
||||
seen["n"] += 1
|
||||
return seen["n"] > 1 # the first seg-0 wait (HW) fails; every wait after succeeds (SW)
|
||||
|
||||
monkeypatch.setattr(stream, "wait_for", flaky_wait)
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
)
|
||||
assert (
|
||||
s is not None and s.transcode is True and s.hw is False
|
||||
) # honest CPU after the fallback
|
||||
assert len(_CapturingProc.calls) == 2 # the HW attempt, then the SW respawn
|
||||
(hw_cmd, *_), _ = _CapturingProc.calls[0]
|
||||
(sw_cmd, *_), sw_kwargs = _CapturingProc.calls[1]
|
||||
assert _venc(hw_cmd) == "h264_vaapi"
|
||||
assert _venc(sw_cmd) == "libx264"
|
||||
if os.name == "posix":
|
||||
assert sw_kwargs.get("preexec_fn") is stream._sw_child_preexec # the SW child is niced
|
||||
|
||||
def test_a_hw_TONEMAP_session_that_produces_nothing_falls_back_to_software(
|
||||
self, capturing_env, monkeypatch
|
||||
):
|
||||
# The fallback gate is `not ready and s.hw`, shared by plain-HW and tonemap sessions — but only
|
||||
# the plain-HW variant was covered. Pin the tonemap variant too: a (True, True) session that
|
||||
# emits nothing (an OpenCL/tonemap init refusal) must respawn on libx264 and end hw=False,
|
||||
# tonemap=False, so an HDR item still plays on CPU.
|
||||
monkeypatch.setattr(stream, "_select_encode", lambda db, item, transcode, src: (True, True))
|
||||
seen = {"n": 0}
|
||||
|
||||
def flaky_wait(path, timeout=20.0, proc=None):
|
||||
seen["n"] += 1
|
||||
return (
|
||||
seen["n"] > 1
|
||||
) # the first seg-0 wait (HW tonemap) fails; every wait after succeeds
|
||||
|
||||
monkeypatch.setattr(stream, "wait_for", flaky_wait)
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
)
|
||||
assert s is not None and s.transcode is True and s.hw is False and s.tonemap is False
|
||||
assert len(_CapturingProc.calls) == 2 # the HW tonemap attempt, then the SW respawn
|
||||
(hw_cmd, *_), _ = _CapturingProc.calls[0]
|
||||
(sw_cmd, *_), _ = _CapturingProc.calls[1]
|
||||
assert (
|
||||
"tonemap_opencl=" in hw_cmd[hw_cmd.index("-vf") + 1]
|
||||
) # first attempt built the OpenCL graph
|
||||
assert _venc(sw_cmd) == "libx264" # respawn is plain software, no tonemap
|
||||
|
||||
def test_a_superseded_hw_session_does_not_respawn_over_the_newer_one(
|
||||
self, capturing_env, monkeypatch
|
||||
):
|
||||
# F2/race: start_session waits on seg_0 OUTSIDE _lock. If a concurrent same-key start (a rapid
|
||||
# seek-restart) or _enforce_cap replaces _sessions[key] while we wait, our proc dying must NOT
|
||||
# trigger the SW fallback — that would tear down the NEWER session and breach the transcode cap.
|
||||
# The identity guard must detect it's been superseded and yield the currently-registered session.
|
||||
monkeypatch.setattr(
|
||||
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
|
||||
)
|
||||
|
||||
class _Newer: # stands in for the session a concurrent _spawn registered
|
||||
hw = True
|
||||
tonemap = False
|
||||
transcode = True
|
||||
last_access = 0.0
|
||||
|
||||
newer = _Newer()
|
||||
|
||||
def wait_then_supersede(path, timeout=20.0, proc=None):
|
||||
# Simulate the concurrent replacement happening during our out-of-lock wait, then report the
|
||||
# (now-killed) HW proc produced nothing.
|
||||
stream._sessions["12345"] = newer
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(stream, "wait_for", wait_then_supersede)
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
)
|
||||
# No SW respawn — only the initial HW _spawn ran; the newer session was left intact.
|
||||
assert len(_CapturingProc.calls) == 1
|
||||
assert s is newer # yielded the currently-registered session, did not clobber it
|
||||
assert stream._sessions["12345"] is newer
|
||||
|
||||
def test_a_cap_culled_hw_session_does_not_respawn_and_is_not_reported_missing(
|
||||
self, capturing_env, monkeypatch
|
||||
):
|
||||
# F2/race, the cull sub-case: while we wait on seg_0 out of lock, cross-key cap pressure lets
|
||||
# _enforce_cap evict THIS session's key entirely (no replacement). The fallback must still not
|
||||
# respawn (that would re-breach the cap the cull just enforced), and must NOT return None — a None
|
||||
# is turned by the route into a dishonest "file not found" 404 for a file that exists. It yields
|
||||
# our own (now-dead) session instead, so the client's segment requests degrade to the retryable
|
||||
# "not available yet" path.
|
||||
monkeypatch.setattr(
|
||||
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
|
||||
)
|
||||
captured = {}
|
||||
|
||||
def wait_then_cull(path, timeout=20.0, proc=None):
|
||||
captured["s"] = stream._sessions[
|
||||
"12345"
|
||||
] # the HW session the initial _spawn just registered
|
||||
del stream._sessions[
|
||||
"12345"
|
||||
] # simulate _enforce_cap dropping the LRU under cap pressure
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(stream, "wait_for", wait_then_cull)
|
||||
s = stream.start_session(
|
||||
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
|
||||
)
|
||||
assert (
|
||||
len(_CapturingProc.calls) == 1
|
||||
) # no SW respawn — the cull is respected, cap not re-breached
|
||||
assert (
|
||||
s is captured["s"]
|
||||
) # our own session yielded, NOT None (which the route would 404 as missing)
|
||||
assert "12345" not in stream._sessions # the culled key stays gone
|
||||
|
||||
def test_remux_reads_no_transcode_knobs_and_stream_copies(self, capturing_env, monkeypatch):
|
||||
# A remux start must stream-copy the video and spawn no niced child (the transcode knobs and
|
||||
# the preexec nice are gated behind `transcode=True`).
|
||||
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode: False)
|
||||
monkeypatch.setattr(
|
||||
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
|
||||
)
|
||||
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) # transcode defaults False
|
||||
assert s is not None and s.transcode is False
|
||||
(cmd, *_), kwargs = _CapturingProc.calls[0]
|
||||
assert _venc(cmd) == "copy"
|
||||
assert kwargs.get("preexec_fn") is None
|
||||
|
||||
|
||||
class TestProbeVideoKind:
|
||||
"""The ffprobe-based source classifier that routes the transcode pipeline: 8-bit SDR → plain VAAPI;
|
||||
true-HDR (PQ/HLG) → the OpenCL tonemap graph; 10-bit *SDR* → software (an exact downconvert, NEVER
|
||||
the HDR tone curve — the F1 distinction). Exercises the REAL function body (not a monkeypatch)."""
|
||||
|
||||
@staticmethod
|
||||
def _probe(monkeypatch, stdout):
|
||||
class _R:
|
||||
def __init__(self):
|
||||
self.stdout = stdout
|
||||
|
||||
monkeypatch.setattr(stream.subprocess, "run", lambda *a, **k: _R())
|
||||
|
||||
def test_8bit_sdr_is_hwsafe(self, monkeypatch):
|
||||
self._probe(monkeypatch, "pix_fmt=yuv420p\nbits_per_raw_sample=8\ncolor_transfer=bt709\n")
|
||||
assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE
|
||||
|
||||
def test_10bit_sdr_pix_fmt_is_sdr10(self, monkeypatch):
|
||||
# yuv420p10le with a non-HDR transfer (bt2020-10 is NOT PQ/HLG) = 10-bit SDR → software, not
|
||||
# tonemap. Signalled by the pix_fmt suffix even when bits_per_raw_sample is absent.
|
||||
self._probe(monkeypatch, "pix_fmt=yuv420p10le\ncolor_transfer=bt2020-10\n")
|
||||
assert stream._probe_video_kind(Path("/x")) == stream._VK_SDR10
|
||||
|
||||
def test_bits_per_raw_sample_over_8_sdr_is_sdr10(self, monkeypatch):
|
||||
self._probe(monkeypatch, "pix_fmt=yuv420p\nbits_per_raw_sample=10\ncolor_transfer=bt709\n")
|
||||
assert stream._probe_video_kind(Path("/x")) == stream._VK_SDR10
|
||||
|
||||
def test_pq_transfer_is_hdr(self, monkeypatch):
|
||||
# PQ (smpte2084) is true HDR — needs a real tonemap, even if only 8-bit-tagged.
|
||||
self._probe(
|
||||
monkeypatch, "pix_fmt=yuv420p10le\nbits_per_raw_sample=10\ncolor_transfer=smpte2084\n"
|
||||
)
|
||||
assert stream._probe_video_kind(Path("/x")) == stream._VK_HDR
|
||||
|
||||
def test_hlg_transfer_is_hdr(self, monkeypatch):
|
||||
# HLG (arib-std-b67) is the other HDR transfer — previously untested; it must classify as HDR.
|
||||
self._probe(
|
||||
monkeypatch,
|
||||
"pix_fmt=yuv420p10le\nbits_per_raw_sample=10\ncolor_transfer=arib-std-b67\n",
|
||||
)
|
||||
assert stream._probe_video_kind(Path("/x")) == stream._VK_HDR
|
||||
|
||||
def test_empty_probe_output_is_hwsafe(self, monkeypatch):
|
||||
# A probe that returns nothing (no streams / stripped metadata) → assume hwsafe; the runtime
|
||||
# HW→SW fallback backstops a wrong guess.
|
||||
self._probe(monkeypatch, "")
|
||||
assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE
|
||||
|
||||
def test_probe_failure_assumes_hwsafe(self, monkeypatch):
|
||||
# ffprobe missing/erroring → assume hwsafe; the runtime HW→SW fallback is the real backstop, so a
|
||||
# probe miss costs at most one wasted HW attempt, never a stranded item.
|
||||
def boom(*a, **k):
|
||||
raise OSError("no ffprobe")
|
||||
|
||||
monkeypatch.setattr(stream.subprocess, "run", boom)
|
||||
assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE
|
||||
|
||||
|
||||
class TestOpenclPresent:
|
||||
"""The OpenCL-runtime probe (_opencl_present) — the precondition for the HW HDR tonemap graph. Drives
|
||||
the REAL glob against a temp ICD dir instead of monkeypatching the function, so a regression to the
|
||||
ICD path / glob / OSError guard would be caught (this routes every HDR source on prod)."""
|
||||
|
||||
def test_true_when_an_icd_file_is_registered(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", tmp_path)
|
||||
(tmp_path / "intel.icd").write_text("libintelocl.so\n")
|
||||
assert stream._opencl_present() is True
|
||||
|
||||
def test_false_when_the_dir_is_empty(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", tmp_path)
|
||||
assert stream._opencl_present() is False
|
||||
|
||||
def test_false_and_no_raise_when_the_dir_is_missing(self, tmp_path, monkeypatch):
|
||||
# A missing dir: Path.glob yields nothing (no raise) → False.
|
||||
monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", tmp_path / "nope")
|
||||
assert stream._opencl_present() is False
|
||||
|
||||
def test_false_when_glob_raises_oserror(self, monkeypatch):
|
||||
# The `except OSError` guard proper: an unreadable ICD dir (permission denied) makes glob RAISE,
|
||||
# not return empty. That must be swallowed to False, never propagated up through _select_encode
|
||||
# into start_session (which would crash HDR routing).
|
||||
class _BoomDir:
|
||||
def glob(self, pattern):
|
||||
raise OSError("permission denied")
|
||||
|
||||
monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", _BoomDir())
|
||||
assert stream._opencl_present() is False
|
||||
|
||||
|
||||
class TestWaitForProcFastFail:
|
||||
"""wait_for(proc=…) must stop as soon as the ffmpeg proc exits (a VAAPI init refusal dies in ~1s
|
||||
producing nothing) instead of blocking the whole timeout — that fast-fail is what keeps the HW→SW
|
||||
fallback from turning into a 25s stall. Exercises the REAL wait_for (every other test monkeypatches
|
||||
it wholesale, so this branch would otherwise never run)."""
|
||||
|
||||
class _DeadProc:
|
||||
def poll(self):
|
||||
return 1 # already exited non-zero
|
||||
|
||||
def test_returns_true_when_the_segment_exists_despite_a_dead_proc(self, tmp_path):
|
||||
seg = tmp_path / "seg_0.ts"
|
||||
seg.write_bytes(b"x")
|
||||
# The proc exited but DID write a first segment → ready. (The final existence re-check.)
|
||||
assert stream.wait_for(seg, timeout=5.0, proc=self._DeadProc()) is True
|
||||
|
||||
def test_returns_false_fast_when_a_dead_proc_wrote_nothing(self, tmp_path):
|
||||
# Missing segment + dead proc → give up immediately, not after `timeout`. Assert it's fast by
|
||||
# using a long timeout the test would visibly hang on if the fast-fail branch regressed.
|
||||
seg = tmp_path / "seg_0.ts"
|
||||
assert stream.wait_for(seg, timeout=30.0, proc=self._DeadProc()) is False
|
||||
|
||||
def test_empty_segment_from_a_dead_proc_is_not_ready(self, tmp_path):
|
||||
# A zero-byte segment is not a real segment (size > 0 required).
|
||||
seg = tmp_path / "seg_0.ts"
|
||||
seg.write_bytes(b"")
|
||||
assert stream.wait_for(seg, timeout=30.0, proc=self._DeadProc()) is False
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""The sliding-window limiter behind /auth/register, password-login, password-reset and demo.
|
||||
|
||||
Pure logic, no DB. `reset()` in particular is only ever called by a test fixture (conftest clears
|
||||
the module-level limiters between tests), so without a test here a refactor could turn it into a
|
||||
no-op and nothing would notice until an unrelated test started failing with a 429.
|
||||
"""
|
||||
|
||||
from app import ratelimit
|
||||
from app.ratelimit import RateLimiter
|
||||
|
||||
|
||||
def test_allow_trips_at_the_cap_and_stays_tripped():
|
||||
lim = RateLimiter(max_events=2, window_seconds=300)
|
||||
|
||||
assert lim.allow("ip") is True
|
||||
assert lim.allow("ip") is True
|
||||
assert lim.allow("ip") is False
|
||||
assert lim.allow("ip") is False
|
||||
|
||||
|
||||
def test_hammering_while_blocked_does_not_push_the_window_forward(monkeypatch):
|
||||
# A blocked attempt must not be recorded, otherwise someone who keeps retrying holds their own
|
||||
# window open forever — an indefinite lockout for anyone sharing that IP. Only observable across
|
||||
# the window boundary, so drive the clock: two attempts fill the cap, ten blocked retries land
|
||||
# just before it expires, and the caller must still be let back in on the far side.
|
||||
clock = {"t": 1000.0}
|
||||
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: clock["t"])
|
||||
lim = RateLimiter(max_events=2, window_seconds=60)
|
||||
|
||||
assert lim.allow("ip") is True
|
||||
assert lim.allow("ip") is True
|
||||
for _ in range(10):
|
||||
clock["t"] += 5 # still inside the window; every one of these is refused
|
||||
assert lim.allow("ip") is False
|
||||
|
||||
clock["t"] = 1000.0 + 61 # past the window of the two attempts that actually counted
|
||||
assert lim.allow("ip") is True
|
||||
|
||||
|
||||
def test_keys_are_independent():
|
||||
lim = RateLimiter(max_events=1, window_seconds=300)
|
||||
|
||||
assert lim.allow("a") is True
|
||||
assert lim.allow("a") is False
|
||||
assert lim.allow("b") is True
|
||||
|
||||
|
||||
def test_reset_clears_every_key():
|
||||
lim = RateLimiter(max_events=1, window_seconds=300)
|
||||
lim.allow("a")
|
||||
lim.allow("b")
|
||||
assert lim.allow("a") is False
|
||||
|
||||
lim.reset()
|
||||
|
||||
assert lim.allow("a") is True
|
||||
assert lim.allow("b") is True
|
||||
|
||||
|
||||
def test_expired_attempts_fall_out_of_the_window():
|
||||
# A zero-length window means every previous attempt is already outside it.
|
||||
lim = RateLimiter(max_events=1, window_seconds=0)
|
||||
|
||||
assert lim.allow("ip") is True
|
||||
assert lim.allow("ip") is True
|
||||
@@ -0,0 +1,90 @@
|
||||
"""What a dead email link says, and what it costs to ask (E9.5 S4 follow-up), DB-lane.
|
||||
|
||||
Reported 2026-09-05: delete an account, then open its password-reset link. The card drew a full
|
||||
"set a new password" form — because a `#reset=` fragment was present, nothing more — and only after
|
||||
the user typed a password did the server answer 400. No security hole (the account's tokens cascade
|
||||
away with it, so nothing can be set on a deleted or a re-created account), but a dead end of exactly
|
||||
the kind this epic exists to remove.
|
||||
|
||||
The fix is a check that does NOT spend the token, so the pre-flight can never cost the user their
|
||||
one real attempt. That is the property most worth pinning here.
|
||||
"""
|
||||
|
||||
from app.routes.auth import RESET_TTL, _issue_token
|
||||
|
||||
_PW = "correct horse battery"
|
||||
_NEW = "brand new password"
|
||||
|
||||
|
||||
def _issue(db, user):
|
||||
raw = _issue_token(db, user, "reset", RESET_TTL)
|
||||
db.commit()
|
||||
return raw
|
||||
|
||||
|
||||
def test_checking_a_live_link_does_not_spend_it(client, db, make_user):
|
||||
raw = _issue(db, make_user(email="live@example.com", password=_PW))
|
||||
|
||||
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": True}
|
||||
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": True}
|
||||
|
||||
# The whole point: after any number of checks the one real attempt is still there.
|
||||
assert client.post(
|
||||
"/auth/password-reset/confirm", json={"token": raw, "password": _NEW}
|
||||
).json() == {"ok": True}
|
||||
|
||||
|
||||
def test_a_link_whose_account_was_deleted_is_reported_dead(client, db, make_user):
|
||||
user = make_user(email="gone@example.com", password=_PW)
|
||||
raw = _issue(db, user)
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
|
||||
|
||||
# And the confirm agrees, in the structured shape the card renders as a named screen — the two
|
||||
# must never disagree about one token.
|
||||
r = client.post("/auth/password-reset/confirm", json={"token": raw, "password": _NEW})
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == {"code": "reset", "reason": "invalid"}
|
||||
|
||||
|
||||
def test_a_spent_link_is_reported_dead(client, db, make_user):
|
||||
raw = _issue(db, make_user(email="spent@example.com", password=_PW))
|
||||
client.post("/auth/password-reset/confirm", json={"token": raw, "password": _NEW})
|
||||
|
||||
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
|
||||
|
||||
|
||||
def test_a_too_short_password_does_not_burn_the_link(client, db, make_user):
|
||||
raw = _issue(db, make_user(email="short@example.com", password=_PW))
|
||||
|
||||
r = client.post("/auth/password-reset/confirm", json={"token": raw, "password": "short"})
|
||||
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"]["reason"] == "weak_password"
|
||||
# A mistyped password must not cost the link — the length check runs BEFORE the token is spent,
|
||||
# and the card tells the two failures apart by `reason` so only one of them takes the form down.
|
||||
assert client.post(
|
||||
"/auth/password-reset/confirm", json={"token": raw, "password": _NEW}
|
||||
).json() == {"ok": True}
|
||||
|
||||
|
||||
def test_a_dead_verification_link_answers_in_the_same_structured_shape(client):
|
||||
r = client.post("/auth/verify", json={"token": "no-such-token"})
|
||||
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == {"code": "verify", "reason": "invalid"}
|
||||
|
||||
|
||||
def test_the_check_never_reports_a_live_link_dead_when_throttled(
|
||||
client, db, make_user, monkeypatch
|
||||
):
|
||||
from app.routes import auth as auth_routes
|
||||
|
||||
raw = _issue(db, make_user(email="throttled@example.com", password=_PW))
|
||||
monkeypatch.setattr(auth_routes._reset_check_limiter, "allow", lambda _ip: False)
|
||||
|
||||
# Fail OPEN. This check may only ever save a wasted form; a throttled probe that answered "dead"
|
||||
# would lock someone out of a link that works.
|
||||
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": True}
|
||||
@@ -0,0 +1,350 @@
|
||||
"""E9.5 review round — the invariants the epic's own review found unpinned.
|
||||
|
||||
Every test here corresponds to a defect a reviewer could still introduce with the whole suite green:
|
||||
a mail whose body never renders in any test, an activation that only works for the exact spelling, a
|
||||
"we already wrote to them" stamp for a mail that was dropped, an env allow-list that answers a
|
||||
different question than the gate it backs, and the address pre-emption that E9.5 S1's argument
|
||||
covered only while the address stayed unverified.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import auth as auth_core
|
||||
from app import email as email_mod
|
||||
from app import mailtext
|
||||
from app.config import settings
|
||||
from app.models import AuthToken, Invite, User
|
||||
from app.routes import auth as auth_routes
|
||||
|
||||
_PW = "correct horse battery"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wire(monkeypatch):
|
||||
"""Capture what `_send` would have put on the wire. Mails are the one surface where a body that
|
||||
never renders in a test is a body that first renders in someone's inbox."""
|
||||
out: list[tuple[list[str], str, str]] = []
|
||||
monkeypatch.setattr(
|
||||
email_mod,
|
||||
"_send",
|
||||
lambda to, subject, body, reply_to=None: out.append((to, subject, body)) is None,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def smtp_on(monkeypatch):
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
|
||||
|
||||
# --- every mail actually renders ------------------------------------------------------------------
|
||||
|
||||
# One call per `send_*`, with arguments of the right shape. The parity test compares HU against EN
|
||||
# and can therefore never catch a template whose placeholders disagree with its CALLER — that
|
||||
# mismatch is a KeyError inside a background task, i.e. a mail that silently never goes out. Three
|
||||
# mailers this epic rewrote (`password_reset`, `role_changed`, `account_unsuspended`) had no test
|
||||
# that executed their `.format()` at all.
|
||||
_EVERY_MAIL = [
|
||||
("send_access_approved", ("to@example.com", "LANG", "https://x/#verify=t", True, 168)),
|
||||
("send_access_approved", ("to@example.com", "LANG", None, True, 24)),
|
||||
("send_access_approved", ("to@example.com", "LANG", None, False, 24)),
|
||||
("send_access_invitation", ("to@example.com", "LANG", "https://x/#verify=t", True, 168)),
|
||||
("send_access_invitation", ("to@example.com", "LANG", None, False, 24)),
|
||||
("send_access_denied", ("to@example.com", "LANG")),
|
||||
("send_role_changed", ("to@example.com", "LANG", "admin", "op@example.com")),
|
||||
("send_role_changed", ("to@example.com", "LANG", "user", None)),
|
||||
("send_account_unsuspended", ("to@example.com", "LANG", "op@example.com")),
|
||||
("send_account_deleted", ("to@example.com", "LANG", None)),
|
||||
("send_account_suspended", ("to@example.com", "LANG", "op@example.com")),
|
||||
("send_account_suspended", ("to@example.com", "LANG", None)),
|
||||
("send_admin_new_request", (["a@example.com"], "LANG", "who@example.com")),
|
||||
("send_verify_email", ("to@example.com", "LANG", "https://x/#verify=t", 24)),
|
||||
("send_password_reset", ("to@example.com", "LANG", "https://x/#reset=t")),
|
||||
("send_test", ("to@example.com", "LANG")),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lang", mailtext.SUPPORTED)
|
||||
@pytest.mark.parametrize("name,args", _EVERY_MAIL, ids=[f"{n}{a[2:3]}" for n, a in _EVERY_MAIL])
|
||||
def test_every_mail_renders_in_every_language(wire, name, args, lang):
|
||||
getattr(email_mod, name)(*[lang if a == "LANG" else a for a in args])
|
||||
|
||||
(_, subject, body) = wire[0]
|
||||
assert subject and body
|
||||
# A template that lost its interpolation renders the brace text verbatim.
|
||||
assert "{" not in subject and "{" not in body
|
||||
|
||||
|
||||
def test_a_weeks_link_is_described_in_days_not_hours(wire):
|
||||
# The S6 approval token lives seven days, and the mail used to say "expires in 168 hours".
|
||||
email_mod.send_access_approved("to@example.com", "en", "https://x/#verify=t", True, 168)
|
||||
assert "7 days" in wire[0][2] and "168" not in wire[0][2]
|
||||
|
||||
email_mod.send_verify_email("to@example.com", "hu", "https://x/#verify=t", 24)
|
||||
assert "24 óra" in wire[1][2]
|
||||
|
||||
|
||||
# --- what a second registration may and may not do -------------------------------------------------
|
||||
|
||||
|
||||
def test_a_later_registration_never_touches_an_existing_password(db, client, smtp_on, wire):
|
||||
"""The rule E9.5's review round 2 put back, having tried the opposite and found it worse.
|
||||
|
||||
"While the address is unverified, the newest registration owns it" looked like a fix for address
|
||||
pre-emption. It is symmetric — an attacker can always be last — so it closed nothing, and it
|
||||
handed an unauthenticated caller the power to overwrite a real registrant's chosen password and
|
||||
delete the confirmation link already sitting in their inbox. On a SEC-10-stripped row it would
|
||||
have installed a password where the whole point is that there is none. Reverted; the owner's
|
||||
recourse is password-reset, which proves the mailbox. Residual logged as SEC-16.
|
||||
"""
|
||||
client.post(
|
||||
"/auth/register", json={"email": "victim@example.com", "password": "the first password"}
|
||||
)
|
||||
db.expire_all()
|
||||
user = db.execute(select(User).where(User.email == "victim@example.com")).scalar_one()
|
||||
before = user.password_hash
|
||||
|
||||
client.post(
|
||||
"/auth/register", json={"email": "victim@example.com", "password": "the second password"}
|
||||
)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(User, user.id).password_hash == before
|
||||
|
||||
|
||||
def test_a_later_registration_leaves_a_link_already_in_flight_alive(db, client, smtp_on, wire):
|
||||
client.post(
|
||||
"/auth/register", json={"email": "victim@example.com", "password": "the first password"}
|
||||
)
|
||||
db.expire_all()
|
||||
user = db.execute(select(User).where(User.email == "victim@example.com")).scalar_one()
|
||||
first = db.execute(select(AuthToken.id).where(AuthToken.user_id == user.id)).scalars().all()
|
||||
# Not just a subset check below: `set() <= set()` is vacuously true, so without this the test
|
||||
# would go green while asserting nothing the day registration stops issuing a token.
|
||||
assert len(first) == 1
|
||||
|
||||
client.post(
|
||||
"/auth/register", json={"email": "victim@example.com", "password": "the second password"}
|
||||
)
|
||||
|
||||
db.expire_all()
|
||||
live = db.execute(select(AuthToken.id).where(AuthToken.user_id == user.id)).scalars().all()
|
||||
# The re-send adds a link; it must not invalidate the one the owner may already be holding.
|
||||
assert set(first) <= set(live) and len(live) == len(first) + 1
|
||||
|
||||
|
||||
def test_a_stripped_account_keeps_its_recovery_stamp(db, client, smtp_on, make_user):
|
||||
"""Prod carries accounts SEC-10 left with no password and a `password_revoked_at` stamp. That
|
||||
stamp is the only reason password-reset still accepts them; an anonymous POST must not be able to
|
||||
clear it, nor to put a password on a row whose whole state is "there is none"."""
|
||||
stripped = make_user(
|
||||
email="stripped@example.com",
|
||||
password=None,
|
||||
email_verified=False,
|
||||
is_active=True,
|
||||
password_revoked_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
client.post(
|
||||
"/auth/register", json={"email": "stripped@example.com", "password": "an attacker password"}
|
||||
)
|
||||
|
||||
db.expire_all()
|
||||
row = db.get(User, stripped.id)
|
||||
assert row.password_hash is None and row.password_revoked_at is not None
|
||||
|
||||
|
||||
def test_a_denied_sibling_is_not_reopened_while_a_pending_row_exists(db, client):
|
||||
# Canonical siblings exist on prod. Picking the denied one reopens it and re-notifies every
|
||||
# admin, putting one person in the queue twice — and the exact-spelling tie-break made that
|
||||
# deterministic rather than merely possible.
|
||||
db.add_all(
|
||||
[
|
||||
Invite(email="pair.one@gmail.com", status="pending"),
|
||||
Invite(email="pairone@gmail.com", status="denied"),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
client.post("/auth/request-access", json={"email": "pairone@gmail.com"})
|
||||
|
||||
db.expire_all()
|
||||
rows = {i.email: i.status for i in db.execute(select(Invite)).scalars().all()}
|
||||
assert rows == {"pair.one@gmail.com": "pending", "pairone@gmail.com": "denied"}
|
||||
|
||||
|
||||
# --- the env bootstrap answers the same question as the gate --------------------------------------
|
||||
|
||||
|
||||
def test_the_env_allow_list_is_matched_canonically(db, monkeypatch):
|
||||
# An operator writing the dotted spelling into ALLOWED_EMAILS was refused when Google answered
|
||||
# with the undotted primary — the 2026-09-03 lockout, on the bootstrap path.
|
||||
monkeypatch.setattr(settings, "allowed_emails", "first.last@gmail.com")
|
||||
|
||||
assert auth_core.is_allowed(db, "firstlast@gmail.com") is True
|
||||
assert auth_core.is_allowed(db, "first.last+tag@gmail.com") is True
|
||||
assert auth_core.is_allowed(db, "someone.else@gmail.com") is False
|
||||
|
||||
|
||||
def test_an_env_granted_address_cannot_be_shadowed_by_a_stranger(db, client, monkeypatch):
|
||||
# `is_allowed` short-circuits on ANY invite row for the canonical form, so before this an
|
||||
# unauthenticated caller could request access under a sibling spelling and permanently shadow an
|
||||
# env grant. With the env list matched canonically the request never opens a row.
|
||||
monkeypatch.setattr(settings, "allowed_emails", "bob@gmail.com")
|
||||
|
||||
client.post("/auth/request-access", json={"email": "b.ob@gmail.com"})
|
||||
|
||||
assert db.execute(select(Invite)).scalars().all() == []
|
||||
assert auth_core.is_allowed(db, "bob@gmail.com") is True
|
||||
|
||||
|
||||
def test_a_dotted_admin_emails_entry_still_bootstraps_its_admin(monkeypatch):
|
||||
monkeypatch.setattr(settings, "admin_emails", "the.operator+siftlode@gmail.com")
|
||||
|
||||
assert "theoperator@gmail.com" in auth_core.env_admin_canonicals()
|
||||
|
||||
|
||||
# --- approving decides about a MAILBOX, not a spelling --------------------------------------------
|
||||
|
||||
|
||||
def test_approving_one_spelling_activates_the_account_registered_under_another(
|
||||
db, make_user, as_user, smtp_on, wire
|
||||
):
|
||||
# The 2026-09-03 incident in one test: the admin approves the address as typed into the request,
|
||||
# and the account exists under the other Gmail spelling of the same mailbox.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
user = make_user(email="dotless@gmail.com", password=_PW, is_active=False)
|
||||
user.email = "dotless@gmail.com"
|
||||
inv = Invite(email="dot.less@gmail.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/approve")
|
||||
|
||||
assert r.status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(User, user.id).is_active is True
|
||||
|
||||
|
||||
def test_deleting_one_of_two_accounts_leaves_the_other_one_allowed(db, make_user, as_user):
|
||||
# Prod carries two accounts for one Gmail mailbox. A canonical invite sweep on delete took the
|
||||
# survivor's grant with it, refusing their next sign-in — this epic's founding bug, self-inflicted.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
doomed = make_user(email="pair.one@gmail.com", password=_PW)
|
||||
make_user(email="pairone@gmail.com", password=_PW)
|
||||
db.add_all([Invite(email="pair.one@gmail.com", status="approved")])
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).delete(f"/api/admin/users/{doomed.id}")
|
||||
|
||||
assert r.status_code == 200
|
||||
assert auth_core.is_allowed(db, "pairone@gmail.com") is True
|
||||
|
||||
|
||||
# --- a button an admin pressed must not report a send that did not happen -------------------------
|
||||
|
||||
|
||||
def test_a_failed_invitation_changes_nothing_the_admin_was_told_about(
|
||||
db, make_user, as_user, smtp_on, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(email_mod, "send_access_invitation", lambda *a, **kw: False)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
stranger = make_user(
|
||||
email="stranger@example.com", password=_PW, email_verified=False, is_active=False
|
||||
)
|
||||
inv = Invite(email="stranger@example.com", status="approved")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite", json={"lang": "en"})
|
||||
|
||||
assert r.status_code == 422 and r.json()["detail"]["reason"] == "send_failed"
|
||||
db.expire_all()
|
||||
# The column exists to answer "did I already write to them?", and the UI dims the button and
|
||||
# warns about a second mail on the strength of it. A dropped send must not make it say yes.
|
||||
assert db.get(Invite, inv.id).invited_at is None
|
||||
# And a request the UI reports as a failure must not have let somebody in behind the admin's
|
||||
# back — the activation belongs after a mail that actually left.
|
||||
assert db.get(User, stranger.id).is_active is False
|
||||
|
||||
|
||||
def test_a_failed_resend_says_so_instead_of_reporting_ok(
|
||||
db, make_user, as_user, smtp_on, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(email_mod, "send_verify_email", lambda *a, **kw: False)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
stuck = make_user(email="stuck@example.com", password=_PW, email_verified=False)
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/users/{stuck.id}/resend-verification")
|
||||
|
||||
assert r.status_code == 422 and r.json()["detail"]["reason"] == "send_failed"
|
||||
|
||||
|
||||
# --- refusals and links that are already dead ------------------------------------------------------
|
||||
|
||||
|
||||
def test_an_expired_reset_link_is_reported_dead_without_being_spent(db, client, make_user):
|
||||
# The endpoint exists because a spent or stale link used to draw the full "set a new password"
|
||||
# form. Expiry is the reason its docstring gives, and it was the one case no test covered.
|
||||
user = make_user(email="late@example.com", password=_PW)
|
||||
raw = auth_routes._issue_token(db, user, "reset", timedelta(hours=1))
|
||||
token = db.execute(select(AuthToken).where(AuthToken.user_id == user.id)).scalar_one()
|
||||
token.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||
db.commit()
|
||||
|
||||
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
|
||||
r = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": raw, "password": "a new one 123"}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_a_verify_token_cannot_pass_as_a_reset_link(db, client, make_user):
|
||||
user = make_user(email="mixed@example.com", password=_PW)
|
||||
raw = auth_routes._issue_token(db, user, "verify", timedelta(hours=1))
|
||||
|
||||
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
|
||||
|
||||
|
||||
def test_a_throttled_login_says_nothing_about_the_account(db, client, make_user, monkeypatch):
|
||||
# The two refusals that answer BEFORE the password is proven must stay byte-identical for a real
|
||||
# account and an unknown one; only `invalid` was pinned.
|
||||
make_user(email="real@example.com", password=_PW)
|
||||
monkeypatch.setattr(auth_routes._login_limiter, "allow", lambda key: False)
|
||||
|
||||
real = client.post("/auth/password-login", json={"email": "real@example.com", "password": _PW})
|
||||
unknown = client.post(
|
||||
"/auth/password-login", json={"email": "nobody@example.com", "password": _PW}
|
||||
)
|
||||
|
||||
assert real.status_code == unknown.status_code == 429
|
||||
assert real.json() == unknown.json() == {"detail": {"code": "login", "reason": "rate_limited"}}
|
||||
|
||||
|
||||
# --- admins hear about a request in their own language --------------------------------------------
|
||||
|
||||
|
||||
def test_two_admins_two_languages_two_mails(db, client, make_user, smtp_on, wire):
|
||||
make_user(email="hu.admin@example.com", role="admin", preferences={"language": "hu"})
|
||||
make_user(email="en.admin@example.com", role="admin", preferences={"language": "en"})
|
||||
|
||||
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "en"})
|
||||
|
||||
notices = [w for w in wire if "asking@example.com" in w[1]]
|
||||
assert len(notices) == 2
|
||||
by_recipient = {w[0][0]: w[1] for w in notices}
|
||||
assert by_recipient["hu.admin@example.com"] == mailtext.MAIL_TEXT["admin_new_request.subject"][
|
||||
"hu"
|
||||
].format(requester="asking@example.com")
|
||||
assert by_recipient["en.admin@example.com"] == mailtext.MAIL_TEXT["admin_new_request.subject"][
|
||||
"en"
|
||||
].format(requester="asking@example.com")
|
||||
|
||||
|
||||
def test_an_env_admin_without_an_account_is_written_to_in_the_default(db, monkeypatch):
|
||||
monkeypatch.setattr(settings, "admin_emails", "bootstrap@example.com")
|
||||
|
||||
assert auth_routes.admin_notify_groups(db) == {mailtext.DEFAULT: ["bootstrap@example.com"]}
|
||||
@@ -0,0 +1,280 @@
|
||||
"""E12 · S5 — the admin-issued set-password link.
|
||||
|
||||
One mechanism answering three things: SEC-15 (a no-SMTP instance auto-verifies every registration, so
|
||||
a pre-empted address cannot be recovered by mail), SEC-16 (a pre-empted address stays takeable until
|
||||
its owner resets, and reset needs working mail), and the everyday "no SMTP and someone forgot theirs".
|
||||
|
||||
The property that must not regress is the one that makes it honest: an admin-issued link proves
|
||||
NOTHING about the mailbox — the admin hands it over by whatever means they like — so it must not
|
||||
confirm the address the way a mailed reset does.
|
||||
"""
|
||||
|
||||
from app.models import AuthToken
|
||||
|
||||
|
||||
def _link(client_admin, user_id: int):
|
||||
r = client_admin.post(f"/api/admin/users/{user_id}/set-password-link")
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["url"].split("#reset=", 1)[1]
|
||||
|
||||
|
||||
def test_the_link_sets_a_password_without_confirming_the_address(db, as_user, make_user, client):
|
||||
"""The whole point of a separate token kind. A mailed reset arrives IN the mailbox, so completing
|
||||
it proves ownership and confirms the address. An admin-issued link does not, and claiming
|
||||
otherwise would let an admin confirm an address nobody ever proved."""
|
||||
admin = make_user(role="admin")
|
||||
target = make_user(email_verified=False)
|
||||
db.commit()
|
||||
|
||||
token = _link(as_user(admin), target.id)
|
||||
r = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "a brand new passphrase"}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
db.refresh(target)
|
||||
assert target.password_hash is not None
|
||||
assert target.email_verified is False # the address is still unproven — and says so
|
||||
|
||||
|
||||
def test_a_mailed_reset_still_confirms_the_address(db, as_user, make_user, client):
|
||||
"""The mutation check for the test above: if `email_verified` were simply never set, this would
|
||||
fail — and the rescue path for "registered but never clicked the link" would be gone."""
|
||||
from datetime import timedelta
|
||||
|
||||
from app.routes.auth import _issue_token
|
||||
|
||||
user = make_user(email_verified=False)
|
||||
db.commit()
|
||||
token = _issue_token(db, user, "reset", timedelta(hours=1))
|
||||
|
||||
r = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "a brand new passphrase"}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
db.refresh(user)
|
||||
assert user.email_verified is True
|
||||
|
||||
|
||||
def test_using_the_link_evicts_whoever_held_the_old_password(db, as_user, make_user, client):
|
||||
"""SEC-16's actual remedy: the squatter's session dies the moment the real owner sets a password.
|
||||
The epoch bump is what does it, and it is the reason this reuses the reset confirm path rather
|
||||
than writing a second one that could forget to bump."""
|
||||
target = make_user()
|
||||
db.commit()
|
||||
before = target.session_epoch
|
||||
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
token = _link(as_user(admin), target.id)
|
||||
client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "the owner's own choice"}
|
||||
)
|
||||
|
||||
db.refresh(target)
|
||||
assert target.session_epoch != before
|
||||
|
||||
|
||||
def test_issuing_does_not_touch_the_current_password(db, as_user, make_user):
|
||||
"""An admin misclick must not lock anyone out. Issuing is an offer; only USING it changes
|
||||
anything — which is also why it is safe to offer on a perfectly healthy account."""
|
||||
target = make_user()
|
||||
db.commit()
|
||||
original = target.password_hash
|
||||
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
_link(as_user(admin), target.id)
|
||||
|
||||
db.refresh(target)
|
||||
assert target.password_hash == original
|
||||
assert target.password_revoked_at is None
|
||||
|
||||
|
||||
def test_the_link_is_single_use(db, as_user, make_user, client):
|
||||
target = make_user()
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
|
||||
token = _link(as_user(admin), target.id)
|
||||
first = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "first passphrase here"}
|
||||
)
|
||||
second = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "second passphrase here"}
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 400
|
||||
|
||||
|
||||
def test_a_new_link_burns_the_outstanding_ones(db, as_user, make_user, client):
|
||||
"""Spending any password token burns the siblings of BOTH kinds. Without that, a spent reset
|
||||
would leave a live admin link behind — a second key to a door someone just changed the lock on."""
|
||||
target = make_user()
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
|
||||
first = _link(as_user(admin), target.id)
|
||||
second = _link(as_user(admin), target.id)
|
||||
|
||||
ok = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": second, "password": "the newer passphrase"}
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
stale = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": first, "password": "the older passphrase"}
|
||||
)
|
||||
assert stale.status_code == 400
|
||||
|
||||
|
||||
def test_the_probe_recognises_an_admin_link(db, as_user, make_user, client):
|
||||
"""`/auth/password-reset/check` is what stops the card drawing a form for a dead link. It peeked
|
||||
only at `reset` tokens, so without teaching it the new kind every admin-issued link would open
|
||||
onto "this link is spent"."""
|
||||
target = make_user()
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
|
||||
token = _link(as_user(admin), target.id)
|
||||
r = client.post("/auth/password-reset/check", json={"token": token})
|
||||
assert r.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_only_an_admin_can_mint_one(db, as_user, make_user):
|
||||
plain = make_user()
|
||||
victim = make_user()
|
||||
db.commit()
|
||||
|
||||
r = as_user(plain).post(f"/api/admin/users/{victim.id}/set-password-link")
|
||||
assert r.status_code in (401, 403)
|
||||
assert db.query(AuthToken).filter(AuthToken.kind == "setpw").count() == 0
|
||||
|
||||
|
||||
def test_the_shared_demo_account_is_refused(db, as_user, make_user):
|
||||
"""The demo account is shared, so handing anyone a way to change its password hands them
|
||||
everyone else's session on it."""
|
||||
admin = make_user(role="admin")
|
||||
demo = make_user(is_demo=True)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/users/{demo.id}/set-password-link")
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"]["reason"] == "demo"
|
||||
|
||||
|
||||
def test_the_access_panel_reports_the_gates(db, as_user, make_user):
|
||||
admin = make_user(role="admin")
|
||||
target = make_user(email_verified=False, is_active=False)
|
||||
db.commit()
|
||||
|
||||
body = as_user(admin).get(f"/api/admin/users/{target.id}/access").json()
|
||||
assert body["email_verified"] is False
|
||||
assert body["is_active"] is False
|
||||
assert set(body) >= {
|
||||
"is_allowed",
|
||||
"can_activate",
|
||||
"email_configured",
|
||||
"password_revoked_at",
|
||||
"google_email",
|
||||
"has_password",
|
||||
"has_google",
|
||||
}
|
||||
|
||||
|
||||
def test_the_panel_names_a_google_address_only_when_it_differs(db, as_user, make_user):
|
||||
""" "Connected" and "connected as someone else" are different statements, and only the second one
|
||||
explains a confusing sign-in. Repeating the account's own address would be noise on every row."""
|
||||
admin = make_user(role="admin")
|
||||
same = make_user(email="same@example.com")
|
||||
same.google_email = "same@example.com"
|
||||
other = make_user(email="typed@example.com")
|
||||
other.google_email = "google-primary@example.com"
|
||||
db.commit()
|
||||
|
||||
c = as_user(admin)
|
||||
assert c.get(f"/api/admin/users/{same.id}/access").json()["google_email"] is None
|
||||
assert (
|
||||
c.get(f"/api/admin/users/{other.id}/access").json()["google_email"]
|
||||
== "google-primary@example.com"
|
||||
)
|
||||
|
||||
|
||||
def test_the_link_is_audited_without_recording_the_link(db, as_user, make_user):
|
||||
"""The audit log is read by humans and kept long; a live credential-setting link is the last
|
||||
thing that belongs in it. That one was ISSUED, by whom, for whom, is the auditable fact."""
|
||||
from app.models import AuditLog
|
||||
|
||||
admin = make_user(role="admin")
|
||||
target = make_user()
|
||||
db.commit()
|
||||
|
||||
url = as_user(admin).post(f"/api/admin/users/{target.id}/set-password-link").json()["url"]
|
||||
secret = url.split("#reset=", 1)[1]
|
||||
|
||||
rows = db.query(AuditLog).filter(AuditLog.action == "user_setpw_link").all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].summary == target.email
|
||||
assert secret not in str(rows[0].summary) + str(rows[0].before) + str(rows[0].after)
|
||||
|
||||
|
||||
def test_a_google_only_account_can_be_given_a_password(db, as_user, make_user, client):
|
||||
"""Deliberately not gated on the account's state: the admin's real case is whichever one it is,
|
||||
and narrowing this would only make it refuse exactly then."""
|
||||
admin = make_user(role="admin")
|
||||
google_only = make_user(password=None)
|
||||
db.commit()
|
||||
assert google_only.password_hash is None
|
||||
|
||||
token = _link(as_user(admin), google_only.id)
|
||||
r = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": token, "password": "now it has one too"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
db.refresh(google_only)
|
||||
assert google_only.password_hash is not None
|
||||
|
||||
|
||||
def test_an_unknown_user_is_a_404(db, as_user, make_user):
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
assert as_user(admin).post("/api/admin/users/999999/set-password-link").status_code == 404
|
||||
assert as_user(admin).get("/api/admin/users/999999/access").status_code == 404
|
||||
|
||||
|
||||
def test_the_issued_token_is_stored_hashed(db, as_user, make_user):
|
||||
"""Same rule as every other token here: the DB holds a hash, so a database read cannot hand
|
||||
someone a working link."""
|
||||
admin = make_user(role="admin")
|
||||
target = make_user()
|
||||
db.commit()
|
||||
|
||||
raw = _link(as_user(admin), target.id)
|
||||
rows = db.query(AuthToken).filter(AuthToken.kind == "setpw").all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].token_hash != raw
|
||||
assert raw not in rows[0].token_hash
|
||||
|
||||
|
||||
def test_the_link_outlives_the_one_hour_reset_window(db, as_user, make_user):
|
||||
"""It travels out of band — the admin has to actually reach the person — so the mailed reset's
|
||||
one hour is far too short."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
admin = make_user(role="admin")
|
||||
target = make_user()
|
||||
db.commit()
|
||||
|
||||
_link(as_user(admin), target.id)
|
||||
row = db.query(AuthToken).filter(AuthToken.kind == "setpw").one()
|
||||
assert row.expires_at - datetime.now(timezone.utc) > timedelta(hours=2)
|
||||
|
||||
|
||||
def test_users_listing_still_serializes(db, as_user, make_user):
|
||||
"""The list gained two fields; this pins that it did not gain the two per-row QUERIES the panel
|
||||
endpoint exists to keep out of it."""
|
||||
admin = make_user(role="admin")
|
||||
db.commit()
|
||||
rows = as_user(admin).get("/api/admin/users").json()
|
||||
assert rows and "password_revoked_at" in rows[0] and "google_email" in rows[0]
|
||||
assert "can_activate" not in rows[0]
|
||||
@@ -0,0 +1,99 @@
|
||||
"""SEC-12 · the download URL guard.
|
||||
|
||||
The Download Center hands a user-supplied URL to yt-dlp on the worker, so the server fetches
|
||||
whatever the user typed. These pin that a URL resolving into our own network is refused, that the
|
||||
refusal reasons stay distinct (an unresolvable host is not the same message as a blocked one), and
|
||||
that a normal public URL still goes through.
|
||||
|
||||
DNS is stubbed rather than live: the point under test is the address JUDGEMENT, and a test that
|
||||
resolves real names would be slow, offline-fragile, and would silently start passing if the guard
|
||||
stopped resolving at all.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.downloads import urlguard
|
||||
|
||||
|
||||
def _stub_dns(monkeypatch, addrs):
|
||||
"""Make every lookup answer with `addrs` (a list of textual IPs)."""
|
||||
|
||||
def fake_getaddrinfo(host, port, *a, **kw):
|
||||
return [(None, None, None, "", (addr, port or 0)) for addr in addrs]
|
||||
|
||||
monkeypatch.setattr(urlguard.socket, "getaddrinfo", fake_getaddrinfo)
|
||||
|
||||
|
||||
def _blocked_reason(url):
|
||||
with pytest.raises(urlguard.BlockedUrl) as e:
|
||||
urlguard.check_url(url)
|
||||
return e.value.reason
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"addr",
|
||||
[
|
||||
"127.0.0.1", # loopback
|
||||
"10.1.2.3", # RFC1918
|
||||
"192.168.0.5", # RFC1918
|
||||
"172.16.9.9", # RFC1918
|
||||
"169.254.169.254", # link-local — the cloud metadata endpoint
|
||||
"0.0.0.0", # unspecified
|
||||
"::1", # IPv6 loopback
|
||||
"fe80::1", # IPv6 link-local
|
||||
"fc00::1", # IPv6 unique-local
|
||||
"::ffff:127.0.0.1", # loopback wearing an IPv6 hat
|
||||
],
|
||||
)
|
||||
def test_addresses_on_our_own_network_are_refused(monkeypatch, addr):
|
||||
_stub_dns(monkeypatch, [addr])
|
||||
assert _blocked_reason("http://whatever.example/video") == urlguard.REASON_PRIVATE
|
||||
|
||||
|
||||
def test_one_bad_answer_in_a_round_robin_blocks_the_whole_host(monkeypatch):
|
||||
# A host answering with both a public and an internal address must not become a coin flip:
|
||||
# yt-dlp would pick for itself, so the guard refuses if ANY answer is forbidden.
|
||||
_stub_dns(monkeypatch, ["93.184.216.34", "127.0.0.1"])
|
||||
assert _blocked_reason("https://mixed.example/v") == urlguard.REASON_PRIVATE
|
||||
|
||||
|
||||
def test_a_public_address_passes(monkeypatch):
|
||||
_stub_dns(monkeypatch, ["93.184.216.34"])
|
||||
urlguard.check_url("https://example.com/watch?v=abc") # does not raise
|
||||
|
||||
|
||||
def test_unresolvable_is_its_own_reason(monkeypatch):
|
||||
import socket as _socket
|
||||
|
||||
def boom(*a, **kw):
|
||||
raise _socket.gaierror("nope")
|
||||
|
||||
monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom)
|
||||
# Distinct from PRIVATE on purpose: "we couldn't look that up" and "we refuse to fetch that"
|
||||
# are different things to tell a user, and the frontend renders them differently.
|
||||
assert _blocked_reason("https://nx.example/v") == urlguard.REASON_UNRESOLVABLE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", ["file:///etc/passwd", "ftp://host/x", "gopher://host/1"])
|
||||
def test_non_http_schemes_are_refused_before_any_lookup(monkeypatch, url):
|
||||
def boom(*a, **kw): # a scheme rejection must not have resolved anything first
|
||||
raise AssertionError("DNS was consulted for a non-http scheme")
|
||||
|
||||
monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom)
|
||||
assert _blocked_reason(url) == urlguard.REASON_SCHEME
|
||||
|
||||
|
||||
def test_a_url_without_a_host_is_refused():
|
||||
assert _blocked_reason("http:///just/a/path") == urlguard.REASON_NO_HOST
|
||||
|
||||
|
||||
def test_a_malformed_url_is_a_refusal_not_a_crash(monkeypatch):
|
||||
# Both shapes raise ValueError LATE — `urlparse` on an unclosed bracket, `.port` only when the
|
||||
# property is READ — and `.hostname` happily returns first. Unguarded, either turns a user typo
|
||||
# into a 500 that escapes the caller's `except BlockedUrl` and loses the localized detail this
|
||||
# module exists to produce.
|
||||
_stub_dns(monkeypatch, ["93.184.216.34"])
|
||||
assert _blocked_reason("http://example.com:99999/v.mp4") == urlguard.REASON_MALFORMED
|
||||
assert _blocked_reason("http://example.com:abc/v.mp4") == urlguard.REASON_MALFORMED
|
||||
assert _blocked_reason("http://[::1/v.mp4") == urlguard.REASON_MALFORMED
|
||||
assert _blocked_reason("http://[example.com]/v.mp4") == urlguard.REASON_MALFORMED
|
||||
@@ -0,0 +1,249 @@
|
||||
"""One mail, one click — the branching welcome/invitation mail (E9.5 S6), DB-lane.
|
||||
|
||||
The funnel's common ordering makes a person open TWO messages: one saying their access is approved,
|
||||
another asking them to confirm their address. The C option carries the confirmation link inside the
|
||||
mail that is already going out, so the usual case is one mail and one click.
|
||||
|
||||
Also here: the silent no-op that is the most likely explanation for the 2026-09-03 report that a
|
||||
confirmation mail "never arrived". Re-registering an address that already had an unconfirmed account
|
||||
sent nothing at all, while answering with the same cheerful "check your inbox".
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import AuditLog, AuthToken, Invite, User
|
||||
|
||||
_PW = "correct horse battery"
|
||||
|
||||
|
||||
def _capture(monkeypatch, name):
|
||||
"""Record the arguments a given email_mod.send_* was called with."""
|
||||
from app import email as email_mod
|
||||
|
||||
calls: list[tuple] = []
|
||||
monkeypatch.setattr(email_mod, name, lambda *a, **kw: calls.append((a, kw)) or True)
|
||||
return calls
|
||||
|
||||
|
||||
def _smtp_on(monkeypatch):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
|
||||
|
||||
def _approve(client, invite_id):
|
||||
r = client.post(f"/api/admin/invites/{invite_id}/approve")
|
||||
assert r.status_code == 200
|
||||
return r
|
||||
|
||||
|
||||
# --- the approval mail branches ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_an_unconfirmed_account_gets_the_confirmation_link_in_the_approval_mail(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_access_approved")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="waiting@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
make_user(email="waiting@example.com", password=_PW, email_verified=False, is_active=False)
|
||||
|
||||
_approve(as_user(admin), inv.id)
|
||||
|
||||
(email, _lang, link, has_account, hours), _ = sent[0]
|
||||
assert email == "waiting@example.com" and has_account is True
|
||||
assert link and "#verify=" in link
|
||||
# The whole point: this is the ONLY message they now have to open.
|
||||
assert hours == 24 * 7
|
||||
|
||||
|
||||
def test_the_link_in_that_mail_actually_confirms_the_address(db, make_user, as_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_access_approved")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="waiting@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
user = make_user(
|
||||
email="waiting@example.com", password=_PW, email_verified=False, is_active=False
|
||||
)
|
||||
_approve(as_user(admin), inv.id)
|
||||
token = sent[0][0][2].split("#verify=")[1]
|
||||
|
||||
r = as_user(admin).post("/auth/verify", json={"token": token})
|
||||
|
||||
assert r.status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(User, user.id).email_verified is True
|
||||
|
||||
|
||||
def test_that_link_outlives_a_weekend(db, make_user, as_user, monkeypatch):
|
||||
# An approval is not a mail anyone sits waiting for; the standard 24h would routinely expire
|
||||
# before it is read, and the one-mail promise with it.
|
||||
_smtp_on(monkeypatch)
|
||||
_capture(monkeypatch, "send_access_approved")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="waiting@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
user = make_user(
|
||||
email="waiting@example.com", password=_PW, email_verified=False, is_active=False
|
||||
)
|
||||
|
||||
_approve(as_user(admin), inv.id)
|
||||
|
||||
tok = db.execute(select(AuthToken).where(AuthToken.user_id == user.id)).scalars().all()[-1]
|
||||
assert tok.expires_at > datetime.now(timezone.utc) + timedelta(days=6)
|
||||
|
||||
|
||||
def test_an_already_confirmed_account_is_just_told_to_sign_in(db, make_user, as_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_access_approved")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="ready@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
make_user(email="ready@example.com", password=_PW, is_active=False)
|
||||
|
||||
_approve(as_user(admin), inv.id)
|
||||
|
||||
(_, _lang, link, has_account, _), _ = sent[0]
|
||||
assert link is None and has_account is True # no link to offer, and none needed
|
||||
|
||||
|
||||
def test_someone_with_no_account_yet_is_told_a_second_mail_is_coming(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_access_approved")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="stranger@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
_approve(as_user(admin), inv.id)
|
||||
|
||||
(_, _lang, link, has_account, _), _ = sent[0]
|
||||
# Nothing to link to yet. Saying a confirmation mail follows is what makes its ABSENCE
|
||||
# noticeable — the failure that went unnoticed on 2026-09-03.
|
||||
assert link is None and has_account is False
|
||||
|
||||
|
||||
# --- the deliberate invitation --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_adding_an_address_to_the_whitelist_mails_nobody(db, make_user, as_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
approved = _capture(monkeypatch, "send_access_approved")
|
||||
invited = _capture(monkeypatch, "send_access_invitation")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
|
||||
r = as_user(admin).post("/api/admin/invites", json={"email": "maybe@example.com"})
|
||||
|
||||
# Whitelisting opens a door as a possibility — often already agreed in person — and an address
|
||||
# can sit approved for weeks. A mail fired automatically here would be nudging someone who never
|
||||
# asked to be nudged.
|
||||
assert r.status_code == 200 and r.json()["invited_at"] is None
|
||||
assert approved == [] and invited == []
|
||||
|
||||
|
||||
def test_the_admin_can_then_invite_them_on_purpose(db, make_user, as_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
invited = _capture(monkeypatch, "send_access_invitation")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="maybe@example.com", status="approved", decided_by=admin.email)
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
|
||||
|
||||
assert r.status_code == 200 and r.json()["invited_at"] is not None
|
||||
assert invited[0][0][0] == "maybe@example.com"
|
||||
# Recorded on the row, so "did I already write to them?" has an answer where the decision is
|
||||
# made — the wrong answer means mailing a real person twice.
|
||||
db.expire_all()
|
||||
assert db.get(Invite, inv.id).invited_at is not None
|
||||
assert "invite_send" in db.execute(select(AuditLog.action)).scalars().all()
|
||||
|
||||
|
||||
def test_an_undecided_request_cannot_be_invited(db, make_user, as_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="asking@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
|
||||
|
||||
assert r.status_code == 409 and r.json()["detail"]["reason"] == "not_approved"
|
||||
|
||||
|
||||
def test_the_invitation_says_so_when_the_instance_cannot_send_mail(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: False)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="maybe@example.com", status="approved", decided_by=admin.email)
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
|
||||
|
||||
assert r.status_code == 409 and r.json()["detail"]["reason"] == "email_off"
|
||||
db.expire_all()
|
||||
assert db.get(Invite, inv.id).invited_at is None # nothing sent, nothing recorded
|
||||
|
||||
|
||||
# --- the silent register no-op --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registering_again_re_sends_the_link_instead_of_sending_nothing(db, client, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_verify_email")
|
||||
assert (
|
||||
client.post(
|
||||
"/auth/register", json={"email": "twice@example.com", "password": _PW}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert len(sent) == 1
|
||||
|
||||
r = client.post("/auth/register", json={"email": "twice@example.com", "password": _PW})
|
||||
|
||||
# The old behaviour was to return in silence: same cheerful "check your inbox", no mail. That is
|
||||
# the most likely explanation for the 2026-09-03 confirmation mail that "never arrived" — none
|
||||
# was ever sent. The response stays uniform; only the dead end is gone.
|
||||
assert r.status_code == 200 and r.json() == {"status": "ok"}
|
||||
assert len(sent) == 2
|
||||
assert sent[1][0][0] == "twice@example.com"
|
||||
|
||||
|
||||
def test_registering_again_does_not_create_a_second_account(db, client, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
_capture(monkeypatch, "send_verify_email")
|
||||
for _ in range(2):
|
||||
client.post("/auth/register", json={"email": "twice@example.com", "password": _PW})
|
||||
|
||||
db.expire_all()
|
||||
rows = db.execute(select(User).where(User.email == "twice@example.com")).scalars().all()
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
def test_a_confirmed_account_still_gets_nothing(db, client, make_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_verify_email")
|
||||
make_user(email="done@example.com", password=_PW) # already verified
|
||||
|
||||
r = client.post("/auth/register", json={"email": "done@example.com", "password": _PW})
|
||||
|
||||
# There is nothing to confirm, so there is nothing to send — and mailing "confirm your address"
|
||||
# to someone who already did would be a lie.
|
||||
assert r.status_code == 200
|
||||
assert sent == []
|
||||
@@ -53,6 +53,11 @@ services:
|
||||
# Downloaded media. Defaults to a Docker-managed named volume; set DOWNLOAD_HOST_PATH in .env
|
||||
# to a host directory (e.g. one your Plex server reads) to keep the Plex-style tree there.
|
||||
- ${DOWNLOAD_HOST_PATH:-siftlode_downloads}:/downloads
|
||||
# Plex library, read-only. The Plex module streams files itself, so it needs to SEE them: set
|
||||
# PLEX_MEDIA_HOST_PATH in .env to the host directory holding the library (the local side of the
|
||||
# admin "path map", whose default is /data=/plex-media). Left unset it falls back to an empty
|
||||
# named volume — harmless, and the Plex module is off by default.
|
||||
- ${PLEX_MEDIA_HOST_PATH:-siftlode_plex_media}:/plex-media:ro
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
@@ -74,6 +79,9 @@ services:
|
||||
worker:
|
||||
image: git.b1fr0st.eu/peter/siftlode:${IMAGE_TAG:-latest}
|
||||
command: ["python", "-m", "app.worker"]
|
||||
# tini as PID 1: reaps orphaned ffmpeg/yt-dlp grandchildren when the watchdog hard-kills a
|
||||
# download child's process group (the worker reaps its own direct children via subprocess.wait).
|
||||
init: true
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
@@ -93,6 +101,8 @@ services:
|
||||
networks: [internal]
|
||||
volumes:
|
||||
- ${DOWNLOAD_HOST_PATH:-siftlode_downloads}:/downloads
|
||||
# Same read-only Plex library as the API — the worker resolves library paths too.
|
||||
- ${PLEX_MEDIA_HOST_PATH:-siftlode_plex_media}:/plex-media:ro
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
@@ -114,6 +124,8 @@ services:
|
||||
volumes:
|
||||
siftlode_pgdata:
|
||||
siftlode_downloads:
|
||||
# Empty fallback for the Plex media mount when PLEX_MEDIA_HOST_PATH is unset (Plex module off).
|
||||
siftlode_plex_media:
|
||||
|
||||
networks:
|
||||
internal:
|
||||
|
||||
@@ -38,6 +38,11 @@ services:
|
||||
# Downloaded media. Defaults to a Docker-managed named volume; set DOWNLOAD_HOST_PATH in .env
|
||||
# to a host directory (e.g. one your Plex server reads) to keep the Plex-style tree there.
|
||||
- ${DOWNLOAD_HOST_PATH:-downloads}:/downloads
|
||||
# Plex library, read-only. The Plex module streams files itself, so it needs to SEE them: set
|
||||
# PLEX_MEDIA_HOST_PATH in .env to the host directory holding the library (the local side of the
|
||||
# admin "path map", whose default is /data=/plex-media). Left unset it falls back to an empty
|
||||
# named volume — harmless, and the Plex module is off by default.
|
||||
- ${PLEX_MEDIA_HOST_PATH:-plex_media}:/plex-media:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; exit(0 if urllib.request.urlopen('http://localhost:8000/healthz').status == 200 else 1)"]
|
||||
interval: 15s
|
||||
@@ -57,6 +62,9 @@ services:
|
||||
GIT_SHA: ${GIT_SHA:-unknown}
|
||||
BUILD_DATE: ${BUILD_DATE:-}
|
||||
command: ["python", "-m", "app.worker"]
|
||||
# tini as PID 1: reaps orphaned ffmpeg/yt-dlp grandchildren when the watchdog hard-kills a
|
||||
# download child's process group (the worker reaps its own direct children via subprocess.wait).
|
||||
init: true
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD:-siftlode}@db:5432/${POSTGRES_DB:-siftlode}
|
||||
@@ -73,6 +81,8 @@ services:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ${DOWNLOAD_HOST_PATH:-downloads}:/downloads
|
||||
# Same read-only Plex library as the API — the worker resolves library paths too.
|
||||
- ${PLEX_MEDIA_HOST_PATH:-plex_media}:/plex-media:ro
|
||||
restart: unless-stopped
|
||||
|
||||
# PO-token provider sidecar: mints YouTube Proof-of-Origin tokens so the worker beats bot-detection
|
||||
@@ -85,3 +95,5 @@ services:
|
||||
volumes:
|
||||
pgdata:
|
||||
downloads:
|
||||
# Empty fallback for the Plex media mount when PLEX_MEDIA_HOST_PATH is unset (Plex module off).
|
||||
plex_media:
|
||||
|
||||
+188
-7
@@ -115,13 +115,34 @@ DOWNLOAD_HOST_PATH=/mnt/media/youtube
|
||||
|
||||
The directory must be writable by the container user (uid `1000`): `sudo chown -R 1000:1000 <dir>`.
|
||||
|
||||
## Plex library (optional)
|
||||
|
||||
If you run a **Plex** server, Siftlode can browse and play your movies and shows beside your YouTube
|
||||
feed. Two things have to line up:
|
||||
|
||||
1. **Server URL + token**, entered as admin under **Configuration → Plex**. That is how Siftlode reads
|
||||
your libraries.
|
||||
2. **The media files themselves**, mounted into the container read-only. Siftlode streams the files
|
||||
directly rather than proxying Plex, so it has to be able to open them.
|
||||
|
||||
Point `PLEX_MEDIA_HOST_PATH` at the directory holding your library and re-run `up -d`:
|
||||
|
||||
```bash
|
||||
PLEX_MEDIA_HOST_PATH=/mnt/media
|
||||
```
|
||||
|
||||
It appears inside the container at `/plex-media`, read-only. Siftlode then rewrites the paths Plex
|
||||
reports into that mount using the **path map** on the same admin page — its default, `/data=/plex-media`,
|
||||
means "wherever Plex says `/data/movies/…`, look in `/plex-media/movies/…`". Set the left-hand side to
|
||||
whatever prefix your Plex server uses, and mount the matching host directory so the rest of the path
|
||||
still lines up.
|
||||
|
||||
Leave `PLEX_MEDIA_HOST_PATH` unset if you don't use Plex — the mount falls back to an empty volume and
|
||||
the module stays off.
|
||||
|
||||
## Day-to-day
|
||||
|
||||
```bash
|
||||
# Update to the latest release
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
|
||||
# Logs / status (api = web, worker = downloads/edits)
|
||||
docker compose -f docker-compose.selfhost.yml logs -f api
|
||||
docker compose -f docker-compose.selfhost.yml logs -f worker
|
||||
@@ -131,6 +152,166 @@ docker compose -f docker-compose.selfhost.yml ps
|
||||
docker compose -f docker-compose.selfhost.yml down
|
||||
```
|
||||
|
||||
Your data (accounts, subscriptions, playlists, the video catalog) lives in the `siftlode_pgdata`
|
||||
Docker volume — back that up to keep your instance's state. Database migrations run automatically
|
||||
when the app starts, so updating is just pull + up.
|
||||
## Backups
|
||||
|
||||
**A backup is three things, not one.** Copying only the database gets you an instance that starts and
|
||||
then cannot read its own secrets.
|
||||
|
||||
1. **The database** — accounts, subscriptions, playlists, the video catalog, and every setting you
|
||||
made in the admin pages. It lives in the `siftlode_pgdata` Docker volume; dump it with the command
|
||||
below.
|
||||
2. **`.env`** — in particular `TOKEN_ENCRYPTION_KEY`. Your Google client secret, SMTP password, Plex
|
||||
token and every stored refresh token are encrypted with it *inside* the database. Restore a dump
|
||||
against a different key and those values are lost — you would have to clear the affected rows from
|
||||
`system_config` and re-enter every secret by hand. Keep `.env` beside the dump, and just as private.
|
||||
3. **Downloaded media** *(only if you use the Download Center)* — the `siftlode_downloads` volume, or
|
||||
whatever `DOWNLOAD_HOST_PATH` points at. Not needed to bring the instance back; the jobs and their
|
||||
metadata are in the database, the files themselves are not.
|
||||
|
||||
Dump and restore the database with these two commands. They also exist as scripts
|
||||
(`scripts/backup.sh` + `scripts/restore.sh`, or `backup.ps1` / `restore.ps1` on Windows) if you have
|
||||
the source checkout:
|
||||
|
||||
If you customised `POSTGRES_USER` / `POSTGRES_DB` in `.env`, substitute them for `siftlode` below —
|
||||
the scripts read them from the environment, these one-liners cannot.
|
||||
|
||||
```bash
|
||||
# Back up. Write to a .part name and rename only on success: redirecting straight into the final
|
||||
# name creates the file BEFORE pg_dump runs, so a failed dump leaves a plausible-looking 0-byte
|
||||
# file in backups/ that you will not discover until the day you need it.
|
||||
mkdir -p backups && out="backups/siftlode-$(date +%Y%m%d-%H%M%S).dump" && \
|
||||
docker compose -f docker-compose.selfhost.yml exec -T db \
|
||||
pg_dump -U siftlode -Fc siftlode > "$out.part" \
|
||||
&& [ -s "$out.part" ] && mv "$out.part" "$out" && echo "wrote $out" \
|
||||
|| { rm -f "$out.part"; echo "BACKUP FAILED" >&2; false; }
|
||||
|
||||
# Restore. Stop the app first: pg_restore --clean DROPs each object in its own transaction, and a
|
||||
# running api holds locks that make those DROPs queue — abort the wait and you are left with a
|
||||
# half-dropped database. Restoring with the app down also means nothing writes into a schema that
|
||||
# is only partly back.
|
||||
docker compose -f docker-compose.selfhost.yml stop api worker
|
||||
docker compose -f docker-compose.selfhost.yml exec -T db \
|
||||
pg_restore -U siftlode -d siftlode --clean --if-exists < backups/siftlode-<timestamp>.dump
|
||||
|
||||
# Start it again only once the restore reported no errors — and, if you are rolling a release back,
|
||||
# only AFTER pinning the older IMAGE_TAG. Every start runs `alembic upgrade head`, so bringing the
|
||||
# newer image back here would re-apply the migrations you just restored away. Use `up -d`, not
|
||||
# `start`: `start` relaunches the existing container, so it ignores the tag you just pinned and
|
||||
# comes back on the image you are rolling back FROM.
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
On Windows, do **not** pipe the dump through PowerShell — it corrupts binary data. Write it inside the
|
||||
container and copy it out instead. Check the exit code between the two steps and use a timestamped
|
||||
name: the dump inside the container keeps its name, so a `cp` that runs after a *failed* dump would
|
||||
copy out the previous run's file and quietly overwrite your only backup with a stale one.
|
||||
|
||||
Paste it as ONE block. `throw` only aborts the statement it is in, so as separate pasted lines the
|
||||
copy would still run after a failed dump — and copy out the previous run's file:
|
||||
|
||||
```powershell
|
||||
& {
|
||||
# Inside the block, because a scriptblock inherits the caller's preference — under a profile that
|
||||
# sets SilentlyContinue, every `throw` below would be swallowed and the block would run on.
|
||||
$ErrorActionPreference = "Stop"
|
||||
$compose = "docker-compose.selfhost.yml"
|
||||
New-Item -ItemType Directory -Force -Path backups | Out-Null
|
||||
$out = "backups\siftlode-$(Get-Date -Format yyyyMMdd-HHmmss).dump"
|
||||
docker compose -f $compose exec -T db pg_dump -U siftlode -Fc siftlode -f /tmp/s.dump
|
||||
$code = $LASTEXITCODE
|
||||
# Remove the in-container dump on the failure path too: leaving it there is how a LATER failed run
|
||||
# copies out this run's file and overwrites a good backup with a stale one.
|
||||
if ($code -ne 0) { docker compose -f $compose exec -T db rm -f /tmp/s.dump; throw "BACKUP FAILED - pg_dump returned $code" }
|
||||
docker compose -f $compose cp db:/tmp/s.dump $out
|
||||
$code = $LASTEXITCODE
|
||||
docker compose -f $compose exec -T db rm -f /tmp/s.dump
|
||||
# Remove the partial file too: `cp` streams, so a mid-stream failure leaves a truncated dump
|
||||
# under the final name — the plausible-looking bad backup the .part dance above avoids.
|
||||
if ($code -ne 0) { Remove-Item $out -ErrorAction SilentlyContinue; throw "BACKUP FAILED - could not copy the dump out (exit $code)" }
|
||||
if ((Get-Item $out).Length -eq 0) { Remove-Item $out; throw "BACKUP FAILED - the dump came out empty" }
|
||||
"wrote $out"
|
||||
}
|
||||
```
|
||||
|
||||
## Upgrading
|
||||
|
||||
**Take a backup first.** Every start runs any new database migrations automatically, and a migration
|
||||
is not reversible by pulling the old image back — see rollback below.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
That tracks `latest`, which moves whenever a release is cut. **To decide for yourself when you move,
|
||||
pin a version** by putting it in `.env`:
|
||||
|
||||
```bash
|
||||
IMAGE_TAG=0.80.0
|
||||
```
|
||||
|
||||
Then `pull` + `up -d` stays on that version until you change the line. Released versions and what
|
||||
changed in them are listed in the app itself, under **Release Notes** in the account menu, and in
|
||||
[`CHANGELOG.md`](../CHANGELOG.md).
|
||||
|
||||
**The registry keeps only the newest few releases, not every version the changelog lists.** A pin is
|
||||
"decide when you take the next hop", not "stay here forever": once you are several releases behind,
|
||||
your pinned tag can no longer be pulled — which matters the day you rebuild the host, prune your
|
||||
Docker images, or move to a new machine. A container that is already running is unaffected (Docker
|
||||
keeps the image locally), and you can always go back to `latest` or build the image yourself from the
|
||||
repository. If you want to sit on one version long term, keep your own copy:
|
||||
`docker save git.b1fr0st.eu/peter/siftlode:0.80.0 -o siftlode-0.80.0.tar`.
|
||||
|
||||
### Rollback
|
||||
|
||||
If a release misbehaves, set `IMAGE_TAG` back to the previous version and `up -d`. That is enough
|
||||
**as long as the release did not change the database schema**. If it did, the older image will refuse
|
||||
to start against the newer schema.
|
||||
|
||||
**Restoring your pre-upgrade dump is the recommended way out of that** — it puts the database back
|
||||
exactly where the old image expects it, in one step, with no revision arithmetic:
|
||||
|
||||
```bash
|
||||
# 1. stop the app (leave the database up)
|
||||
docker compose -f docker-compose.selfhost.yml stop api worker
|
||||
# 2. restore the dump you took before upgrading (see Backups above). Leave the app stopped:
|
||||
# starting it here would run the NEWER image's migrations against the database you just restored.
|
||||
docker compose -f docker-compose.selfhost.yml exec -T db \
|
||||
pg_restore -U siftlode -d siftlode --clean --if-exists < backups/siftlode-<timestamp>.dump
|
||||
# 3. pin the previous version in .env, then start again
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
If you have no usable dump, you can step the schema back instead — but **not with `alembic downgrade
|
||||
-1`**. One release routinely ships several migrations (0.80.0 shipped four), so `-1` undoes only the
|
||||
newest one and leaves the schema at a revision the older image *also* cannot resolve: you lose that
|
||||
migration's data and still get a crash loop. Downgrade to the revision the target version expects,
|
||||
which that version can tell you itself:
|
||||
|
||||
```bash
|
||||
# 1. stop the app (leave the database up)
|
||||
docker compose -f docker-compose.selfhost.yml stop api worker
|
||||
# 2. ask the version you are rolling back TO which schema revision it expects
|
||||
IMAGE_TAG=0.79.0 docker compose -f docker-compose.selfhost.yml run --rm api alembic heads
|
||||
# 3. step the schema back to exactly that revision — run this with the CURRENT (newer) image,
|
||||
# because it is the one that ships the migrations being undone
|
||||
docker compose -f docker-compose.selfhost.yml run --rm api alembic downgrade <revision from step 2>
|
||||
# 4. pin the previous version in .env, then start again
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
A downgrade drops the columns the newer version added, so anything stored only in those columns is
|
||||
gone. That is the price of not having the dump.
|
||||
|
||||
### A migration failed on start
|
||||
|
||||
The container will keep restarting (`restart: unless-stopped`), and `worker` will sit waiting for an
|
||||
`api` that never becomes healthy. Read the reason first:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml logs api | tail -50
|
||||
```
|
||||
|
||||
Then either fix the cause and let it retry, or roll back: restore your pre-upgrade dump, pin the
|
||||
previous `IMAGE_TAG`, and `up -d`. Do not delete the `siftlode_pgdata` volume to "start clean" unless
|
||||
you have a dump you have actually tested restoring — that volume *is* your instance.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "siftlode-frontend",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+14
-3
@@ -17,6 +17,7 @@ import {
|
||||
import { notify, removeByMetaKind } from "./lib/notifications";
|
||||
import { LS, setAccountRaw } from "./lib/storage";
|
||||
import { usePref } from "./lib/usePref";
|
||||
import { useVerifyEntry } from "./lib/useVerifyEntry";
|
||||
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
||||
import { useChannelLayout } from "./components/ChannelsProvider";
|
||||
@@ -37,6 +38,7 @@ import { StateMessage } from "./components/QueryState";
|
||||
import ErrorDialog from "./components/ErrorDialog";
|
||||
import VersionBanner from "./components/VersionBanner";
|
||||
import DemoBanner from "./components/DemoBanner";
|
||||
import VerifyBanner from "./components/VerifyBanner";
|
||||
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
||||
// The built app version, NOT imported from releaseNotes — importing anything from that module
|
||||
// pulls its whole ~900-line changelog into the eager App chunk and defeats the lazy ReleaseNotes
|
||||
@@ -54,6 +56,11 @@ export default function App() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { page, channelView, ytSearch } = useNavigation();
|
||||
const { setPage, enterYtSearch } = useNavigationActions();
|
||||
// An emailed verification link is confirmed HERE, above the signed-in/signed-out branch below, so
|
||||
// it works in a browser that already holds a session. It used to be handled inside Welcome, which
|
||||
// that branch never renders when signed in — the token was silently dropped. Signed out, the
|
||||
// outcome goes to the auth card's banners; signed in, to the top-of-app bar.
|
||||
const verifyEntry = useVerifyEntry();
|
||||
// The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load
|
||||
// effect + share-link capture all moved there). App reads them to hand to <Feed> and to the
|
||||
// handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
|
||||
@@ -240,7 +247,10 @@ export default function App() {
|
||||
// Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed
|
||||
// history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice.
|
||||
removeByMetaKind("access-requests");
|
||||
const pending = meQuery.data.pending_invites ?? 0;
|
||||
// Both kinds of "someone is waiting on you": a request needing a decision, and a sign-up whose
|
||||
// address was approved before they registered. The second used to produce NO signal at all —
|
||||
// nothing lands in the queue for it — which is how the 2026-09-03 registrant went unnoticed.
|
||||
const pending = (meQuery.data.pending_invites ?? 0) + (meQuery.data.pending_activations ?? 0);
|
||||
if (pending > 0) {
|
||||
notify({
|
||||
level: "warning",
|
||||
@@ -259,7 +269,7 @@ export default function App() {
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [meQuery.data?.role, meQuery.data?.pending_invites]);
|
||||
}, [meQuery.data?.role, meQuery.data?.pending_invites, meQuery.data?.pending_activations]);
|
||||
|
||||
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
|
||||
function changeLanguage(code: LangCode) {
|
||||
@@ -288,7 +298,7 @@ export default function App() {
|
||||
</StateMessage>
|
||||
</div>
|
||||
);
|
||||
if (!meQuery.data) return <Welcome />;
|
||||
if (!meQuery.data) return <Welcome verify={verifyEntry} />;
|
||||
|
||||
// Shown briefly while a lazy page/module chunk loads (first visit to that page only).
|
||||
const pageFallback = (
|
||||
@@ -386,6 +396,7 @@ export default function App() {
|
||||
header={<Header me={meQuery.data!} onYtSearch={enterYtSearch} />}
|
||||
banners={
|
||||
<>
|
||||
<VerifyBanner entry={verifyEntry} />
|
||||
{meQuery.data!.is_demo && <DemoBanner />}
|
||||
<VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Clock, Copy, Globe, KeyRound, Mail, Send, ShieldAlert } from "lucide-react";
|
||||
import Modal from "./Modal";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
|
||||
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
||||
|
||||
// The admin's answer to "why can this person not get in?", and the place every recovery action for
|
||||
// one account lives (E12 · S5, design option C).
|
||||
//
|
||||
// It exists because nothing in the admin UI showed an account's GATE STATE: an admin could see a
|
||||
// "Pending approval" chip but not whether the address was confirmed, whether the password had been
|
||||
// cleared by SEC-10, or which Google account was attached. Every support conversation started by
|
||||
// guessing. The recovery actions sit under that readout rather than in the row, so the admin picks
|
||||
// one having read what is actually wrong.
|
||||
|
||||
/** One line of the Access readout: an icon, a label, and the state in the tone that state deserves. */
|
||||
function AccessRow({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
tone = "muted",
|
||||
}: {
|
||||
icon: typeof Mail;
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "muted" | "warn" | "ok";
|
||||
}) {
|
||||
const toneCls = tone === "warn" ? "text-warn" : tone === "ok" ? "text-success" : "text-muted";
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Icon className={`w-3.5 h-3.5 shrink-0 ${toneCls}`} />
|
||||
<span className="text-muted w-28 shrink-0">{label}</span>
|
||||
<span className={`${toneCls} min-w-0 truncate`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccountPanel({
|
||||
user,
|
||||
onClose,
|
||||
}: {
|
||||
user: AdminUserRow;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [link, setLink] = useState<{ url: string; expires_at: string | null } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: qk.adminUserAccess(user.id),
|
||||
queryFn: () => api.adminUserAccess(user.id),
|
||||
});
|
||||
|
||||
// Every action here goes through a `quiet: true` endpoint, which suppresses the GLOBAL error
|
||||
// dialog on the promise that the caller reports the failure inline. Without an `onError` that
|
||||
// promise is broken and a refusal renders as nothing at all — on the broken-SMTP instance this
|
||||
// panel exists for, the admin would read silence as "the mail went out".
|
||||
const issue = useMutation({
|
||||
mutationFn: () => api.createSetPasswordLink(user.id),
|
||||
onSuccess: (r) => setLink({ url: r.url, expires_at: r.expires_at }),
|
||||
onError: () => notify({ level: "error", message: t("users.account.linkFailed") }),
|
||||
});
|
||||
|
||||
const resend = useMutation({
|
||||
mutationFn: () => api.resendUserVerification(user.id),
|
||||
onSuccess: () => notify({ level: "success", message: t("users.account.resendSent") }),
|
||||
onError: (err) =>
|
||||
notify({
|
||||
level: "error",
|
||||
message: isEmailOff(err)
|
||||
? t("users.pendingAccess.resendEmailOff")
|
||||
: t("users.pendingAccess.resendFailed"),
|
||||
}),
|
||||
});
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: () => api.activateUser(user.id),
|
||||
onSuccess: () => invalidateUserAccess(qc, user.id),
|
||||
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
|
||||
});
|
||||
|
||||
const copy = async () => {
|
||||
if (!link) return;
|
||||
// Via the shared helper, whose synchronous `execCommand` path is what makes copying work on a
|
||||
// plain-http LAN origin — where `navigator.clipboard` is undefined, and which is exactly the
|
||||
// minimal self-host this no-email link was built for.
|
||||
if (await copyText(link.url)) {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
return;
|
||||
}
|
||||
// The link is on screen and selectable, so a failed copy is a convenience that failed — not the
|
||||
// action failing. Say so rather than leaving the button looking inert.
|
||||
notify({ level: "warning", message: t("users.account.copyFailed") });
|
||||
};
|
||||
|
||||
const a: AdminUserAccess | undefined = q.data;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={user.display_name ?? user.email.split("@")[0]}
|
||||
onClose={onClose}
|
||||
placement="drawer"
|
||||
maxWidth="max-w-md"
|
||||
>
|
||||
<div className="text-2xs text-muted truncate -mt-2 mb-4">{user.email}</div>
|
||||
|
||||
{q.isError && !a ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : !a ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
|
||||
{t("users.account.access")}
|
||||
</div>
|
||||
<div className="glass-card rounded-xl p-3 space-y-2 mb-5">
|
||||
<AccessRow
|
||||
icon={Mail}
|
||||
label={t("users.account.email")}
|
||||
value={
|
||||
a.email_verified
|
||||
? t("users.account.emailConfirmed")
|
||||
: t("users.account.emailUnconfirmed")
|
||||
}
|
||||
tone={a.email_verified ? "ok" : "warn"}
|
||||
/>
|
||||
<AccessRow
|
||||
icon={Clock}
|
||||
label={t("users.account.approval")}
|
||||
value={
|
||||
a.is_active
|
||||
? t("users.account.approvalDone")
|
||||
: a.can_activate
|
||||
? t("users.account.approvalWaitingOnYou")
|
||||
: t("users.account.approvalNotRequested")
|
||||
}
|
||||
tone={a.is_active ? "ok" : "warn"}
|
||||
/>
|
||||
<AccessRow
|
||||
icon={KeyRound}
|
||||
label={t("users.account.password")}
|
||||
value={
|
||||
a.has_password
|
||||
? t("users.account.passwordSet")
|
||||
: a.password_revoked_at
|
||||
? t("users.account.passwordRevoked")
|
||||
: t("users.account.passwordNone")
|
||||
}
|
||||
// Not having a password is only a PROBLEM when it was taken away; a Google-only
|
||||
// account never had one and is working exactly as intended.
|
||||
tone={a.has_password ? "ok" : a.password_revoked_at ? "warn" : "muted"}
|
||||
/>
|
||||
<AccessRow
|
||||
icon={Globe}
|
||||
label={t("users.account.google")}
|
||||
value={
|
||||
!a.has_google
|
||||
? t("users.account.googleNone")
|
||||
: a.google_email
|
||||
? t("users.account.googleAs", { email: a.google_email })
|
||||
: t("users.account.googleConnected")
|
||||
}
|
||||
tone={a.has_google ? "ok" : "muted"}
|
||||
/>
|
||||
{/* The gate that explains a Google sign-in silently becoming a fresh access request. */}
|
||||
{!a.is_allowed && (
|
||||
<AccessRow
|
||||
icon={ShieldAlert}
|
||||
label={t("users.account.invite")}
|
||||
value={t("users.account.inviteMissing")}
|
||||
tone="warn"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
|
||||
{t("users.account.recovery")}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{a.can_activate && (
|
||||
<button
|
||||
onClick={() => activate.mutate()}
|
||||
disabled={activate.isPending}
|
||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
|
||||
>
|
||||
<Check className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 text-left">{t("users.account.activate")}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => resend.mutate()}
|
||||
disabled={!a.email_configured || a.email_verified || resend.isPending}
|
||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs disabled:opacity-45 transition"
|
||||
>
|
||||
<Send className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 text-left">{t("users.account.resend")}</span>
|
||||
{/* Says WHY it is unavailable instead of just greying out — the no-mail case is the
|
||||
whole reason the link below exists, so naming it here is the explanation. */}
|
||||
{!a.email_configured && (
|
||||
<span className="text-muted">{t("users.account.mailOff")}</span>
|
||||
)}
|
||||
{a.email_configured && a.email_verified && (
|
||||
<span className="text-muted">{t("users.account.alreadyConfirmed")}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => issue.mutate()}
|
||||
disabled={issue.isPending || user.is_demo}
|
||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
|
||||
>
|
||||
<KeyRound className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 text-left">
|
||||
{link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{link && (
|
||||
<div className="mt-4 pt-4 border-t border-border space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-3 py-2 text-2xs font-mono truncate">
|
||||
{link.url}
|
||||
</div>
|
||||
<button
|
||||
onClick={copy}
|
||||
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs border border-accent/40 text-accent transition"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? t("users.account.copied") : t("users.account.copy")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-2xs text-muted leading-relaxed">{t("users.account.linkNote")}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,37 @@ import { useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
|
||||
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
|
||||
import {
|
||||
Ban,
|
||||
Check,
|
||||
Clock,
|
||||
MailWarning,
|
||||
Mails,
|
||||
RotateCcw,
|
||||
Send,
|
||||
Shield,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
HttpError,
|
||||
type AdminUserRow,
|
||||
type Invite,
|
||||
type Me,
|
||||
type PendingAccessRow,
|
||||
} from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
|
||||
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
||||
import Tooltip from "./Tooltip";
|
||||
import InlineNotice from "./InlineNotice";
|
||||
import { Section } from "./ui/form";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import { LANGUAGES, type LangCode } from "../i18n";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import AccountPanel from "./AccountPanel";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import Tabs, { usePersistedTab } from "./Tabs";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
@@ -26,7 +50,14 @@ export default function AdminUsers() {
|
||||
const [tab, setTab] = usePersistedTab(ADMIN_USERS_TAB_KEY, "roles");
|
||||
const tabs = [
|
||||
{ id: "roles", label: t("users.tabs.roles") },
|
||||
{ id: "access", label: t("users.tabs.access"), badge: me.pending_invites },
|
||||
// Both kinds of "waiting on the admin": a request needing a decision, and a sign-up whose
|
||||
// address was approved BEFORE they registered — the second never reaches the invite queue,
|
||||
// so the badge used to read zero while somebody sat locked out (roadmap UX-25).
|
||||
{
|
||||
id: "access",
|
||||
label: t("users.tabs.access"),
|
||||
badge: me.pending_invites + me.pending_activations,
|
||||
},
|
||||
{ id: "demo", label: t("users.tabs.demo") },
|
||||
];
|
||||
const active = tabs.some((x) => x.id === tab) ? tab : "roles";
|
||||
@@ -40,7 +71,12 @@ export default function AdminUsers() {
|
||||
</PageToolbar>
|
||||
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto">
|
||||
{active === "roles" && <UsersRoles me={me} />}
|
||||
{active === "access" && <AdminInvites />}
|
||||
{active === "access" && (
|
||||
<>
|
||||
<AdminInvites />
|
||||
<PendingAccess />
|
||||
</>
|
||||
)}
|
||||
{active === "demo" && <AdminDemo />}
|
||||
</div>
|
||||
</>
|
||||
@@ -73,6 +109,9 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
// Which rows have an action in flight, so we disable ONLY those rows' buttons (SC1) — a Set, since
|
||||
// with per-row disabling the admin can now start actions on several rows concurrently.
|
||||
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set());
|
||||
// The account panel (E12 S5): which user it is showing, or null. Held here rather than per row so
|
||||
// only one is ever mounted.
|
||||
const [panelFor, setPanelFor] = useState<AdminUserRow | null>(null);
|
||||
const startPending = (id: number) => setPendingIds((s) => new Set(s).add(id));
|
||||
const endPending = (id: number) =>
|
||||
setPendingIds((s) => {
|
||||
@@ -158,6 +197,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
|
||||
return (
|
||||
<Section card title={t("users.roles.title")}>
|
||||
{panelFor && <AccountPanel user={panelFor} onClose={() => setPanelFor(null)} />}
|
||||
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
@@ -173,13 +213,17 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
const self = u.id === me.id;
|
||||
return (
|
||||
<div key={u.id} className="glass-card flex items-center gap-2.5 p-2.5 rounded-xl">
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
onClick={() => setPanelFor(u)}
|
||||
title={t("users.account.open")}
|
||||
className="selectable cursor-pointer min-w-0 flex-1 text-left rounded-lg -m-1 p-1"
|
||||
>
|
||||
<div className="text-sm truncate">
|
||||
{u.display_name ?? u.email.split("@")[0]}
|
||||
{self && <span className="text-muted"> · {t("users.roles.you")}</span>}
|
||||
</div>
|
||||
<div className="text-2xs text-muted truncate">{u.email}</div>
|
||||
</div>
|
||||
</button>
|
||||
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
|
||||
{u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>}
|
||||
{u.is_suspended && <Badge tone="warning">{t("users.roles.suspended")}</Badge>}
|
||||
@@ -260,17 +304,207 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Did the invitation fail because this instance has no working SMTP? */
|
||||
function inviteEmailOff(err: HttpError): boolean {
|
||||
const d = err.detailData as { code?: string; reason?: string } | null | undefined;
|
||||
return d?.code === "invite_send" && d.reason === "email_off";
|
||||
}
|
||||
|
||||
// --- Sign-ups that are still outside (roadmap UX-25) --------------------------------------------
|
||||
//
|
||||
// The gap this closes: approve an address BEFORE the person registers and their sign-up produces no
|
||||
// queue row at all — `_decide` only takes a pending invite — so the Access-requests screen above was
|
||||
// empty while they sat locked out. The only working move was re-typing the address into the
|
||||
// whitelist box, which nobody would guess. This section makes the state visible and the fix one
|
||||
// click, WITHOUT changing who may cause what: the button is admin-authenticated and explicit. Two
|
||||
// earlier attempts moved authority instead (auto-activating on registration, reopening the approved
|
||||
// invite) and were reverted after review.
|
||||
function PendingAccess() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const rows = useQuery({
|
||||
queryKey: qk.adminPendingAccess(),
|
||||
queryFn: () => api.adminPendingAccess(),
|
||||
});
|
||||
// Activating changes /api/me's badge count too, so refresh all of them or the number lies until
|
||||
// reload. Shared with the account panel's copy of this mutation (see lib/adminUsers).
|
||||
const refresh = () => invalidateUserAccess(qc);
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: (u: PendingAccessRow) => api.activateUser(u.id),
|
||||
onSuccess: (_r, u) => {
|
||||
refresh();
|
||||
notify({ level: "success", message: t("users.pendingAccess.activated", { email: u.email }) });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
|
||||
});
|
||||
|
||||
const resend = useMutation({
|
||||
mutationFn: (u: PendingAccessRow) => api.resendUserVerification(u.id),
|
||||
onSuccess: (_r, u) => {
|
||||
refresh();
|
||||
notify({ level: "success", message: t("users.pendingAccess.resent", { email: u.email }) });
|
||||
},
|
||||
// The public resend is deliberately silent; this one is not, so say WHY it failed — an instance
|
||||
// with no working SMTP would otherwise show a success toast for a mail that was never sent.
|
||||
onError: (err) =>
|
||||
notify({
|
||||
level: "error",
|
||||
message: isEmailOff(err)
|
||||
? t("users.pendingAccess.resendEmailOff")
|
||||
: t("users.pendingAccess.resendFailed"),
|
||||
}),
|
||||
});
|
||||
|
||||
const list = rows.data ?? [];
|
||||
const busy = activate.isPending || resend.isPending;
|
||||
if (rows.isSuccess && list.length === 0) return null; // nothing stuck: no empty box to explain
|
||||
|
||||
return (
|
||||
<Section card title={t("users.pendingAccess.title")}>
|
||||
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.pendingAccess.body")}</p>
|
||||
{rows.isError && !rows.data ? (
|
||||
<StateMessage tone="error" onRetry={() => rows.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : rows.isLoading && !rows.data ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{list.map((u) => (
|
||||
<PendingAccessRowView
|
||||
key={u.id}
|
||||
row={u}
|
||||
busy={busy}
|
||||
onActivate={() => activate.mutate(u)}
|
||||
onResend={() => resend.mutate(u)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// One stuck sign-up. The row names WHICH gate is shut rather than just offering buttons — the same
|
||||
// reasoning as the sign-in card's blocked panel, from the admin's side: an admin about to let someone
|
||||
// in should see that the address has not been confirmed yet, because on a no-SMTP instance that is
|
||||
// the difference between the owner and whoever registered the address first (roadmap SEC-15).
|
||||
function PendingAccessRowView({
|
||||
row,
|
||||
busy,
|
||||
onActivate,
|
||||
onResend,
|
||||
}: {
|
||||
row: PendingAccessRow;
|
||||
busy: boolean;
|
||||
onActivate: () => void;
|
||||
onResend: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm truncate">{row.email}</div>
|
||||
<div className="text-2xs text-muted flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{row.created_at && (
|
||||
<span>
|
||||
{t("users.pendingAccess.signedUp", {
|
||||
time: new Date(row.created_at).toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{row.email_verified ? (
|
||||
<span className="inline-flex items-center gap-1 text-success">
|
||||
<Check className="w-3 h-3" />
|
||||
{t("users.pendingAccess.emailConfirmed")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-warn">
|
||||
<MailWarning className="w-3 h-3" />
|
||||
{t("users.pendingAccess.emailUnconfirmed")}
|
||||
</span>
|
||||
)}
|
||||
{!row.is_active && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{t("users.pendingAccess.inactive")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!row.email_verified && (
|
||||
<Tooltip hint={t("users.pendingAccess.resendHint")}>
|
||||
<button
|
||||
onClick={onResend}
|
||||
disabled={busy}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-fg disabled:opacity-50 transition"
|
||||
aria-label={t("users.pendingAccess.resend")}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{row.can_activate && (
|
||||
<Tooltip hint={t("users.pendingAccess.activateHint")}>
|
||||
<button
|
||||
onClick={onActivate}
|
||||
disabled={busy}
|
||||
className="shrink-0 px-2.5 py-1.5 rounded-lg border border-accent/40 text-accent text-xs font-medium hover:bg-accent/10 disabled:opacity-50 transition"
|
||||
>
|
||||
{t("users.pendingAccess.activate")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The mail language for one invitation. Owns its own display state and reports every change up
|
||||
* through `onPick`, because the dialog that hosts it resolves to a plain boolean — the choice has
|
||||
* to live in the caller's closure, not in this component. Endonyms come from the app's own
|
||||
* language list, so a third locale would appear here for free. */
|
||||
function InviteLangPicker({
|
||||
initial,
|
||||
onPick,
|
||||
}: {
|
||||
initial: LangCode;
|
||||
onPick: (lang: LangCode) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [lang, setLang] = useState<LangCode>(initial);
|
||||
return (
|
||||
<SegmentedToggle
|
||||
className="mt-3"
|
||||
size="md"
|
||||
ariaLabel={t("settings.invites.inviteLang")}
|
||||
label={t("settings.invites.inviteLang")}
|
||||
options={LANGUAGES.map((l) => ({ value: l.code, label: l.label }))}
|
||||
value={lang}
|
||||
onChange={(v) => {
|
||||
setLang(v);
|
||||
onPick(v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Access requests (the Invite whitelist) — moved verbatim from Settings → Account -------
|
||||
|
||||
function AdminInvites() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const me = useMe();
|
||||
const invites = useQuery({ queryKey: qk.adminInvites(), queryFn: () => api.adminInvites() });
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.adminInvites() });
|
||||
qc.invalidateQueries({ queryKey: qk.me() }); // updates the pending badge
|
||||
// The stuck-sign-ups section below describes the same backlog and changes with every decision
|
||||
// here: approving an address moves a registrant INTO it (active, still unverified), whitelisting
|
||||
// one takes a row out. Leaving it stale hides the resend button this epic added for them.
|
||||
qc.invalidateQueries({ queryKey: qk.adminPendingAccess() });
|
||||
};
|
||||
const approve = useMutation({
|
||||
mutationFn: ({ id }: { id: number; email: string }) => api.approveInvite(id),
|
||||
@@ -297,6 +531,92 @@ function AdminInvites() {
|
||||
});
|
||||
if (ok) deny.mutate(i.id);
|
||||
};
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => api.deleteInvite(id),
|
||||
onSuccess: (d) => {
|
||||
refresh();
|
||||
// Removing the row is not always a revoke: is_allowed falls back to the env allow-lists, so
|
||||
// for an address on those the person keeps access. Saying "removed" alone would mislead.
|
||||
if (d.still_allowed_by_env)
|
||||
notify({ level: "warning", message: t("settings.invites.deletedStillAllowed") });
|
||||
else notify({ level: "success", message: t("settings.invites.deleted") });
|
||||
},
|
||||
onError: (e) => {
|
||||
// A 409 means the list was stale — someone registered on that address since it loaded. Say
|
||||
// what to do instead of a generic failure, and refetch so the row re-renders as read-only.
|
||||
const conflict = e instanceof HttpError && e.status === 409;
|
||||
notify({
|
||||
level: "error",
|
||||
message: t(
|
||||
conflict ? "settings.invites.deleteHasAccount" : "settings.invites.deleteFailed",
|
||||
),
|
||||
});
|
||||
if (conflict) refresh();
|
||||
},
|
||||
});
|
||||
const onDelete = async (i: Invite) => {
|
||||
const ok = await confirm({
|
||||
title: t("settings.invites.deleteTitle"),
|
||||
// Removing the row is not a revoke when the address is also in the server's env allow-lists —
|
||||
// say so while the admin can still change their mind, not in a toast after the fact.
|
||||
message: t(
|
||||
i.still_allowed_by_env
|
||||
? "settings.invites.deleteConfirmStillAllowed"
|
||||
: "settings.invites.deleteConfirm",
|
||||
{ email: i.email },
|
||||
),
|
||||
confirmLabel: t("settings.invites.delete"),
|
||||
danger: true,
|
||||
});
|
||||
if (ok) remove.mutate(i.id);
|
||||
};
|
||||
// Sending is its OWN act, never a side effect of approving an address: the operator opens a door
|
||||
// as a possibility (often already agreed in person), and a mail fired automatically would be
|
||||
// nudging someone who never asked. `invited_at` comes back on the row so a second click is a
|
||||
// decision rather than an accident.
|
||||
const invite = useMutation({
|
||||
mutationFn: (v: { inv: Invite; lang: LangCode }) => api.sendInvitation(v.inv.id, v.lang),
|
||||
onSuccess: (_r, v) => {
|
||||
refresh();
|
||||
notify({ level: "success", message: t("settings.invites.invited", { email: v.inv.email }) });
|
||||
},
|
||||
onError: (err) =>
|
||||
notify({
|
||||
level: "error",
|
||||
message:
|
||||
err instanceof HttpError && inviteEmailOff(err)
|
||||
? t("settings.invites.inviteEmailOff")
|
||||
: t("settings.invites.inviteFailed"),
|
||||
}),
|
||||
});
|
||||
// The one recipient the app has no language signal about: the admin typed this address in, so
|
||||
// nobody ever told us how to write to its owner. The dialog is where that gets decided — and it
|
||||
// is also where the "you already wrote to them" warning belongs, next to the button that would
|
||||
// do it a second time, rather than only in a tooltip the admin may never hover.
|
||||
const onInvite = async (inv: Invite) => {
|
||||
let lang: LangCode = LANGUAGES.some((l) => l.code === inv.lang)
|
||||
? (inv.lang as LangCode)
|
||||
: ((i18n.language.split("-")[0] === "hu" ? "hu" : "en") as LangCode);
|
||||
const ok = await confirm({
|
||||
title: t("settings.invites.inviteTitle"),
|
||||
message: (
|
||||
<>
|
||||
<p>
|
||||
{t(
|
||||
inv.invited_at
|
||||
? "settings.invites.inviteConfirmAgain"
|
||||
: "settings.invites.inviteConfirm",
|
||||
{ email: inv.email, time: new Date(inv.invited_at ?? 0).toLocaleString() },
|
||||
)}
|
||||
</p>
|
||||
<InviteLangPicker initial={lang} onPick={(v) => (lang = v)} />
|
||||
</>
|
||||
),
|
||||
confirmLabel: t("settings.invites.inviteSend"),
|
||||
});
|
||||
if (ok) invite.mutate({ inv, lang });
|
||||
};
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: (email: string) => api.addInvite(email),
|
||||
onSuccess: (_d, email) => {
|
||||
@@ -313,6 +633,9 @@ function AdminInvites() {
|
||||
|
||||
return (
|
||||
<Section card title={t("settings.invites.title")}>
|
||||
{!me.email_configured && (
|
||||
<InlineNotice className="mb-3">{t("settings.invites.emailOff")}</InlineNotice>
|
||||
)}
|
||||
{invites.isError && !invites.data ? (
|
||||
<StateMessage tone="error" onRetry={() => invites.refetch()}>
|
||||
{t("common.loadError")}
|
||||
@@ -329,7 +652,8 @@ function AdminInvites() {
|
||||
inv={i}
|
||||
onApprove={() => approve.mutate({ id: i.id, email: i.email })}
|
||||
onDeny={() => onDeny(i)}
|
||||
busy={approve.isPending || deny.isPending}
|
||||
onDelete={() => onDelete(i)}
|
||||
busy={approve.isPending || deny.isPending || remove.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -364,11 +688,67 @@ function AdminInvites() {
|
||||
</summary>
|
||||
<div className="mt-2 space-y-1 text-xs text-muted">
|
||||
{decided.map((i) => (
|
||||
<div key={i.id} className="flex items-center justify-between gap-2">
|
||||
<span className="truncate">{i.email}</span>
|
||||
<div key={i.id} className="flex items-center gap-2">
|
||||
<span className="truncate flex-1">{i.email}</span>
|
||||
<span className={i.status === "approved" ? "text-accent" : "text-danger"}>
|
||||
{i.status}
|
||||
</span>
|
||||
{/* Only an approved row with NOBODY behind it yet: this button means "the door is
|
||||
open, come in", which is meaningless to someone already inside — and half the
|
||||
whitelist is existing users. An account that exists but is still stuck has its
|
||||
own surface above, with a re-send. The label carries the answer to "did I already
|
||||
write to them?", the question whose wrong answer means mailing a person twice. */}
|
||||
{i.status === "approved" && !i.has_account && (
|
||||
<Tooltip
|
||||
hint={
|
||||
i.invited_at
|
||||
? t("settings.invites.invitedAt", {
|
||||
time: new Date(i.invited_at).toLocaleString(),
|
||||
})
|
||||
: t("settings.invites.inviteHint")
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => onInvite(i)}
|
||||
disabled={invite.isPending}
|
||||
className={`shrink-0 p-1 rounded-md disabled:opacity-50 transition ${
|
||||
i.invited_at
|
||||
? "text-muted/50 hover:text-fg"
|
||||
: "text-muted hover:text-accent"
|
||||
}`}
|
||||
aria-label={t("settings.invites.inviteLabel", { email: i.email })}
|
||||
>
|
||||
<Mails className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* An entry with an account behind it stays read-only: deleting it would revoke that
|
||||
user's access, since is_allowed reads this row. Delete the user instead. */}
|
||||
{i.has_account ? (
|
||||
// `disabled` would drop it from the tab order AND suppress the title, so the
|
||||
// reason reached nobody — same call as the Playlists drag handle.
|
||||
<button
|
||||
type="button"
|
||||
aria-disabled
|
||||
onClick={(e) => e.preventDefault()}
|
||||
title={t("settings.invites.deleteHasAccount")}
|
||||
aria-label={t("settings.invites.deleteHasAccount")}
|
||||
className="shrink-0 p-1 text-muted/50 cursor-not-allowed"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<Tooltip hint={t("settings.invites.deleteHint")}>
|
||||
<button
|
||||
onClick={() => onDelete(i)}
|
||||
disabled={remove.isPending}
|
||||
className="shrink-0 p-1 rounded-md text-muted hover:text-danger disabled:opacity-50 transition"
|
||||
aria-label={t("settings.invites.deleteLabel", { email: i.email })}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -506,11 +886,13 @@ function InviteRow({
|
||||
inv,
|
||||
onApprove,
|
||||
onDeny,
|
||||
onDelete,
|
||||
busy,
|
||||
}: {
|
||||
inv: Invite;
|
||||
onApprove: () => void;
|
||||
onDeny: () => void;
|
||||
onDelete: () => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -546,6 +928,34 @@ function InviteRow({
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/* Deny records a decision and keeps the row; delete forgets the address. Once an account
|
||||
exists for it the row is that account's approval gate, so it is managed from Users & roles
|
||||
instead — shown as a disabled control rather than omitted, or the admin sees a row that is
|
||||
silently missing a button their other rows have. */}
|
||||
{inv.has_account ? (
|
||||
// aria-disabled, not `disabled`: the reason has to stay reachable by keyboard.
|
||||
<button
|
||||
type="button"
|
||||
aria-disabled
|
||||
onClick={(e) => e.preventDefault()}
|
||||
title={t("settings.invites.deleteHasAccount")}
|
||||
aria-label={t("settings.invites.deleteHasAccount")}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border/50 text-muted/50 cursor-not-allowed"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<Tooltip hint={t("settings.invites.deleteHint")}>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={busy}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
||||
aria-label={t("settings.invites.deleteLabel", { email: inv.email })}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function Banner({
|
||||
action,
|
||||
onDismiss,
|
||||
dismissTitle,
|
||||
role,
|
||||
}: {
|
||||
tone: BannerTone;
|
||||
icon: LucideIcon;
|
||||
@@ -33,10 +34,17 @@ export default function Banner({
|
||||
action?: { label: string; onClick: () => void };
|
||||
onDismiss?: () => void;
|
||||
dismissTitle?: string;
|
||||
/** `"status"` for a bar that APPEARS in response to something the user just did, so a screen
|
||||
* reader announces it. Left unset for the permanent bars (demo, version): those are part of the
|
||||
* page when it loads, and announcing them on every visit is noise, not information. */
|
||||
role?: "status";
|
||||
}) {
|
||||
const c = TONES[tone];
|
||||
return (
|
||||
<div className={`shrink-0 flex items-center gap-2 px-4 py-2 text-sm border-b text-fg ${c.bar}`}>
|
||||
<div
|
||||
role={role}
|
||||
className={`shrink-0 flex items-center gap-2 px-4 py-2 text-sm border-b text-fg ${c.bar}`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 shrink-0 ${c.icon}`} />
|
||||
<span className="min-w-0">{children}</span>
|
||||
{action && (
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Plug, RotateCcw, Send } from "lucide-react";
|
||||
import { api, type ConfigItem, type PlexTestResult } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import InlineNotice from "./InlineNotice";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import Tabs, { usePersistedTab } from "./Tabs";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
@@ -35,6 +37,7 @@ const GROUP_ORDER = [
|
||||
export default function ConfigPanel() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const me = useMe();
|
||||
const q = useQuery({ queryKey: qk.adminConfig(), queryFn: api.adminConfig });
|
||||
const data = q.data;
|
||||
|
||||
@@ -100,6 +103,10 @@ export default function ConfigPanel() {
|
||||
}
|
||||
}
|
||||
await qc.invalidateQueries({ queryKey: qk.adminConfig() });
|
||||
// The email-not-configured notice reads me.email_configured, which is otherwise only
|
||||
// refetched on its own 60s interval — without this it keeps warning about fields the admin
|
||||
// has just filled in, which reads as the save not having taken.
|
||||
await qc.invalidateQueries({ queryKey: qk.me() });
|
||||
setReseedToken((n) => n + 1); // re-seed the draft from the saved server state
|
||||
setSaveState("saved");
|
||||
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
|
||||
@@ -204,7 +211,11 @@ export default function ConfigPanel() {
|
||||
</div>
|
||||
|
||||
{activeGroup === "email" && (
|
||||
<div className="mt-3 pt-3 border-t border-border/60">
|
||||
<div className="mt-3 pt-3 border-t border-border/60 space-y-3">
|
||||
{/* The one place an operator can see that mail is off. Sending is fail-soft, so a
|
||||
half-filled config costs verification links, resets and access-request notices
|
||||
with no error anywhere — say so next to the fields that fix it. */}
|
||||
{!me.email_configured && <InlineNotice>{t("config.emailOff")}</InlineNotice>}
|
||||
<Tooltip hint={t("config.testEmailHint")}>
|
||||
<button
|
||||
onClick={() => testEmail.mutate()}
|
||||
|
||||
@@ -94,7 +94,7 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
onClose={() => close(null)}
|
||||
maxWidth={choices.length > 1 ? "max-w-md" : "max-w-sm"}
|
||||
>
|
||||
<p className="text-sm text-muted leading-relaxed">{state.opts.message}</p>
|
||||
<div className="text-sm text-muted leading-relaxed">{state.opts.message}</div>
|
||||
{/* One action: the familiar Cancel/Confirm row. Two or more: a column, because side by
|
||||
side they wrap raggedly and a wrapped button reads as a second, lesser group — and
|
||||
these are peers. Ordered least to most destructive, top to bottom. */}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
// An in-flow warning that sits INSIDE a panel, next to the thing it is about — as opposed to
|
||||
// Banner, which is the full-bleed top-of-app bar (border-b, app chrome) and looks wrong nested in a
|
||||
// card. Same warn tokens as Banner's warning tone so the two read as one system.
|
||||
export default function InlineNotice({
|
||||
children,
|
||||
icon: Icon = AlertTriangle,
|
||||
className = "",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
icon?: LucideIcon;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={`flex items-start gap-2 rounded-xl border border-warn/30 bg-warn/15 px-3 py-2 text-sm text-fg ${className}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0 mt-0.5 text-warn" aria-hidden />
|
||||
<span className="min-w-0">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,11 +25,17 @@ export default function Modal({
|
||||
onClose,
|
||||
children,
|
||||
maxWidth = "max-w-lg",
|
||||
placement = "center",
|
||||
}: {
|
||||
title: ReactNode;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
maxWidth?: string;
|
||||
/** `drawer` slides in from the right edge and runs full height; everything else about the shell —
|
||||
* the layer stack, ESC, the focus trap, Back-to-close, the scroll lock — is identical. A drawer
|
||||
* is a placement, not a second dialog implementation: duplicating that behaviour is exactly how
|
||||
* two shells drift apart. */
|
||||
placement?: "center" | "drawer";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
||||
@@ -106,9 +112,12 @@ export default function Modal({
|
||||
};
|
||||
}, [restoreTo]);
|
||||
|
||||
const drawer = placement === "drawer";
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-overlay flex items-center justify-center p-4 sm:p-6 bg-black/70 overlay-backdrop"
|
||||
className={`fixed inset-0 z-overlay flex bg-black/70 overlay-backdrop ${
|
||||
drawer ? "justify-end" : "items-center justify-center p-4 sm:p-6"
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
pressedOnBackdrop.current = e.target === e.currentTarget;
|
||||
}}
|
||||
@@ -122,7 +131,11 @@ export default function Modal({
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`}
|
||||
className={
|
||||
drawer
|
||||
? `glass relative w-full ${maxWidth} h-full overflow-y-auto rounded-none sm:rounded-l-2xl outline-none overlay-drawer`
|
||||
: `glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 p-5 pb-3">
|
||||
<h2 id={titleId} className="text-lg font-semibold leading-snug">
|
||||
|
||||
@@ -281,6 +281,20 @@ export default function NavSidebar({
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{name}</div>
|
||||
<div className="text-xs text-muted truncate">{me.email}</div>
|
||||
{/* The address Google answers with, when it isn't the one above. /api/me sends it
|
||||
only when the two differ, so for most accounts this line never exists — and for
|
||||
someone who registered as `me+siftlode@gmail.com` it answers the question their
|
||||
sign-in screen would otherwise leave open: which address gets me in? A text
|
||||
label rather than a brand glyph, since the app draws one icon set (lucide) and a
|
||||
generic mail icon here would say nothing. */}
|
||||
{me.google_email && (
|
||||
<div
|
||||
className="text-2xs text-muted truncate mt-0.5"
|
||||
title={t("header.account.googleAddress", { email: me.google_email })}
|
||||
>
|
||||
<span className="uppercase tracking-wide">Google</span> · {me.google_email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{me.role === "admin" && (
|
||||
|
||||
@@ -243,6 +243,7 @@ function NotificationRow({
|
||||
const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number";
|
||||
const isScheduler = n.type === "scheduler" && typeof n.data?.job_id === "string";
|
||||
const isDownloadDone = n.type === "download_done";
|
||||
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
|
||||
let title = n.title;
|
||||
let body = n.body;
|
||||
if (isMaintenance) {
|
||||
@@ -258,6 +259,17 @@ function NotificationRow({
|
||||
job,
|
||||
});
|
||||
body = d.summary || n.body;
|
||||
} else if (isEmailUndelivered) {
|
||||
// Email is fail-soft: a drop leaves no error anywhere else, so this notice IS the trace. Name
|
||||
// the message that was lost, since that is what tells the operator what their users didn't get.
|
||||
const d = n.data as { reason: string; subject?: string };
|
||||
title = t("inbox.emailUndelivered.title");
|
||||
body = t(
|
||||
d.reason === "not_configured"
|
||||
? "inbox.emailUndelivered.notConfigured"
|
||||
: "inbox.emailUndelivered.sendFailed",
|
||||
{ subject: d.subject || n.body || "" },
|
||||
);
|
||||
} else if (isDownloadDone) {
|
||||
// "Download ready" / "Edit ready" per the producer's kind, with the video's title as the body
|
||||
// (shipped in data.title). Same notification type; the copy differs so an edit isn't mislabelled.
|
||||
|
||||
@@ -39,6 +39,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
import { useUndoable } from "../lib/useUndoable";
|
||||
import { cycleSort, type SortState } from "../lib/columnSort";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { applyView, sameOrder } from "../lib/playlistView";
|
||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
import UndoToolbar from "./UndoToolbar";
|
||||
@@ -259,6 +260,13 @@ export default function Playlists() {
|
||||
return (v: Video) => m.get(v.id) ?? 0;
|
||||
}, [items]);
|
||||
|
||||
// Total playtime of the whole playlist (sums every item, matching the itemCount beside it — not
|
||||
// the filtered `shown` set). Items with an unknown duration count as 0; hidden when nothing sums.
|
||||
const totalSeconds = useMemo(
|
||||
() => items.reduce((acc, v) => acc + (v.duration_seconds ?? 0), 0),
|
||||
[items],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => playlistColumns({ t, indexOf: storedIndex, onPlay, onRemove, canEdit }),
|
||||
[t, storedIndex, onPlay, onRemove, canEdit],
|
||||
@@ -591,176 +599,192 @@ export default function Playlists() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-4 pt-3">
|
||||
// No top padding: the sticky column header must sit flush at the scroller top so it doesn't
|
||||
// shift up when `position: sticky` engages on the first scroll (the fixed toolbar band above
|
||||
// gives the breathing room). Same reason as the channel table — see Channels.tsx.
|
||||
<div className="px-4 pb-4">
|
||||
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
|
||||
fixed band, so only the video list scrolls. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-2 pb-2 max-w-5xl mx-auto">
|
||||
<div className="flex gap-3 items-start mb-4">
|
||||
<div className="w-24 h-13.5 rounded-lg bg-card overflow-hidden shrink-0">
|
||||
{/* Two tight rows beside a small cover: title + actions on the first, the meta line and the
|
||||
view controls sharing the second. This band is FIXED chrome (it never scrolls), so every
|
||||
row it sheds is list height won back — the old layout stacked three rows next to the
|
||||
cover plus a fourth block under it, for roughly twice this height. */}
|
||||
<div className="px-4 py-2 max-w-5xl mx-auto">
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="w-20 h-11.25 rounded-lg bg-card overflow-hidden shrink-0">
|
||||
{items[0]?.thumbnail_url && (
|
||||
<img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={saveRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveRename();
|
||||
if (e.key === "Escape") setRenaming(false);
|
||||
}}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
||||
{editable && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setRenameValue(detail.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
title={t("playlists.rename")}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="shrink-0 text-muted hover:text-fg"
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={saveRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveRename();
|
||||
if (e.key === "Escape") setRenaming(false);
|
||||
}}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="min-w-0 flex-1 text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
||||
{editable && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setRenameValue(detail.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
title={t("playlists.rename")}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="shrink-0 text-muted hover:text-fg"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{detail.yt_playlist_id && (
|
||||
<a
|
||||
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
title={t("playlists.openOnYoutube")}
|
||||
aria-label={t("playlists.openOnYoutube")}
|
||||
className="shrink-0 text-muted hover:text-accent"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex gap-1.5 items-center shrink-0">
|
||||
{linked && detail.dirty && (
|
||||
<span
|
||||
title={t("playlists.unsyncedHint")}
|
||||
className="inline-flex items-center gap-1.5 text-2xs text-accent mr-1"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
{t("playlists.unsynced")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => shown.length && setPlayingId(shown[0]!.id)}
|
||||
disabled={!shown.length}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
|
||||
</button>
|
||||
{editable && canWrite && (
|
||||
<button
|
||||
onClick={pushToYoutube}
|
||||
disabled={pushing}
|
||||
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
|
||||
aria-label={
|
||||
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
|
||||
}
|
||||
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
|
||||
detail.dirty
|
||||
? "border-accent text-accent hover:bg-accent/10"
|
||||
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
{pushing ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : linked ? (
|
||||
<Youtube className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{detail.yt_playlist_id && (
|
||||
<a
|
||||
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
title={t("playlists.openOnYoutube")}
|
||||
aria-label={t("playlists.openOnYoutube")}
|
||||
className="shrink-0 text-muted hover:text-accent"
|
||||
{linked && detail.dirty && (
|
||||
<button
|
||||
onClick={revertToYoutube}
|
||||
disabled={reverting}
|
||||
title={t("playlists.revert")}
|
||||
aria-label={t("playlists.revert")}
|
||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
)}
|
||||
{editable && (
|
||||
<button
|
||||
onClick={deletePlaylist}
|
||||
title={t("playlists.delete")}
|
||||
aria-label={t("playlists.delete")}
|
||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted mt-0.5">
|
||||
{t("playlists.itemCount", { count: items.length })}
|
||||
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
|
||||
</div>
|
||||
<div className="flex gap-1.5 mt-3 flex-wrap items-center">
|
||||
<button
|
||||
onClick={() => shown.length && setPlayingId(shown[0]!.id)}
|
||||
disabled={!shown.length}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
|
||||
</button>
|
||||
{editable && canWrite && (
|
||||
<button
|
||||
onClick={pushToYoutube}
|
||||
disabled={pushing}
|
||||
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
|
||||
aria-label={
|
||||
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
|
||||
}
|
||||
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
|
||||
detail.dirty
|
||||
? "border-accent text-accent hover:bg-accent/10"
|
||||
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
{pushing ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : linked ? (
|
||||
<Youtube className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<span className="text-xs text-muted">
|
||||
{t("playlists.itemCount", { count: items.length })}
|
||||
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
|
||||
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
|
||||
</span>
|
||||
{(items.length > 1 || viewActive) && (
|
||||
<>
|
||||
<span aria-hidden="true" className="text-xs text-muted/40">
|
||||
·
|
||||
</span>
|
||||
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={groupBy}
|
||||
onChange={(e) => setGroupBy(e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
{t("playlists.groupByChannel")}
|
||||
</label>
|
||||
{viewActive && (
|
||||
<button
|
||||
onClick={clearView}
|
||||
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
|
||||
>
|
||||
{t("playlists.clearView")}
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{linked && detail.dirty && (
|
||||
<button
|
||||
onClick={revertToYoutube}
|
||||
disabled={reverting}
|
||||
title={t("playlists.revert")}
|
||||
aria-label={t("playlists.revert")}
|
||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
)}
|
||||
{editable && (
|
||||
<button
|
||||
onClick={deletePlaylist}
|
||||
title={t("playlists.delete")}
|
||||
aria-label={t("playlists.delete")}
|
||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{linked && detail.dirty && (
|
||||
<span
|
||||
title={t("playlists.unsyncedHint")}
|
||||
className="inline-flex items-center gap-1.5 text-2xs text-accent ml-1"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
{t("playlists.unsynced")}
|
||||
</span>
|
||||
{canSaveOrder && (
|
||||
<button
|
||||
onClick={saveOrder}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
|
||||
</button>
|
||||
)}
|
||||
{/* ONE sentence that names the state. Three loose texts used to pile up here
|
||||
(reset link + save hint + "reordering is off"), and none of them answered the
|
||||
question a user actually has — "did I just change my playlist?". Worse,
|
||||
sorting a list whose stored order ALREADY matches the sort produced a
|
||||
warning, no Save button and no visible change, which reads as a malfunction.
|
||||
Now the line says which of the three situations you are in; the disabled grip
|
||||
still explains itself on hover/focus. */}
|
||||
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
|
||||
{canEdit && (
|
||||
<div className="ml-auto">
|
||||
<UndoToolbar
|
||||
canUndo={order.canUndo}
|
||||
canRedo={order.canRedo}
|
||||
onUndo={order.undo}
|
||||
onRedo={order.redo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(items.length > 1 || viewActive) && (
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap max-w-5xl">
|
||||
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={groupBy}
|
||||
onChange={(e) => setGroupBy(e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
{t("playlists.groupByChannel")}
|
||||
</label>
|
||||
{viewActive && (
|
||||
<button
|
||||
onClick={clearView}
|
||||
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
|
||||
>
|
||||
{t("playlists.clearView")}
|
||||
</button>
|
||||
)}
|
||||
{canSaveOrder && (
|
||||
<button
|
||||
onClick={saveOrder}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
|
||||
</button>
|
||||
)}
|
||||
{/* ONE sentence that names the state. Three loose texts used to pile up here (reset
|
||||
link + save hint + "reordering is off"), and none of them answered the question a
|
||||
user actually has — "did I just change my playlist?". Worse, sorting a list whose
|
||||
stored order ALREADY matches the sort produced a warning, no Save button and no
|
||||
visible change, which reads as a malfunction. Now the line says which of the three
|
||||
situations you are in; the disabled grip still explains itself on hover/focus. */}
|
||||
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
|
||||
{canEdit && (
|
||||
<div className="ml-auto">
|
||||
<UndoToolbar
|
||||
canUndo={order.canUndo}
|
||||
canRedo={order.canRedo}
|
||||
onUndo={order.undo}
|
||||
onRedo={order.redo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageToolbar>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
import { formatDuration } from "../lib/format";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
||||
@@ -247,7 +247,7 @@ export default function PlaylistsRail({
|
||||
<div className="text-2xs text-muted">
|
||||
{t("playlists.itemCount", { count: pl.item_count })}
|
||||
{pl.total_duration_seconds > 0 &&
|
||||
` · ${formatDuration(pl.total_duration_seconds)}`}
|
||||
` · ${formatRuntime(pl.total_duration_seconds)}`}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -19,6 +19,7 @@ import i18n from "../i18n";
|
||||
import { Carousel } from "./Carousel";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||
import { PLAYABLE_TINT, playableClass } from "./plexCards";
|
||||
|
||||
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
|
||||
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
|
||||
@@ -253,6 +254,33 @@ function CrewPanel({
|
||||
);
|
||||
}
|
||||
|
||||
// The playback-class chip (dot + Plex-native label), with a muted codec · bit-depth subtitle when
|
||||
// known. Class + colour are derived through the SAME shared helper the browse-card corner dot uses,
|
||||
// so the two surfaces can't drift. `overlay` tints the label for the dark player background.
|
||||
function PlaybackChip({ detail, overlay }: { detail: PlexItemDetail; overlay: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
if (!detail.playable) return null;
|
||||
const cls = playableClass(detail.playable, detail.transcode_hw);
|
||||
const sub = [
|
||||
detail.codec_video ? detail.codec_video.toUpperCase() : null,
|
||||
detail.video_bit_depth != null ? `${detail.video_bit_depth}-bit` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs ${
|
||||
overlay ? "bg-white/10 text-white/90" : "bg-surface text-muted"
|
||||
}`}
|
||||
title={t(`plex.playable.${cls}`)}
|
||||
>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${PLAYABLE_TINT[cls] ?? "bg-gray-400"}`} />
|
||||
<span className="font-medium">{t(`plex.filter.playableOpt.${cls}`)}</span>
|
||||
{sub && <span className="opacity-70">{sub}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
detail: PlexItemDetail;
|
||||
variant: "page" | "overlay";
|
||||
@@ -479,6 +507,7 @@ export default function PlexInfo({
|
||||
{detail.studio}
|
||||
</Filterable>
|
||||
)}
|
||||
<PlaybackChip detail={detail} overlay={overlay} />
|
||||
</div>
|
||||
|
||||
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
|
||||
|
||||
@@ -101,6 +101,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// Which encoder the current transcode session ran: true=VAAPI hardware, false=libx264 software,
|
||||
// null=not a transcode. Feeds the stats overlay's mode row ("transcode · GPU" / "· CPU").
|
||||
const hwRef = useRef<boolean | null>(null);
|
||||
// Whether the current HW transcode tone-mapped a 10-bit/HDR source on the GPU (OpenCL). Feeds the
|
||||
// stats mode row's "· HDR→SDR" tag; null/false for an 8-bit HW, software, or non-transcode session.
|
||||
const tonemapRef = useRef<boolean | null>(null);
|
||||
const durationRef = useRef(0);
|
||||
// ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio
|
||||
// client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
|
||||
@@ -234,6 +237,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// re-encodes the video (hw = which encoder), and its first segment is genuinely slower than remux.
|
||||
const isTranscode = sess.mode === "hls" && !!sess.transcode;
|
||||
hwRef.current = isTranscode ? (sess.hw ?? false) : null;
|
||||
tonemapRef.current = isTranscode ? (sess.tonemap ?? false) : null;
|
||||
setSessionIsTranscode(isTranscode);
|
||||
// hls.js plays our non-zero-start copyts stream ~1s BEHIND the media clock (see HLS_SUB_LAG);
|
||||
// fold that into the session start so the seekbar clock, seeking AND the subtitle shift are all
|
||||
@@ -257,7 +261,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
hlsRef.current = null;
|
||||
}
|
||||
if (sess.mode === "hls" && Hls.isSupported()) {
|
||||
const hls = new Hls({ maxBufferLength: 30, enableWorker: true });
|
||||
// startPosition: 0 — our HLS is a growing EVENT playlist with no ENDLIST (see _HLS_TAIL in
|
||||
// stream.py), which hls.js classifies as LIVE. Its default (startPosition: -1) then starts
|
||||
// playback at the live edge and hugs it, so it never builds a forward buffer (rebuffer
|
||||
// spinners), and on a resume/seek session the segments' absolute PTS (-copyts, offset by the
|
||||
// server's -ss) don't line up with the live-edge timeline → 0 fragments load → permanent
|
||||
// stall. But the seek-restart model means fragment 0 IS the requested content position
|
||||
// (`?t=` positioned the server ffmpeg there), so we always want to start VOD-style at 0 and
|
||||
// buffer forward up to maxBufferLength. Applies to remux + transcode alike (both use copyts).
|
||||
const hls = new Hls({ maxBufferLength: 30, enableWorker: true, startPosition: 0 });
|
||||
hlsRef.current = hls;
|
||||
hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`);
|
||||
hls.attachMedia(video);
|
||||
@@ -1206,6 +1218,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
hlsRef={hlsRef}
|
||||
playable={detail?.playable ?? ""}
|
||||
hwRef={hwRef}
|
||||
tonemapRef={tonemapRef}
|
||||
sessionStartRef={sessionStartRef}
|
||||
absRef={absRef}
|
||||
durationRef={durationRef}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
@@ -387,6 +388,12 @@ export default function PlexPlaylistView({
|
||||
}, [q.data]);
|
||||
const blocks = useMemo(() => buildBlocks(items), [items]);
|
||||
const order = useMemo(() => items.map((i) => i.id), [items]);
|
||||
// Total playtime of the whole playlist, mirroring the YouTube playlist header (E9 12D): items with
|
||||
// an unknown duration count as 0, and the runtime is dropped from the meta line when nothing sums.
|
||||
const totalSeconds = useMemo(
|
||||
() => items.reduce((acc, it) => acc + (it.duration_seconds ?? 0), 0),
|
||||
[items],
|
||||
);
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) });
|
||||
@@ -517,18 +524,26 @@ export default function PlexPlaylistView({
|
||||
|
||||
<div className="glass rounded-2xl p-4 sm:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && rename()}
|
||||
onBlur={rename}
|
||||
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-1.5 text-lg font-bold focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
) : (
|
||||
<h1 className="min-w-0 flex-1 truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && rename()}
|
||||
onBlur={rename}
|
||||
className="glass-card w-full rounded-lg px-3 py-1.5 text-lg font-bold focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
) : (
|
||||
<h1 className="truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<div className="mt-0.5 text-sm text-muted">
|
||||
{t("plex.browse.items", { count: items.length })}
|
||||
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Layout preference: Accordion or Tree — the shared list-view switcher. */}
|
||||
<ViewSwitcher
|
||||
value={layout}
|
||||
|
||||
@@ -296,6 +296,28 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Playback class (Direct Play / Direct Stream / Transcode HW/SW) — movie-only (shows have no
|
||||
// class), and dropped when the facet is empty. Labels are localized (the facet ships class keys).
|
||||
...(scope === "movie" && facets && facets.playable.length > 0
|
||||
? [
|
||||
{
|
||||
id: "playable",
|
||||
title: t("plex.filter.playable"),
|
||||
body: (
|
||||
<Combobox
|
||||
label={t("plex.filter.playable")}
|
||||
options={facets.playable.map((p) => ({
|
||||
value: p.value,
|
||||
label: t(`plex.filter.playableOpt.${p.value}`),
|
||||
count: p.count,
|
||||
}))}
|
||||
selected={filters.playable}
|
||||
onChange={(next) => patch({ playable: next })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,6 +13,7 @@ export function PlexStatsOverlay({
|
||||
hlsRef,
|
||||
playable,
|
||||
hwRef,
|
||||
tonemapRef,
|
||||
sessionStartRef,
|
||||
absRef,
|
||||
durationRef,
|
||||
@@ -22,6 +23,7 @@ export function PlexStatsOverlay({
|
||||
hlsRef: RefObject<Hls | null>;
|
||||
playable: string;
|
||||
hwRef: RefObject<boolean | null>; // active transcode encoder: true=GPU, false=CPU, null=not transcode
|
||||
tonemapRef: RefObject<boolean | null>; // HW HDR→SDR tonemap engaged (adds a "· HDR→SDR" mode tag)
|
||||
sessionStartRef: RefObject<number>;
|
||||
absRef: RefObject<number>;
|
||||
durationRef: RefObject<number>;
|
||||
@@ -78,6 +80,7 @@ export function PlexStatsOverlay({
|
||||
buildStatRows({
|
||||
playable,
|
||||
hwEncoder: hwRef.current,
|
||||
tonemap: tonemapRef.current,
|
||||
transport: hls ? "hls" : "direct",
|
||||
videoWidth: v?.videoWidth ?? 0,
|
||||
videoHeight: v?.videoHeight ?? 0,
|
||||
@@ -94,7 +97,7 @@ export function PlexStatsOverlay({
|
||||
sample();
|
||||
const iv = setInterval(sample, 1000);
|
||||
return () => clearInterval(iv);
|
||||
}, [videoRef, hlsRef, playable, hwRef, sessionStartRef, absRef, durationRef]);
|
||||
}, [videoRef, hlsRef, playable, hwRef, tonemapRef, sessionStartRef, absRef, durationRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -413,6 +413,15 @@ function SignInMethods({ me }: { me: Me }) {
|
||||
? t("settings.account.googleLink.connectedHint")
|
||||
: t("settings.account.googleLink.connectHint")}
|
||||
</p>
|
||||
{/* Which address Google actually answers with — sent only when it differs from the
|
||||
account's own (see /api/me). "Connected" alone left that unanswerable, and it is
|
||||
exactly what someone who registered with a `+tag` needs to know. */}
|
||||
{me.google_email && (
|
||||
<p className="text-xs text-fg/80 mt-1.5">
|
||||
<span className="text-muted">{t("settings.account.googleLink.signedInAs")}</span>{" "}
|
||||
{me.google_email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{me.has_google ? (
|
||||
<span className="shrink-0 text-2xs px-2 py-1 rounded-full border border-accent/40 text-accent">
|
||||
|
||||
@@ -6,6 +6,8 @@ import { History, Pause, Play, RefreshCw } from "lucide-react";
|
||||
import { api, type AdminQuotaRow, type Me } from "../lib/api";
|
||||
import { formatEta, quotaActionLabel } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { useWizard } from "./WizardProvider";
|
||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
@@ -51,6 +53,8 @@ export default function Stats() {
|
||||
function Overview({ me }: { me: Me }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
// The 403 case needs somewhere to go, or the toast names a fix the user cannot reach from here.
|
||||
const { openWizard } = useWizard();
|
||||
const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
|
||||
const usage = useQuery({ queryKey: qk.myUsage(), queryFn: api.myUsage });
|
||||
const s = status.data;
|
||||
@@ -64,7 +68,12 @@ function Overview({ me }: { me: Me }) {
|
||||
message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }),
|
||||
});
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("stats.sync.syncFailed") }),
|
||||
// The same helper and the same wizard handle as the Channels page: it translates the 403 into
|
||||
// "connect YouTube" WITH a button that opens the wizard — instead of surfacing the route's bare
|
||||
// English detail under a Hungarian UI — and delegates everything else to notifyActionError so
|
||||
// the server's own reason wins, which is what makes the SEC-14 cooldown read as a pace limit
|
||||
// rather than "Sync failed".
|
||||
onError: (e) => notifyYouTubeActionError(e, t("stats.sync.syncFailed"), openWizard),
|
||||
});
|
||||
const deepAll = useMutation({
|
||||
mutationFn: () => api.deepAll(true),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, AlertTriangle } from "lucide-react";
|
||||
import Banner from "./Banner";
|
||||
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
|
||||
// The top-of-app outcome of an emailed verification link opened in a browser that ALREADY holds a
|
||||
// session. That case used to be silent: the token was only ever consumed inside Welcome.tsx, which a
|
||||
// signed-in visit never reaches, so the link opened a tab, the fragment was stripped, and nothing
|
||||
// happened. Signed OUT, the same outcome is rendered by the auth card's own banners instead — this
|
||||
// bar is only for the signed-in half.
|
||||
//
|
||||
// No "send a new link" action here, unlike the auth card: this viewer is signed in, so the sign-in
|
||||
// screen (where resending lives) is not where they are, and the message is a report, not a task. A
|
||||
// wrong-account case is why the address is named.
|
||||
export default function VerifyBanner({ entry }: { entry: VerifyEntry }) {
|
||||
const { t } = useTranslation();
|
||||
// A reset link opened here can only be ACTED on signed out, so say that rather than nothing —
|
||||
// silence is precisely what made the verify half a bug. The token stays in the address bar, so
|
||||
// signing out from this same tab picks it up.
|
||||
//
|
||||
// Tested on `resetPending` ALONE, not `!outcome && resetPending`. The two can both be set (a
|
||||
// reset link pasted over an unread confirmation, or a slow verify resolving after one), and this
|
||||
// one goes first because it is the banner with something to DO. `dismiss` then peels it and the
|
||||
// outcome shows underneath — where `!outcome &&` would have hidden this one until the other was
|
||||
// dismissed, and dismissing that used to clear both.
|
||||
if (entry.resetPending) {
|
||||
return (
|
||||
<Banner
|
||||
tone="warning"
|
||||
icon={AlertTriangle}
|
||||
role="status"
|
||||
onDismiss={entry.dismiss}
|
||||
dismissTitle={t("header.banner.dismiss")}
|
||||
>
|
||||
{t("header.verify.resetSignedOut")}
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (!entry.outcome) return null;
|
||||
|
||||
const ok = entry.outcome === "ok" || entry.outcome === "ok_reset";
|
||||
const text =
|
||||
entry.outcome === "invalid"
|
||||
? t("header.verify.invalid")
|
||||
: entry.outcome === "unreachable"
|
||||
? t("header.verify.unreachable")
|
||||
: entry.email
|
||||
? t("header.verify.confirmedNamed", { email: entry.email })
|
||||
: t("header.verify.confirmed");
|
||||
|
||||
return (
|
||||
<Banner
|
||||
tone={ok ? "accent" : "warning"}
|
||||
icon={ok ? CheckCircle2 : AlertTriangle}
|
||||
role="status"
|
||||
onDismiss={entry.dismiss}
|
||||
dismissTitle={t("header.banner.dismiss")}
|
||||
>
|
||||
{text}
|
||||
{entry.outcome === "ok_reset" && (
|
||||
<span className="text-muted"> {t("header.verify.reset")}</span>
|
||||
)}
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,53 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useHistorySubview, type SubviewUrl } from "../lib/history";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
Clapperboard,
|
||||
Clock,
|
||||
Code2,
|
||||
Download,
|
||||
Expand,
|
||||
Filter,
|
||||
Inbox,
|
||||
Link2Off,
|
||||
ListVideo,
|
||||
Lock,
|
||||
MailWarning,
|
||||
PlayCircle,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
// Siftlode is open source; the landing footer links to the public repository.
|
||||
const REPO_URL = "https://git.b1fr0st.eu/peter/siftlode";
|
||||
import { api } from "../lib/api";
|
||||
import { api, deadResetLink, loginBlocked, type LoginBlocked } from "../lib/api";
|
||||
import { HttpError } from "../lib/api";
|
||||
import { setLanguage, type LangCode } from "../i18n";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { entryHash, entrySearch } from "../lib/entryUrl";
|
||||
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
const PASSWORD_MIN = 10;
|
||||
|
||||
type Mode = "signin" | "register" | "forgot" | "reset" | "resend";
|
||||
export type Mode = "signin" | "register" | "forgot" | "reset" | "resend";
|
||||
|
||||
// The card's sub-views are real history entries, so browser/mouse Back leaves one the way it leaves
|
||||
// any other page — they used to be plain state, and Back silently changed only the address bar.
|
||||
// "signin" is the root (no param) and "reset" is deliberately NOT mirrored: that view is only
|
||||
// reachable with the one-time token from the #reset fragment, so a bookmarked ?m=reset would open a
|
||||
// form that cannot submit.
|
||||
export const MODE_URL: SubviewUrl<Mode> = {
|
||||
key: "m",
|
||||
encode: (m: Mode) => (m === "signin" || m === "reset" ? null : m),
|
||||
decode: (raw: string) =>
|
||||
raw === "register" || raw === "forgot" || raw === "resend" ? (raw as Mode) : null,
|
||||
};
|
||||
|
||||
// Per-mode i18n keys for the card title and the submit button. A flat map (vs nested ternaries)
|
||||
// keeps adding a new mode a one-line change and makes a missing key a compile error.
|
||||
@@ -50,7 +70,7 @@ const SUBMIT_KEY: Record<Mode, string> = {
|
||||
// password, Google, explicit demo) over a short marketing pitch with feature cards. Shown
|
||||
// whenever the user isn't signed in (see App.tsx). The auth forms talk to /auth/* (see the
|
||||
// backend); errors are surfaced inline (the api calls use the quiet flag).
|
||||
export default function Welcome() {
|
||||
export default function Welcome({ verify }: { verify: VerifyEntry }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
// Which screenshot (if any) is open in the full-size lightbox.
|
||||
const [zoomed, setZoomed] = useState<{ src: string; label: string } | null>(null);
|
||||
@@ -74,7 +94,7 @@ export default function Welcome() {
|
||||
<p className="text-muted mt-4 text-lg leading-relaxed">{t("welcome.hero.subtitle")}</p>
|
||||
<p className="text-sm text-muted mt-4">{t("welcome.hero.trust")}</p>
|
||||
</div>
|
||||
<AuthCard />
|
||||
<AuthCard verify={verify} />
|
||||
</section>
|
||||
|
||||
{/* App preview */}
|
||||
@@ -288,47 +308,59 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
|
||||
);
|
||||
}
|
||||
|
||||
function AuthCard() {
|
||||
// One answer per reset token, however many times React mounts this card. StrictMode double-invokes
|
||||
// effects in dev and the shell remounts the card as the session probe settles — measured in UAT on
|
||||
// 2026-09-05: FOUR probes went out for one opened link. That would burn the endpoint's per-IP budget
|
||||
// after a couple of reloads and silently drop back to drawing the form unchecked. Holding the
|
||||
// in-flight promise means a remount joins the request already running instead of starting another.
|
||||
const resetTokenChecks = new Map<string, Promise<boolean>>();
|
||||
|
||||
function checkResetToken(token: string): Promise<boolean> {
|
||||
let pending = resetTokenChecks.get(token);
|
||||
if (!pending) {
|
||||
pending = api
|
||||
.checkPasswordReset(token)
|
||||
.then((r) => r.ok)
|
||||
// A probe that FAILED says nothing about the token, so it answers "go ahead" and forgets
|
||||
// itself: a network blip must never be cached as "this link is dead".
|
||||
.catch(() => {
|
||||
resetTokenChecks.delete(token);
|
||||
return true;
|
||||
});
|
||||
resetTokenChecks.set(token, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
const { t } = useTranslation();
|
||||
// Snapshot the pre-login params ONCE (query + fragment) so we can clean the address bar below
|
||||
// without dismissing the banners/reset-mode they drive. Secret tokens (reset, verify) ride the
|
||||
// URL FRAGMENT so they never reach the server / proxy logs / Referer (SB3); plain status flags
|
||||
// stay in the query string.
|
||||
const [params] = useState(() => new URLSearchParams(window.location.search));
|
||||
const [hashParams] = useState(() => new URLSearchParams(window.location.hash.replace(/^#/, "")));
|
||||
const resetToken = hashParams.get("reset");
|
||||
const verifyToken = hashParams.get("verify"); // raw token → POST to confirm (new flow)
|
||||
const bounced = params.get("access"); // Google denied → "requested" | "denied"
|
||||
const deleted = params.get("deleted"); // self-service account deletion just completed
|
||||
// The entry URL is snapshotted once at import time (lib/entryUrl) rather than here, so nothing can
|
||||
// rewrite the address bar out from under this card before it mounts — and so the App-level hook and
|
||||
// this card read the SAME values regardless of which mounts first. Clearing the address bar is that
|
||||
// hook's job now, since it is the one thing that runs on every visit, signed in or not.
|
||||
//
|
||||
// State, not a const: once the token has been spent the reset view must stop being the card's
|
||||
// root, or a Back from the "password changed" screen lands on a form whose token is already used.
|
||||
const [resetToken, setResetToken] = useState<string | null>(() => entryHash.get("reset"));
|
||||
// The Google access-request notice, off the FRAGMENT (see lib/entryUrl): it names the address now,
|
||||
// and an address in the query string would land in every reverse-proxy access log. `linked` is set
|
||||
// only when the request already existed under ANOTHER spelling of the same mailbox — the case worth
|
||||
// explaining; the same spelling twice is just a repeat.
|
||||
const bounced = entryHash.get("access"); // Google denied → "requested" | "denied"
|
||||
const bouncedEmail = entryHash.get("email");
|
||||
const bouncedLinked = entryHash.get("linked");
|
||||
const deleted = entrySearch.get("deleted"); // self-service account deletion just completed
|
||||
// Google login outcome → "suspended" | "oauth_cancelled". State (not a const) so it clears on the
|
||||
// first deliberate action, like the verify banner — otherwise it lingers stalely on later screens.
|
||||
const [login, setLogin] = useState<string | null>(() => params.get("login"));
|
||||
// Verification outcome: from POSTing the fragment token (new flow), or the legacy
|
||||
// ?verify=ok|invalid redirect (pre-SB3 emails still in flight).
|
||||
const [verify, setVerify] = useState<string | null>(() => params.get("verify"));
|
||||
const [login, setLogin] = useState<string | null>(() => entrySearch.get("login"));
|
||||
// The verification outcome is confirmed in App (above the signed-in branch) and handed down, so
|
||||
// one emailed link behaves the same whether or not this browser already holds a session.
|
||||
const verifyOutcome = verify.outcome;
|
||||
|
||||
// Strip BOTH the query string and the fragment so the address bar stays clean and the token
|
||||
// doesn't linger in history.
|
||||
useEffect(() => {
|
||||
if (window.location.search || window.location.hash) {
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Confirm a fragment verification token by POSTing it (new SB3 flow). Fire ONCE: the token is
|
||||
// single-use, so a double POST (StrictMode remount / any re-render) would 400 on the second call
|
||||
// and wrongly flip the banner to "invalid".
|
||||
const verifyFired = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!verifyToken || verifyFired.current) return;
|
||||
verifyFired.current = true;
|
||||
api
|
||||
.verifyEmail(verifyToken)
|
||||
.then(() => setVerify("ok"))
|
||||
.catch(() => setVerify("invalid"));
|
||||
}, [verifyToken]);
|
||||
|
||||
const [mode, setMode] = useState<Mode>(resetToken ? "reset" : "signin");
|
||||
const { view: mode, open: setMode } = useHistorySubview<Mode>(
|
||||
resetToken ? "reset" : "signin",
|
||||
MODE_URL,
|
||||
);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -344,6 +376,44 @@ function AuthCard() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const [done, setDone] = useState<string>(""); // success/info message replacing the form
|
||||
// Why password sign-in refused, when the server named a state worth a screen rather than a red line
|
||||
// (E9.5 S4). It REPLACES the form: in all three of those states the password was already proven
|
||||
// correct, so there is nothing to retype — leaving the form up would only invite a pointless retry.
|
||||
const [blocked, setBlocked] = useState<LoginBlocked | null>(null);
|
||||
// The emailed reset link turned out to be spent, expired, or to belong to an account that no longer
|
||||
// exists. Same reasoning as `blocked`: the form cannot succeed, so it must not be on screen.
|
||||
const [deadReset, setDeadReset] = useState(false);
|
||||
// Ask whether the `#reset=` token is still usable BEFORE drawing the form, and hold the card blank
|
||||
// until the answer lands. Reported 2026-09-05: deleting an account and then opening its reset link
|
||||
// rendered a full "set a new password" form that could only ever answer 400 — the user finds out
|
||||
// after typing. This is advisory (the confirm below stays the authority); a check that errors is
|
||||
// treated as "go ahead", never as "dead", so a broken probe cannot lock anyone out of a live link.
|
||||
const [checkingReset, setCheckingReset] = useState(() => entryHash.get("reset") !== null);
|
||||
useEffect(() => {
|
||||
const token = entryHash.get("reset");
|
||||
if (!token) return;
|
||||
let live = true;
|
||||
void checkResetToken(token).then((ok) => {
|
||||
if (!live) return;
|
||||
if (!ok) {
|
||||
setDeadReset(true);
|
||||
setResetToken(null); // and stop the reset view being the card's root, so Back can't return
|
||||
}
|
||||
setCheckingReset(false);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
// `done` and `error` belong to the view that produced them, and the render is `done ? msg : form`
|
||||
// — without this, Back out of a submitted form would show the old success message under the
|
||||
// previous view's title. Clearing on view change covers both Back/Forward and the mode links.
|
||||
useEffect(() => {
|
||||
setDone("");
|
||||
setError("");
|
||||
setBlocked(null);
|
||||
setDeadReset(false);
|
||||
}, [mode]);
|
||||
|
||||
// Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated
|
||||
// by the demo whitelist server-side.
|
||||
@@ -355,9 +425,11 @@ function AuthCard() {
|
||||
const reset = () => {
|
||||
setError("");
|
||||
setDone("");
|
||||
setBlocked(null);
|
||||
setDeadReset(false);
|
||||
// The verify/login banners are one-time entry notices — dismiss once the user takes any
|
||||
// deliberate action (mode switch, CTA, submit) so they don't linger stalely on later screens.
|
||||
setVerify(null);
|
||||
verify.dismiss();
|
||||
setLogin(null);
|
||||
};
|
||||
|
||||
@@ -367,6 +439,13 @@ function AuthCard() {
|
||||
setMode("resend");
|
||||
};
|
||||
|
||||
// Used by the "verified, but your password was revoked" banner — the only way back into that
|
||||
// account is a reset link, so put the user one click from it.
|
||||
const goForgot = () => {
|
||||
reset();
|
||||
setMode("forgot");
|
||||
};
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
reset();
|
||||
@@ -400,9 +479,24 @@ function AuthCard() {
|
||||
setDone(t("welcome.auth.resendDone"));
|
||||
} else if (mode === "reset") {
|
||||
await api.confirmPasswordReset(resetToken ?? "", password);
|
||||
setResetToken(null); // single-use: the reset view is no longer a place to come back to
|
||||
setDone(t("welcome.auth.resetDone"));
|
||||
}
|
||||
} catch (err) {
|
||||
// An account-shaped refusal becomes a named screen; `invalid` / `rate_limited` stay a line
|
||||
// under the form, because there the form IS the next step.
|
||||
const refusal = err instanceof HttpError ? loginBlocked(err.detailData) : null;
|
||||
if (refusal && PANEL_REASONS.has(refusal.reason)) {
|
||||
setBlocked(refusal);
|
||||
return;
|
||||
}
|
||||
// The token died between the pre-flight check and this submit — or was never checked, because
|
||||
// the probe itself failed. Either way the answer is the same screen, not a red line.
|
||||
if (mode === "reset" && err instanceof HttpError && deadResetLink(err.detailData)) {
|
||||
setDeadReset(true);
|
||||
setResetToken(null);
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
err instanceof HttpError && err.detail ? err.detail : t("welcome.auth.genericError"),
|
||||
);
|
||||
@@ -431,15 +525,43 @@ function AuthCard() {
|
||||
}
|
||||
}
|
||||
|
||||
const titleKey = TITLE_KEY[mode];
|
||||
// A named state owns the card's heading while it is up, so it reads as the card's subject rather
|
||||
// than a box sitting inside a heading that still says "Set a new password".
|
||||
const titleKey = blocked
|
||||
? `welcome.auth.blocked.${blocked.reason}.title`
|
||||
: deadReset
|
||||
? "welcome.auth.deadLink.title"
|
||||
: TITLE_KEY[mode];
|
||||
const StateIcon = blocked ? BLOCKED_ICON[blocked.reason] : deadReset ? Link2Off : null;
|
||||
|
||||
return (
|
||||
<div className="glass rounded-2xl p-6 sm:p-7 w-full max-w-md lg:justify-self-end">
|
||||
<h2 className="text-lg font-semibold mb-1">{t(titleKey)}</h2>
|
||||
<h2 className="text-lg font-semibold mb-1 flex items-center gap-2.5">
|
||||
{StateIcon && (
|
||||
<span className="w-8 h-8 rounded-xl bg-warn/15 text-warn grid place-items-center shrink-0">
|
||||
<StateIcon className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
)}
|
||||
{t(titleKey)}
|
||||
</h2>
|
||||
|
||||
{/* One-time status banners from a redirect (Google-denied, email verification). */}
|
||||
{verify === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
|
||||
{verify === "invalid" && (
|
||||
{verifyOutcome === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
|
||||
{verifyOutcome === "ok_reset" && (
|
||||
<Banner tone="warn">
|
||||
{t("welcome.auth.verifyOkReset")}{" "}
|
||||
<button onClick={goForgot} className="font-semibold text-accent hover:underline">
|
||||
{t("welcome.auth.verifyOkResetCta")}
|
||||
</button>
|
||||
</Banner>
|
||||
)}
|
||||
{/* No resend CTA here, unlike the invalid banner below: the token was NOT spent — we simply
|
||||
could not reach the server — so pushing the user to request a fresh link would act on a
|
||||
statement we know to be false (DX-16). Retrying the same link is the correct next move. */}
|
||||
{verifyOutcome === "unreachable" && (
|
||||
<Banner tone="warn">{t("welcome.auth.verifyUnreachable")}</Banner>
|
||||
)}
|
||||
{verifyOutcome === "invalid" && (
|
||||
<Banner tone="warn">
|
||||
{t("welcome.auth.verifyInvalid")}{" "}
|
||||
<button onClick={goResend} className="font-semibold text-accent hover:underline">
|
||||
@@ -447,7 +569,26 @@ function AuthCard() {
|
||||
</button>
|
||||
</Banner>
|
||||
)}
|
||||
{bounced === "requested" && <Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>}
|
||||
{bounced === "requested" &&
|
||||
(bouncedEmail ? (
|
||||
<Banner tone="ok">
|
||||
<span className="block">{t("welcome.auth.accessRequestedFor")}</span>
|
||||
<Address value={bouncedEmail} />
|
||||
<span className="block">{t("welcome.auth.accessRequestedReview")}</span>
|
||||
</Banner>
|
||||
) : (
|
||||
<Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>
|
||||
))}
|
||||
{/* The alias insight, stated only when it applies: Google answered with one spelling of a
|
||||
mailbox whose request is already on file under another. Silence here is what let the
|
||||
2026-09-03 registrant believe nothing had been recorded at all. */}
|
||||
{bounced === "requested" && bouncedLinked && (
|
||||
<Banner tone="warn">
|
||||
<span className="block">{t("welcome.auth.accessLinked")}</span>
|
||||
<Address value={bouncedLinked} />
|
||||
<span className="block">{t("welcome.auth.accessLinkedTail")}</span>
|
||||
</Banner>
|
||||
)}
|
||||
{bounced === "denied" && <Banner tone="warn">{t("welcome.auth.accessDenied")}</Banner>}
|
||||
{login === "suspended" && <Banner tone="warn">{t("welcome.auth.suspended")}</Banner>}
|
||||
{login === "oauth_cancelled" && (
|
||||
@@ -455,7 +596,22 @@ function AuthCard() {
|
||||
)}
|
||||
{deleted && <Banner tone="ok">{t("welcome.auth.deleted")}</Banner>}
|
||||
|
||||
{done ? (
|
||||
{blocked ? (
|
||||
<BlockedPanel blocked={blocked} onResend={goResend} onBack={() => setBlocked(null)} />
|
||||
) : deadReset ? (
|
||||
<StatePanel
|
||||
body={t("welcome.auth.deadLink.body")}
|
||||
cta={{ label: t("welcome.auth.deadLink.cta"), onClick: goForgot }}
|
||||
onBack={() => {
|
||||
reset();
|
||||
setMode("signin");
|
||||
}}
|
||||
/>
|
||||
) : checkingReset ? (
|
||||
// Blank rather than the form: showing a form we may be about to withdraw is the flicker this
|
||||
// whole check exists to remove.
|
||||
<p className="mt-3 text-sm text-muted">{t("welcome.auth.working")}</p>
|
||||
) : done ? (
|
||||
<div className="mt-3">
|
||||
<p className="text-sm text-fg/80 leading-relaxed">{done}</p>
|
||||
<button
|
||||
@@ -507,6 +663,18 @@ function AuthCard() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* A confirmation mail can be slow, filtered, or — before E9.5 S6 — never sent at all for a
|
||||
repeat sign-up. Offered right under the form rather than below the mode switches: it is
|
||||
the second thing a stuck person needs, after the password they just tried. */}
|
||||
{mode === "signin" && (
|
||||
<button
|
||||
onClick={goResend}
|
||||
className="mt-3 block w-full text-center text-xs text-muted hover:text-fg"
|
||||
>
|
||||
{t("welcome.auth.resendLink")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mode switches */}
|
||||
<div className="flex items-center justify-between mt-3 text-xs">
|
||||
{mode === "signin" ? (
|
||||
@@ -545,14 +713,6 @@ function AuthCard() {
|
||||
|
||||
{mode === "signin" && (
|
||||
<>
|
||||
{/* A verification email can be lost or never arrive, so offer a resend path from
|
||||
sign-in too — not only from the expired-link banner (needs a broken link to show). */}
|
||||
<button
|
||||
onClick={goResend}
|
||||
className="mt-2 block w-full text-center text-xs text-muted hover:text-fg"
|
||||
>
|
||||
{t("welcome.auth.resendLink")}
|
||||
</button>
|
||||
<div className="flex items-center gap-3 my-4 text-2xs uppercase tracking-wide text-muted">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
{t("welcome.auth.or")}
|
||||
@@ -603,9 +763,155 @@ function AuthCard() {
|
||||
);
|
||||
}
|
||||
|
||||
// An email address the user has to READ and compare, character by character — the recorded address on
|
||||
// a blocked-sign-in panel, the mailbox a request was filed under. Monospaced and breakable because
|
||||
// that comparison is the whole job: a dot or a `+tag` in a proportional font is exactly what nobody
|
||||
// noticed on 2026-09-03.
|
||||
function Address({ value }: { value: string }) {
|
||||
return <span className="block font-mono font-medium break-all my-0.5">{value}</span>;
|
||||
}
|
||||
|
||||
// Which refusals earn a screen of their own. The other two (`invalid`, `rate_limited`) are answered
|
||||
// BEFORE the password is proven, carry no account detail, and leave the form as the next step — a
|
||||
// line under it is the honest size for them.
|
||||
const PANEL_REASONS = new Set<LoginBlocked["reason"]>([
|
||||
"email_unverified",
|
||||
"pending_approval",
|
||||
"suspended",
|
||||
]);
|
||||
|
||||
const BLOCKED_ICON: Partial<Record<LoginBlocked["reason"], LucideIcon>> = {
|
||||
email_unverified: MailWarning,
|
||||
pending_approval: Clock,
|
||||
suspended: ShieldAlert,
|
||||
};
|
||||
|
||||
// The frame every named state in this card shares: a sentence, whatever detail that state can give,
|
||||
// then the way out. The icon and title sit in the card's own h2 (see titleKey/StateIcon) so a state
|
||||
// reads as the card's subject. One frame, because two states that mean "this form cannot succeed"
|
||||
// should not drift apart in shape.
|
||||
function StatePanel({
|
||||
body,
|
||||
cta,
|
||||
onBack,
|
||||
children,
|
||||
}: {
|
||||
body: string;
|
||||
cta?: { label: string; onClick: () => void };
|
||||
onBack: () => void;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Announced and focused, because this panel REPLACES the form the user just submitted: without a
|
||||
// live region a screen reader reports nothing at all, and without the focus move the button they
|
||||
// were standing on is unmounted, dropping focus to <body> so Tab restarts at the top of the page.
|
||||
// The Banner in this same file got `role="status"` for the same reason; the panel is the louder
|
||||
// half — it is the whole answer to a submit — so it announces assertively.
|
||||
const heading = useRef<HTMLParagraphElement>(null);
|
||||
useEffect(() => heading.current?.focus(), []);
|
||||
return (
|
||||
<div className="mt-3 flex flex-col gap-4" role="alert" aria-live="assertive">
|
||||
<p
|
||||
ref={heading}
|
||||
tabIndex={-1}
|
||||
className="text-sm text-fg/80 leading-relaxed focus:outline-hidden"
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
{children}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<button onClick={onBack} className="text-sm text-accent hover:underline">
|
||||
{t("welcome.auth.backToSignin")}
|
||||
</button>
|
||||
{cta && (
|
||||
<button
|
||||
onClick={cta.onClick}
|
||||
className="shrink-0 px-3 py-2 rounded-xl text-xs font-semibold bg-card border border-border hover:border-accent transition"
|
||||
>
|
||||
{cta.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The named state behind a 403, in place of the sign-in form (E9.5 S4). It says WHICH mailbox the
|
||||
// account is on and WHICH of the two gates is still shut, because the flat "awaiting admin approval"
|
||||
// string it replaces was true and useless: it never revealed that the address the account carried was
|
||||
// a different spelling of the one the user had just typed.
|
||||
function BlockedPanel({
|
||||
blocked,
|
||||
onResend,
|
||||
onBack,
|
||||
}: {
|
||||
blocked: LoginBlocked;
|
||||
onResend: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { reason, email, emailVerified, approved, operator } = blocked;
|
||||
// Suspension is not a gate that will open on its own, so the checklist would be a lie there.
|
||||
const gated = reason !== "suspended";
|
||||
return (
|
||||
<StatePanel
|
||||
body={t(`welcome.auth.blocked.${reason}.body`)}
|
||||
cta={
|
||||
reason === "email_unverified"
|
||||
? { label: t("welcome.auth.resendCta"), onClick: onResend }
|
||||
: undefined
|
||||
}
|
||||
onBack={onBack}
|
||||
>
|
||||
{gated && (
|
||||
<div className="rounded-xl border border-border bg-card/60 px-3.5 py-3 flex flex-col gap-2">
|
||||
<Gate done={emailVerified === true} k="email" />
|
||||
<Gate done={approved === true} k="approval" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{email && (
|
||||
<div className="border-l-2 border-border pl-3 flex flex-col gap-0.5">
|
||||
<span className="text-2xs uppercase tracking-wide text-muted">
|
||||
{t("welcome.auth.blocked.addressLabel")}
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
<Address value={email} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{operator && (
|
||||
<p className="text-xs text-muted leading-relaxed">
|
||||
{t("welcome.auth.blocked.operator", { contact: operator })}
|
||||
</p>
|
||||
)}
|
||||
</StatePanel>
|
||||
);
|
||||
}
|
||||
|
||||
// One row of the two-gate checklist. Both states get their own full sentence rather than a shared
|
||||
// label plus a status word: Hungarian puts them together differently, and gluing translated
|
||||
// fragments is how a bilingual UI ends up reading like neither language.
|
||||
function Gate({ done, k }: { done: boolean; k: "email" | "approval" }) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = done ? Check : Clock;
|
||||
return (
|
||||
<span className={`flex items-center gap-2 text-xs ${done ? "text-fg/80" : "text-muted"}`}>
|
||||
<Icon className={`w-4 h-4 shrink-0 ${done ? "text-success" : ""}`} />
|
||||
{t(`welcome.auth.blocked.gate.${k}${done ? "Done" : "Pending"}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// `role="status"` because these appear in response to something that just happened (a verification
|
||||
// link, a denied Google sign-in, a completed deletion) rather than being part of the card at rest —
|
||||
// without it a screen-reader user gets no announcement that anything changed.
|
||||
function Banner({ tone, children }: { tone: "ok" | "warn"; children: React.ReactNode }) {
|
||||
const cls = tone === "ok" ? "bg-accent/15 text-fg" : "bg-warn/15 text-fg";
|
||||
return (
|
||||
<div className={`rounded-xl px-3 py-2 text-xs leading-relaxed mt-3 ${cls}`}>{children}</div>
|
||||
<div role="status" className={`rounded-xl px-3 py-2 text-xs leading-relaxed mt-3 ${cls}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The admin account panel's RECOVERY actions, and specifically that a refusal is visible.
|
||||
//
|
||||
// All three call `quiet: true` endpoints, which suppress the global error dialog on the promise that
|
||||
// the caller reports the failure inline. Without an `onError` that promise is broken and a refusal
|
||||
// renders as nothing at all — and on the broken-SMTP instance this panel exists for, an admin reads
|
||||
// silence as "the mail went out". These pin that each failure says something, and that the resend
|
||||
// distinguishes "this instance has no working mail" from a genuine send failure.
|
||||
//
|
||||
// i18n is a key-passthrough so assertions read stable keys; the API and the notify singleton are
|
||||
// stubbed so the panel can be driven without a server.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
const notifyMock = vi.fn();
|
||||
vi.mock("../lib/notifications", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/notifications")>();
|
||||
return { ...actual, notify: (...a: unknown[]) => notifyMock(...a) };
|
||||
});
|
||||
|
||||
const access = {
|
||||
email_verified: false,
|
||||
email_configured: true,
|
||||
is_active: false,
|
||||
is_allowed: true,
|
||||
can_activate: true,
|
||||
has_password: false,
|
||||
google_email: null,
|
||||
invite_status: null,
|
||||
};
|
||||
|
||||
const apiMock = {
|
||||
adminUserAccess: vi.fn(async () => access),
|
||||
createSetPasswordLink: vi.fn(),
|
||||
resendUserVerification: vi.fn(),
|
||||
activateUser: vi.fn(),
|
||||
};
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return { ...actual, api: apiMock };
|
||||
});
|
||||
|
||||
const { HttpError } = await import("../lib/api");
|
||||
const AccountPanel = (await import("./AccountPanel")).default;
|
||||
|
||||
const user = {
|
||||
id: 7,
|
||||
email: "stuck@example.com",
|
||||
display_name: null,
|
||||
role: "user",
|
||||
is_demo: false,
|
||||
is_active: false,
|
||||
is_suspended: false,
|
||||
};
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<AccountPanel user={user as any} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Click the recovery button whose label is `key`, once the readout has loaded. */
|
||||
async function clickAction(key: string) {
|
||||
mount();
|
||||
await screen.findByText("users.account.recovery");
|
||||
fireEvent.click(screen.getByText(key));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
notifyMock.mockClear();
|
||||
Object.values(apiMock).forEach((m) => m.mockClear());
|
||||
apiMock.adminUserAccess.mockResolvedValue(access);
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const errorMessages = () =>
|
||||
notifyMock.mock.calls
|
||||
.map(([a]) => a as { level?: string; message?: string })
|
||||
.filter((a) => a.level === "error")
|
||||
.map((a) => a.message);
|
||||
|
||||
describe("the account panel reports a refused recovery action", () => {
|
||||
it("says the link could not be minted instead of leaving the button inert", async () => {
|
||||
apiMock.createSetPasswordLink.mockRejectedValue(new HttpError(404, "gone", null));
|
||||
|
||||
await clickAction("users.account.setPassword");
|
||||
|
||||
await waitFor(() => expect(errorMessages()).toContain("users.account.linkFailed"));
|
||||
// …and no link block was rendered, so the toast is the only thing that happened.
|
||||
expect(screen.queryByText("users.account.copy")).toBeNull();
|
||||
});
|
||||
|
||||
it("names the no-mail case, so a no-SMTP instance does not read as a bug", async () => {
|
||||
apiMock.resendUserVerification.mockRejectedValue(
|
||||
new HttpError(409, "email off", { code: "resend", reason: "email_off" }),
|
||||
);
|
||||
|
||||
await clickAction("users.account.resend");
|
||||
|
||||
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.resendEmailOff"));
|
||||
});
|
||||
|
||||
it("reports a genuine send failure as a failure, not as a sent mail", async () => {
|
||||
apiMock.resendUserVerification.mockRejectedValue(
|
||||
new HttpError(422, "send failed", { code: "resend", reason: "send_failed" }),
|
||||
);
|
||||
|
||||
await clickAction("users.account.resend");
|
||||
|
||||
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.resendFailed"));
|
||||
// The success toast must NOT also fire — that is the exact lie the backend guards against.
|
||||
expect(notifyMock.mock.calls.map(([a]) => (a as { message?: string }).message)).not.toContain(
|
||||
"users.account.resendSent",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a refused activation", async () => {
|
||||
apiMock.activateUser.mockRejectedValue(
|
||||
new HttpError(409, "not allowed", { code: "activate", reason: "not_allowed" }),
|
||||
);
|
||||
|
||||
await clickAction("users.account.activate");
|
||||
|
||||
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.activateFailed"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Access-requests panel's delete affordance and its three outcomes. The control's whole point is
|
||||
// that it explains itself: an entry backed by an account keeps a focusable, labelled control instead
|
||||
// of quietly missing one, and an address the server's env still allow-lists gets a warning BEFORE the
|
||||
// irreversible click, not a correction after it.
|
||||
//
|
||||
// i18n is a key-passthrough so assertions read stable keys; the API and the confirm/notify
|
||||
// singletons are stubbed so the panel can be driven without a server.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
// Echo the interpolation too, so a message that forgets its {{email}} is visible in the test.
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars?.email ? `${key}:${String(vars.email)}` : key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
type ConfirmOpts = {
|
||||
title?: string;
|
||||
message?: React.ReactNode;
|
||||
confirmLabel?: string;
|
||||
danger?: boolean;
|
||||
};
|
||||
const confirmMock = vi.fn(async (_opts: ConfirmOpts) => true);
|
||||
vi.mock("./ConfirmProvider", () => ({
|
||||
useConfirm: () => confirmMock,
|
||||
ConfirmProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
const notifyMock = vi.fn();
|
||||
vi.mock("../lib/notifications", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/notifications")>();
|
||||
return { ...actual, notify: (...a: unknown[]) => notifyMock(...a) };
|
||||
});
|
||||
|
||||
const meMock = {
|
||||
pending_invites: 0,
|
||||
pending_activations: 0,
|
||||
email_configured: true,
|
||||
id: 1,
|
||||
role: "admin",
|
||||
};
|
||||
vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
|
||||
|
||||
// The tab strip is rendered into an app-level toolbar slot that does not exist here, so pin the
|
||||
// active tab directly — this file is about the Access-requests panel, not the tab chrome.
|
||||
vi.mock("./Tabs", () => ({
|
||||
default: () => null,
|
||||
usePersistedTab: () => ["access", () => {}],
|
||||
}));
|
||||
|
||||
const listMock = vi.fn();
|
||||
const deleteMock = vi.fn();
|
||||
const inviteMock = vi.fn();
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
adminInvites: () => listMock(),
|
||||
deleteInvite: (id: number) => deleteMock(id),
|
||||
sendInvitation: (id: number, lang: string) => inviteMock(id, lang),
|
||||
// The access tab also renders the stuck-sign-ups section (E9.5 S5); nothing stuck here, so it
|
||||
// renders nothing and this file stays about the delete affordance.
|
||||
adminPendingAccess: () => Promise.resolve([]),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { HttpError } from "../lib/api";
|
||||
import AdminUsers from "./AdminUsers";
|
||||
|
||||
const invite = (over: Partial<Record<string, unknown>> = {}) => ({
|
||||
id: 1,
|
||||
email: "asker@example.com",
|
||||
status: "pending",
|
||||
note: null,
|
||||
requested_at: null,
|
||||
decided_at: null,
|
||||
decided_by: null,
|
||||
has_account: false,
|
||||
still_allowed_by_env: false,
|
||||
invited_at: null,
|
||||
lang: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const renderPanel = () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AdminUsers />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
confirmMock.mockClear().mockResolvedValue(true);
|
||||
notifyMock.mockClear();
|
||||
deleteMock.mockReset().mockResolvedValue({ deleted: 1, still_allowed_by_env: false });
|
||||
listMock.mockReset().mockResolvedValue([invite()]);
|
||||
inviteMock
|
||||
.mockReset()
|
||||
.mockResolvedValue(invite({ status: "approved", invited_at: "2026-09-05T09:00:00Z" }));
|
||||
meMock.email_configured = true;
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const openAccessTab = async () => {
|
||||
await screen.findByText("settings.invites.title");
|
||||
};
|
||||
|
||||
describe("Access requests — the delete affordance", () => {
|
||||
it("offers a labelled delete on an entry with no account behind it", async () => {
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
const btn = await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com");
|
||||
expect(btn.getAttribute("aria-disabled")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the control present and focusable — not missing — when the address has an account", async () => {
|
||||
listMock.mockResolvedValue([invite({ has_account: true })]);
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
const btn = await screen.findByLabelText("settings.invites.deleteHasAccount");
|
||||
// aria-disabled, not `disabled`: a disabled button leaves the tab order and takes its
|
||||
// explanation with it, which is the whole reason the control is shown at all.
|
||||
expect(btn.getAttribute("aria-disabled")).toBe("true");
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(btn.getAttribute("title")).toBe("settings.invites.deleteHasAccount");
|
||||
expect(screen.queryByLabelText(/deleteLabel/)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the same control on the previously-decided list, where the case is commonest", async () => {
|
||||
// The affordance exists twice — the pending rows and the collapsed "decided" list — and an
|
||||
// approved address that has since registered lands in the second one. Both must behave alike.
|
||||
listMock.mockResolvedValue([invite({ status: "approved", has_account: true })]);
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
fireEvent.click(await screen.findByText(/settings\.invites\.decided/));
|
||||
const btn = await screen.findByLabelText("settings.invites.deleteHasAccount");
|
||||
expect(btn.getAttribute("aria-disabled")).toBe("true");
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("offers a working delete on a decided entry with no account", async () => {
|
||||
listMock.mockResolvedValue([invite({ status: "denied" })]);
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
fireEvent.click(await screen.findByText(/settings\.invites\.decided/));
|
||||
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
|
||||
|
||||
await waitFor(() => expect(deleteMock).toHaveBeenCalledWith(1));
|
||||
});
|
||||
|
||||
it("warns in the confirmation — before the click is irreversible — when the env still allows it", async () => {
|
||||
listMock.mockResolvedValue([invite({ still_allowed_by_env: true })]);
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
|
||||
|
||||
expect(confirmMock).toHaveBeenCalledTimes(1);
|
||||
expect(confirmMock.mock.calls[0]![0]).toMatchObject({
|
||||
message: "settings.invites.deleteConfirmStillAllowed:asker@example.com",
|
||||
danger: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the plain confirmation otherwise", async () => {
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
|
||||
|
||||
expect(confirmMock.mock.calls[0]![0]).toMatchObject({
|
||||
message: "settings.invites.deleteConfirm:asker@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("says the entry kept its access when the server reports the env allow-list", async () => {
|
||||
deleteMock.mockResolvedValue({ deleted: 1, still_allowed_by_env: true });
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "warning",
|
||||
message: "settings.invites.deletedStillAllowed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("turns a 409 into the actionable reason rather than a generic failure", async () => {
|
||||
// The stale-list case: someone registered on the address after the page loaded.
|
||||
deleteMock.mockRejectedValue(new HttpError(409, "That address already has an account"));
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
message: "settings.invites.deleteHasAccount",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the mail-is-off warning only while email is unconfigured", async () => {
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
expect(screen.queryByText("settings.invites.emailOff")).toBeNull();
|
||||
cleanup();
|
||||
|
||||
meMock.email_configured = false;
|
||||
renderPanel();
|
||||
await openAccessTab();
|
||||
expect(await screen.findByText("settings.invites.emailOff")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// Whitelisting an address is opening a door as a possibility, not knocking on one — the operator has
|
||||
// usually already agreed it with the person, and a row can sit approved for weeks. So adding stays
|
||||
// silent and telling them is its own act, on its own button (E9.5 S6).
|
||||
describe("inviting someone the admin whitelisted", () => {
|
||||
// The decided rows live behind a <details>; await the summary rather than the section title, or
|
||||
// the click races the invite query that populates it.
|
||||
const open = async () => {
|
||||
renderPanel();
|
||||
fireEvent.click(await screen.findByText(/settings.invites.decided/));
|
||||
};
|
||||
|
||||
it("offers a send button on an approved row", async () => {
|
||||
listMock.mockResolvedValue([invite({ status: "approved" })]);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
// Falls back to the admin's own UI language: nobody ever told us anything about this mailbox.
|
||||
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "en"));
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "settings.invites.invited:asker@example.com" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("asks first, and writes to nobody when the admin backs out", async () => {
|
||||
confirmMock.mockResolvedValue(false);
|
||||
listMock.mockResolvedValue([invite({ status: "approved" })]);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
await waitFor(() => expect(confirmMock).toHaveBeenCalledTimes(1));
|
||||
expect(inviteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults to the language this person asked us in", async () => {
|
||||
listMock.mockResolvedValue([invite({ status: "approved", lang: "hu" })]);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "hu"));
|
||||
});
|
||||
|
||||
it("sends in the language picked in the dialog, not the default", async () => {
|
||||
// The dialog is the only place this decision can be made — and the pick has to survive back to
|
||||
// the caller, because the confirmation resolves to a plain boolean.
|
||||
let release: (ok: boolean) => void = () => {};
|
||||
confirmMock.mockImplementation(() => new Promise<boolean>((r) => (release = r)));
|
||||
listMock.mockResolvedValue([invite({ status: "approved" })]);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
await waitFor(() => expect(confirmMock).toHaveBeenCalled());
|
||||
const dialog = render(<>{confirmMock.mock.calls[0]![0].message}</>);
|
||||
fireEvent.click(dialog.getByText("Magyar"));
|
||||
release(true);
|
||||
|
||||
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "hu"));
|
||||
});
|
||||
|
||||
it("warns in the dialog when this person has already been written to", async () => {
|
||||
// The warning used to live only in a tooltip, which the admin may never hover. It belongs next
|
||||
// to the button that would mail a real person a second time.
|
||||
listMock.mockResolvedValue([
|
||||
invite({ status: "approved", invited_at: "2026-09-05T09:00:00Z" }),
|
||||
]);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
await waitFor(() => expect(confirmMock).toHaveBeenCalled());
|
||||
const dialog = render(<>{confirmMock.mock.calls[0]![0].message}</>);
|
||||
expect(dialog.container.textContent).toContain("settings.invites.inviteConfirmAgain");
|
||||
});
|
||||
|
||||
it("offers none on a denied row, which would contradict the decision", async () => {
|
||||
listMock.mockResolvedValue([invite({ status: "denied" })]);
|
||||
await open();
|
||||
|
||||
expect(screen.queryByLabelText("settings.invites.inviteLabel:asker@example.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers none once somebody is already behind that address", async () => {
|
||||
// "The door is open, come in" is meaningless to someone already inside, and most of a mature
|
||||
// whitelist is existing users. An account that exists but is stuck has its own row above.
|
||||
listMock.mockResolvedValue([invite({ status: "approved", has_account: true })]);
|
||||
await open();
|
||||
|
||||
expect(screen.queryByLabelText("settings.invites.inviteLabel:asker@example.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("says the mail was never sent when the instance has no SMTP", async () => {
|
||||
listMock.mockResolvedValue([invite({ status: "approved" })]);
|
||||
inviteMock.mockRejectedValue(
|
||||
new HttpError(409, "ignored", { code: "invite_send", reason: "email_off" }),
|
||||
);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
// A button the admin pressed must not answer with a success toast for a mail nobody received.
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "settings.invites.inviteEmailOff" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The admin lifeline for a sign-up that is stuck outside (E9.5 S5, roadmap UX-25). Approve an address
|
||||
// BEFORE the person registers and their sign-up produces no queue row at all, so the Access-requests
|
||||
// screen was empty while they sat locked out and the only working move — re-typing the address into
|
||||
// the whitelist box — was undiscoverable. These pin what the section shows and, just as importantly,
|
||||
// what it does NOT show: the section is a discovery surface, and a person the queue above already
|
||||
// covers must not appear twice.
|
||||
//
|
||||
// i18n is a key-passthrough so assertions read stable keys; the API and the notify singleton are
|
||||
// stubbed so the panel can be driven without a server.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars?.email ? `${key}:${String(vars.email)}` : key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./ConfirmProvider", () => ({
|
||||
useConfirm: () => vi.fn(async () => true),
|
||||
ConfirmProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
const notifyMock = vi.fn();
|
||||
vi.mock("../lib/notifications", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/notifications")>();
|
||||
return { ...actual, notify: (...a: unknown[]) => notifyMock(...a) };
|
||||
});
|
||||
|
||||
const meMock = {
|
||||
pending_invites: 0,
|
||||
pending_activations: 0,
|
||||
email_configured: true,
|
||||
id: 1,
|
||||
role: "admin",
|
||||
};
|
||||
vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
|
||||
|
||||
// The tab strip renders into an app-level toolbar slot that does not exist here; pin the tab.
|
||||
vi.mock("./Tabs", () => ({
|
||||
default: () => null,
|
||||
usePersistedTab: () => ["access", () => {}],
|
||||
}));
|
||||
|
||||
const pendingMock = vi.fn();
|
||||
const activateMock = vi.fn();
|
||||
const resendMock = vi.fn();
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
adminInvites: () => Promise.resolve([]),
|
||||
adminPendingAccess: () => pendingMock(),
|
||||
activateUser: (id: number) => activateMock(id),
|
||||
resendUserVerification: (id: number) => resendMock(id),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import AdminUsers from "./AdminUsers";
|
||||
|
||||
/** A row as /api/admin/pending-access serves it. */
|
||||
const row = (over: Record<string, unknown> = {}) => ({
|
||||
id: 7,
|
||||
email: "later@example.com",
|
||||
display_name: null,
|
||||
role: "user",
|
||||
is_active: false,
|
||||
is_suspended: false,
|
||||
email_verified: true,
|
||||
is_demo: false,
|
||||
has_password: true,
|
||||
has_google: false,
|
||||
created_at: "2026-09-01T10:00:00Z",
|
||||
can_activate: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AdminUsers />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pendingMock.mockReset();
|
||||
activateMock.mockReset().mockResolvedValue({});
|
||||
resendMock.mockReset().mockResolvedValue({ ok: true, email: "later@example.com" });
|
||||
notifyMock.mockReset();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("the stuck sign-ups section", () => {
|
||||
it("offers Activate for a registrant whose address was already approved", async () => {
|
||||
pendingMock.mockResolvedValue([row()]);
|
||||
mount();
|
||||
|
||||
const button = await screen.findByText("users.pendingAccess.activate");
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => expect(activateMock).toHaveBeenCalledWith(7));
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "users.pendingAccess.activated:later@example.com" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows no Activate when the address is not allowed, since that would be a different decision", async () => {
|
||||
// Activating an address with no approved invite makes an account that signs in with a password
|
||||
// but is turned away by Google — the whitelist box is the honest move for that.
|
||||
pendingMock.mockResolvedValue([row({ can_activate: false, email_verified: false })]);
|
||||
mount();
|
||||
|
||||
await screen.findByText("users.pendingAccess.emailUnconfirmed");
|
||||
expect(screen.queryByText("users.pendingAccess.activate")).toBeNull();
|
||||
});
|
||||
|
||||
it("names which gate is shut, so an admin sees an unconfirmed address before letting it in", async () => {
|
||||
pendingMock.mockResolvedValue([row({ email_verified: false })]);
|
||||
mount();
|
||||
|
||||
// On a no-SMTP instance this is the difference between the owner and whoever registered the
|
||||
// address first (roadmap SEC-15), so it belongs on the row, not in a tooltip.
|
||||
expect(await screen.findByText("users.pendingAccess.emailUnconfirmed")).toBeTruthy();
|
||||
expect(screen.getByText("users.pendingAccess.inactive")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("re-sends the confirmation link for an unconfirmed account", async () => {
|
||||
pendingMock.mockResolvedValue([row({ email_verified: false })]);
|
||||
mount();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("users.pendingAccess.resend"));
|
||||
|
||||
await waitFor(() => expect(resendMock).toHaveBeenCalledWith(7));
|
||||
});
|
||||
|
||||
it("offers no resend once the address is confirmed", async () => {
|
||||
pendingMock.mockResolvedValue([row()]);
|
||||
mount();
|
||||
|
||||
await screen.findByText("users.pendingAccess.activate");
|
||||
expect(screen.queryByLabelText("users.pendingAccess.resend")).toBeNull();
|
||||
});
|
||||
|
||||
it("says the mail was never sent when the instance has no SMTP", async () => {
|
||||
const { HttpError } = await import("../lib/api");
|
||||
pendingMock.mockResolvedValue([row({ email_verified: false })]);
|
||||
resendMock.mockRejectedValue(
|
||||
new HttpError(409, "ignored", { code: "resend", reason: "email_off" }),
|
||||
);
|
||||
mount();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("users.pendingAccess.resend"));
|
||||
|
||||
// Mail is fail-soft everywhere else, which is what left the 0.78.5 sign-ups with nothing to fall
|
||||
// back on. A button the admin pressed must not answer with a success toast.
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "users.pendingAccess.resendEmailOff" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders nothing at all when nobody is stuck", async () => {
|
||||
pendingMock.mockResolvedValue([]);
|
||||
mount();
|
||||
|
||||
// The section withdraws itself rather than leaving an empty box to explain — so wait for it to
|
||||
// GO, not merely for the request to have been made.
|
||||
await waitFor(() => expect(screen.queryByText("users.pendingAccess.title")).toBeNull());
|
||||
expect(pendingMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The honest states of the sign-in card (E9.5 S4). Two things are under test and they share a root:
|
||||
// a screen that does not name the address it is talking about hides an alias mismatch. On 2026-09-03
|
||||
// a registration as `np.temu@gmail.com` met a Google claim of `nptemu@gmail.com` and every screen in
|
||||
// the funnel stayed silent about it.
|
||||
//
|
||||
// entryUrl snapshots window.location at IMPORT time, so the access-notice cases reset the module
|
||||
// registry and set the URL before importing — that is the behaviour under test, not a workaround.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
|
||||
const noVerify: VerifyEntry = {
|
||||
outcome: null,
|
||||
email: null,
|
||||
resetPending: false,
|
||||
dismiss: () => {},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
/** Mount the real card at a given entry URL, with i18n as a key-passthrough and /auth/* stubbed. */
|
||||
async function mountCard(url: string, apiOverrides: Record<string, unknown> = {}) {
|
||||
window.history.replaceState(null, "", url);
|
||||
vi.resetModules();
|
||||
vi.doMock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts && typeof opts.contact === "string" ? `${key}:${opts.contact}` : key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.doMock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
authConfig: () => Promise.resolve({ google_enabled: false }),
|
||||
...apiOverrides,
|
||||
},
|
||||
};
|
||||
});
|
||||
const { HttpError } = await import("../lib/api");
|
||||
const { default: Welcome } = await import("./Welcome");
|
||||
await act(async () => {
|
||||
render(<Welcome verify={noVerify} />);
|
||||
});
|
||||
return { HttpError, Welcome };
|
||||
}
|
||||
|
||||
/** Fill the sign-in form and submit it. */
|
||||
async function signIn(email = "someone@example.com") {
|
||||
fireEvent.change(screen.getByPlaceholderText("welcome.auth.email"), { target: { value: email } });
|
||||
fireEvent.change(screen.getByPlaceholderText("welcome.auth.password"), {
|
||||
target: { value: "correct horse battery" },
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("welcome.auth.signinButton"));
|
||||
});
|
||||
}
|
||||
|
||||
/** A password-login refusal carrying the server's structured detail. */
|
||||
function refusal(
|
||||
HttpError: typeof import("../lib/api").HttpError,
|
||||
detail: Record<string, unknown>,
|
||||
) {
|
||||
return () => Promise.reject(new HttpError(403, "ignored", { code: "login", ...detail }));
|
||||
}
|
||||
|
||||
describe("a refused sign-in becomes a named state", () => {
|
||||
it("names the address the account is on, not the one that was typed", async () => {
|
||||
const passwordLogin = vi.fn();
|
||||
const { HttpError } = await mountCard("/", { passwordLogin });
|
||||
passwordLogin.mockImplementation(
|
||||
refusal(HttpError, {
|
||||
reason: "pending_approval",
|
||||
email: "np.temu@gmail.com",
|
||||
email_verified: true,
|
||||
approved: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await signIn("nptemu@gmail.com");
|
||||
|
||||
expect(screen.getByText("welcome.auth.blocked.pending_approval.title")).toBeTruthy();
|
||||
expect(screen.getByText("np.temu@gmail.com")).toBeTruthy();
|
||||
// The password was already proven correct, so the form is not the next step and must be gone —
|
||||
// leaving it up would only invite a retry that cannot succeed.
|
||||
expect(screen.queryByPlaceholderText("welcome.auth.password")).toBeNull();
|
||||
});
|
||||
|
||||
it("reports each gate from its own flag, so an approved-but-unverified account is told the truth", async () => {
|
||||
const passwordLogin = vi.fn();
|
||||
const { HttpError } = await mountCard("/", { passwordLogin });
|
||||
passwordLogin.mockImplementation(
|
||||
refusal(HttpError, {
|
||||
reason: "email_unverified",
|
||||
email: "approved.first@example.com",
|
||||
email_verified: false,
|
||||
approved: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await signIn("approved.first@example.com");
|
||||
|
||||
// E9.5 S1 made the two gates order-independent; a screen that assumed a sequence would send this
|
||||
// user off to wait for an admin who has already decided.
|
||||
expect(screen.getByText("welcome.auth.blocked.gate.emailPending")).toBeTruthy();
|
||||
expect(screen.getByText("welcome.auth.blocked.gate.approvalDone")).toBeTruthy();
|
||||
expect(screen.getByText("welcome.auth.resendCta")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows no checklist for a suspension, which is not a gate that opens on its own", async () => {
|
||||
const passwordLogin = vi.fn();
|
||||
const { HttpError } = await mountCard("/", { passwordLogin });
|
||||
passwordLogin.mockImplementation(
|
||||
refusal(HttpError, {
|
||||
reason: "suspended",
|
||||
email: "banned@example.com",
|
||||
operator: "ops@example.com",
|
||||
}),
|
||||
);
|
||||
|
||||
await signIn("banned@example.com");
|
||||
|
||||
expect(screen.getByText("welcome.auth.blocked.suspended.title")).toBeTruthy();
|
||||
expect(screen.getByText("welcome.auth.blocked.operator:ops@example.com")).toBeTruthy();
|
||||
expect(screen.queryByText("welcome.auth.blocked.gate.emailDone")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves a wrong password as a line under the form, where the form IS the next step", async () => {
|
||||
const passwordLogin = vi.fn();
|
||||
const { HttpError } = await mountCard("/", { passwordLogin });
|
||||
passwordLogin.mockImplementation(() =>
|
||||
Promise.reject(new HttpError(401, "localized invalid", { code: "login", reason: "invalid" })),
|
||||
);
|
||||
|
||||
await signIn();
|
||||
|
||||
expect(screen.getByText("localized invalid")).toBeTruthy();
|
||||
expect(screen.getByPlaceholderText("welcome.auth.password")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// Reported 2026-09-05: delete an account, then open its reset link. The card drew the full "set a new
|
||||
// password" form purely because a `#reset=` fragment was present, and only told the user after they
|
||||
// had typed one. Opening a dead reset link is ordinary — the token lives an hour, mail is slow, and
|
||||
// whoever got two mails clicks the older one.
|
||||
describe("an emailed reset link that is already finished", () => {
|
||||
it("refuses to draw a form that cannot succeed", async () => {
|
||||
await mountCard("/#reset=dead-token", {
|
||||
checkPasswordReset: () => Promise.resolve({ ok: false }),
|
||||
});
|
||||
|
||||
expect(screen.getByText("welcome.auth.deadLink.title")).toBeTruthy();
|
||||
expect(screen.getByText("welcome.auth.deadLink.cta")).toBeTruthy();
|
||||
expect(screen.queryByPlaceholderText("welcome.auth.newPassword")).toBeNull();
|
||||
});
|
||||
|
||||
it("draws the form when the link is still good", async () => {
|
||||
await mountCard("/#reset=live-token", {
|
||||
checkPasswordReset: () => Promise.resolve({ ok: true }),
|
||||
});
|
||||
|
||||
expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy();
|
||||
expect(screen.queryByText("welcome.auth.deadLink.title")).toBeNull();
|
||||
});
|
||||
|
||||
it("takes the form down if the link dies between the check and the submit", async () => {
|
||||
const confirmPasswordReset = vi.fn();
|
||||
const { HttpError } = await mountCard("/#reset=racing-token", {
|
||||
checkPasswordReset: () => Promise.resolve({ ok: true }),
|
||||
confirmPasswordReset,
|
||||
});
|
||||
confirmPasswordReset.mockImplementation(() =>
|
||||
Promise.reject(new HttpError(400, "ignored", { code: "reset", reason: "invalid" })),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("welcome.auth.newPassword"), {
|
||||
target: { value: "brand new password" },
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("welcome.auth.resetButton"));
|
||||
});
|
||||
|
||||
expect(screen.getByText("welcome.auth.deadLink.title")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the form up for a too-short password, which costs the link nothing", async () => {
|
||||
const confirmPasswordReset = vi.fn();
|
||||
const { HttpError } = await mountCard("/#reset=live-token", {
|
||||
checkPasswordReset: () => Promise.resolve({ ok: true }),
|
||||
confirmPasswordReset,
|
||||
});
|
||||
confirmPasswordReset.mockImplementation(() =>
|
||||
Promise.reject(
|
||||
new HttpError(400, "too short", { code: "reset", reason: "weak_password", min: 10 }),
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("welcome.auth.newPassword"), {
|
||||
target: { value: "0123456789ab" },
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("welcome.auth.resetButton"));
|
||||
});
|
||||
|
||||
expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy();
|
||||
expect(screen.queryByText("welcome.auth.deadLink.title")).toBeNull();
|
||||
});
|
||||
|
||||
it("probes a token once, however many times the card mounts", async () => {
|
||||
const checkPasswordReset = vi.fn().mockResolvedValue({ ok: true });
|
||||
const { Welcome } = await mountCard("/#reset=counted-token", { checkPasswordReset });
|
||||
|
||||
await act(async () => {
|
||||
render(<Welcome verify={noVerify} />);
|
||||
});
|
||||
|
||||
// StrictMode double-invokes effects and the shell remounts this card as the session probe
|
||||
// settles; four probes per opened link would exhaust the endpoint's per-IP budget in two reloads
|
||||
// and silently drop back to drawing the form unchecked.
|
||||
expect(checkPasswordReset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows the form when the check itself fails, so a broken probe locks nobody out", async () => {
|
||||
await mountCard("/#reset=live-token", {
|
||||
checkPasswordReset: () => Promise.reject(new Error("network")),
|
||||
});
|
||||
|
||||
expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the access-request notice from a denied Google sign-in", () => {
|
||||
it("names the address the request was recorded for", async () => {
|
||||
await mountCard("/#access=requested&email=nptemu%40gmail.com");
|
||||
|
||||
expect(screen.getByText("welcome.auth.accessRequestedFor")).toBeTruthy();
|
||||
expect(screen.getByText("nptemu@gmail.com")).toBeTruthy();
|
||||
expect(screen.queryByText("welcome.auth.accessLinked")).toBeNull();
|
||||
});
|
||||
|
||||
it("explains the alias when the request is already on file under another spelling", async () => {
|
||||
await mountCard(
|
||||
"/#access=requested&email=nptemu%40gmail.com&linked=np.temu%2Bsiftlode%40gmail.com",
|
||||
);
|
||||
|
||||
expect(screen.getByText("welcome.auth.accessLinked")).toBeTruthy();
|
||||
// %2B, not a bare `+`: URLSearchParams reads the latter as a space and would mangle exactly the
|
||||
// address family this epic exists to protect.
|
||||
expect(screen.getByText("np.temu+siftlode@gmail.com")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to the unnamed notice when the server sent no address", async () => {
|
||||
await mountCard("/#access=requested");
|
||||
|
||||
expect(screen.getByText("welcome.auth.accessRequested")).toBeTruthy();
|
||||
expect(screen.queryByText("welcome.auth.accessRequestedFor")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -94,7 +94,7 @@ export type PageDef = {
|
||||
export const PAGE_CONTENT: Record<Page, PageDef> = {
|
||||
feed: { Component: FeedPage },
|
||||
channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" },
|
||||
playlists: { Component: Playlists, maxWidth: "64rem" },
|
||||
playlists: { Component: Playlists, fadeTop: false, maxWidth: "64rem" },
|
||||
plex: { Component: PlexBrowse, maxWidth: "1600px" },
|
||||
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
|
||||
messages: { Component: MessagesPage },
|
||||
|
||||
@@ -8,14 +8,23 @@ import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "..
|
||||
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
|
||||
// state). Rendered by the browse grid, the person page, and the series show/season views.
|
||||
|
||||
// Playback cost, escalating good→heavy: direct (green) → remux, a light repackage
|
||||
// (amber) → transcode, a full re-encode (red). Reuses the status trio as a gradient;
|
||||
// all three stay visually distinct.
|
||||
const PLAYABLE_TINT: Record<string, string> = {
|
||||
// Playback cost, escalating good→heavy: Direct Play (green) → Direct Stream / remux, a light
|
||||
// repackage (blue) → Transcode · HW, a hardware re-encode (amber) → Transcode · SW, a software
|
||||
// re-encode (red). transcode splits HW vs SW by card.transcode_hw. Reuses the semantic tokens as a
|
||||
// gradient; all four stay visually distinct in light + dark.
|
||||
export const PLAYABLE_TINT: Record<string, string> = {
|
||||
direct: "bg-success",
|
||||
remux: "bg-warn",
|
||||
transcode: "bg-danger",
|
||||
remux: "bg-info",
|
||||
transcode_hw: "bg-warn",
|
||||
transcode_sw: "bg-danger",
|
||||
};
|
||||
// Collapse the wire `playable` (direct|remux|transcode) + transcode_hw flag into one of the 4 display
|
||||
// classes, so the corner dot's colour + tooltip can't drift from the sidebar filter's option keys.
|
||||
// Shared with the info-page playback chip (PlexInfo) so both surfaces derive the class identically.
|
||||
export function playableClass(playable: string, transcodeHw?: boolean | null): string {
|
||||
if (playable === "transcode") return transcodeHw ? "transcode_hw" : "transcode_sw";
|
||||
return playable; // direct | remux
|
||||
}
|
||||
|
||||
// The reveal class for a status pill (watched / in-progress) that shares a corner with a hover-reveal
|
||||
// control (the watched-toggle). When that control is present it takes over on touch, so the pill yields
|
||||
@@ -82,13 +91,22 @@ export function PlexPosterCard({
|
||||
decoding="async"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
{/* Playable-cost tint (always shown — a quick "will it direct-play" cue). */}
|
||||
{card.playable && (
|
||||
<span
|
||||
className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}
|
||||
title={t(`plex.playable.${card.playable}`)}
|
||||
/>
|
||||
)}
|
||||
{/* Playable-cost tint (always shown — a quick "will it direct-play" cue). z-20 keeps the dot
|
||||
ABOVE the full-card hover-reveal overlay below (absolute inset-0, no pointer-events-none):
|
||||
without it the overlay sits on top and swallows the cursor, so the dot's native `title`
|
||||
tooltip never fired on hover (a regression once the reveal overlay was added). */}
|
||||
{card.playable &&
|
||||
(() => {
|
||||
const cls = playableClass(card.playable, card.transcode_hw);
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={t(`plex.playable.${cls}`)}
|
||||
className={`absolute top-1.5 left-1.5 z-20 w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[cls] ?? "bg-gray-400"}`}
|
||||
title={t(`plex.playable.${cls}`)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{/* Rest-state watched badge (top-right). Yields to the hover quick-actions on a playable card
|
||||
(reveal-idle: gone on touch where the toggle is always shown); a watched show keeps it. */}
|
||||
{card.status === "watched" && (
|
||||
|
||||
@@ -110,6 +110,13 @@ export function buildPlexActiveFilters(
|
||||
remove: () => patch({ studios: filters.studios.filter((x) => x !== s) }),
|
||||
});
|
||||
}
|
||||
for (const p of filters.playable) {
|
||||
out.push({
|
||||
key: `playable:${p}`,
|
||||
label: t(`plex.filter.playableOpt.${p}`),
|
||||
remove: () => patch({ playable: filters.playable.filter((x) => x !== p) }),
|
||||
});
|
||||
}
|
||||
for (const k of filters.keywords) {
|
||||
out.push({
|
||||
key: `keyword:${k}`,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The login card's sub-views (Create an account / Forgot password / Didn't get the email) used to be
|
||||
// plain React state: clicking one changed nothing in history, so Back popped to whatever entry came
|
||||
// before the card and the address bar moved while the form on screen did not. They now ride
|
||||
// useHistorySubview, and this pins the wiring decisions that are specific to THIS card — which modes
|
||||
// are mirrored, and that Back walks the views. The hook's own mechanics live in
|
||||
// history.subview.dom.test.ts.
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
import { useHistorySubview } from "../lib/history";
|
||||
import { MODE_URL, type Mode } from "./Welcome";
|
||||
|
||||
const render = (root: Mode = "signin") => renderHook(() => useHistorySubview<Mode>(root, MODE_URL));
|
||||
|
||||
// jsdom's history.back() is asynchronous and does not rewind location, so — as in
|
||||
// history.subview.dom.test.ts — a Back is simulated by landing on the target entry and firing
|
||||
// popstate. `open()` pushing a REAL entry is asserted separately via history.length.
|
||||
const popTo = (search: string, state: unknown = null) =>
|
||||
act(() => {
|
||||
window.history.replaceState(state, "", search || "/");
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state }));
|
||||
});
|
||||
|
||||
// jsdom keeps one history across a file; land on a clean root entry before each test.
|
||||
beforeEach(() => window.history.replaceState(null, "", "/"));
|
||||
afterEach(() => window.history.replaceState(null, "", "/"));
|
||||
|
||||
describe("login card mode ↔ URL", () => {
|
||||
it("mirrors the three link-reachable views and leaves sign-in as the bare root", () => {
|
||||
expect(MODE_URL.encode("signin")).toBeNull();
|
||||
expect(MODE_URL.encode("register")).toBe("register");
|
||||
expect(MODE_URL.encode("forgot")).toBe("forgot");
|
||||
expect(MODE_URL.encode("resend")).toBe("resend");
|
||||
});
|
||||
|
||||
it("never mirrors the reset view", () => {
|
||||
// Reset is only reachable with the one-time token from the #reset fragment. Mirroring it would
|
||||
// hand out a bookmark that opens a password form with no token behind it.
|
||||
expect(MODE_URL.encode("reset")).toBeNull();
|
||||
expect(MODE_URL.decode("reset")).toBeNull();
|
||||
});
|
||||
|
||||
it("decodes only real views, so a hand-typed ?m= falls back to sign-in", () => {
|
||||
expect(MODE_URL.decode("register")).toBe("register");
|
||||
expect(MODE_URL.decode("")).toBeNull();
|
||||
expect(MODE_URL.decode("nonsense")).toBeNull();
|
||||
});
|
||||
|
||||
it("opening a view pushes a real history entry, and landing back on the root restores sign-in", () => {
|
||||
const { result } = render();
|
||||
const entries = window.history.length;
|
||||
expect(result.current.view).toBe("signin");
|
||||
|
||||
act(() => result.current.open("forgot"));
|
||||
|
||||
// The whole point: a Back has somewhere to go. Before this the click changed state only.
|
||||
expect(window.history.length).toBe(entries + 1);
|
||||
expect(result.current.view).toBe("forgot");
|
||||
expect(new URLSearchParams(window.location.search).get("m")).toBe("forgot");
|
||||
|
||||
popTo("/");
|
||||
|
||||
expect(result.current.view).toBe("signin");
|
||||
});
|
||||
|
||||
it("walks back one view at a time when the user hops between them", () => {
|
||||
const { result } = render();
|
||||
const entries = window.history.length;
|
||||
act(() => result.current.open("register"));
|
||||
act(() => result.current.open("forgot"));
|
||||
expect(window.history.length).toBe(entries + 2); // one entry per view, not one for the lot
|
||||
|
||||
popTo("/?m=register", { _sub: "register" });
|
||||
expect(result.current.view).toBe("register");
|
||||
|
||||
popTo("/");
|
||||
expect(result.current.view).toBe("signin");
|
||||
});
|
||||
|
||||
it("opens on the reset view when a reset token seeded it, without putting it in the URL", () => {
|
||||
const { result } = render("reset");
|
||||
expect(result.current.view).toBe("reset");
|
||||
expect(window.location.search).toBe("");
|
||||
});
|
||||
|
||||
it("restores a bookmarked view from the URL alone", () => {
|
||||
window.history.replaceState(null, "", "/?m=resend");
|
||||
const { result } = render();
|
||||
expect(result.current.view).toBe("resend");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The sign-in card's sub-views as the user meets them: clicking a link must leave a history entry,
|
||||
// so Back — including the mouse's back button — returns to the previous view. They used to be plain
|
||||
// React state, and Back moved only the address bar while the form on screen stayed put. The
|
||||
// encode/decode table lives in welcomeMode.dom.test.ts; this file renders the real card, so a revert
|
||||
// to useState fails here.
|
||||
//
|
||||
// i18n is mocked to a key-passthrough so queries read stable keys, and the API module is stubbed —
|
||||
// the card probes /auth/config on mount and must not reach the network.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: { ...actual.api, authConfig: () => Promise.resolve({ google_enabled: false }) },
|
||||
};
|
||||
});
|
||||
|
||||
import Welcome from "./Welcome";
|
||||
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
|
||||
// App confirms the emailed token and hands the outcome down; these tests are about the card's
|
||||
// sub-views, so there is nothing to report.
|
||||
const noVerify: VerifyEntry = {
|
||||
outcome: null,
|
||||
email: null,
|
||||
resetPending: false,
|
||||
dismiss: () => {},
|
||||
};
|
||||
|
||||
const back = () =>
|
||||
act(() => {
|
||||
// jsdom's history.back() is async and does not rewind location, so land on the previous entry
|
||||
// the way the browser would and fire popstate. The push itself is asserted via history.length.
|
||||
window.history.replaceState(null, "", "/");
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: null }));
|
||||
});
|
||||
|
||||
beforeEach(() => window.history.replaceState(null, "", "/"));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
describe("sign-in card sub-views and the Back button", () => {
|
||||
it("opens Forgot password as a history entry and comes back to sign in", async () => {
|
||||
render(<Welcome verify={noVerify} />);
|
||||
expect(await screen.findByText("welcome.auth.signinTitle")).toBeTruthy();
|
||||
const entries = window.history.length;
|
||||
|
||||
fireEvent.click(screen.getByText("welcome.auth.forgotLink"));
|
||||
|
||||
expect(screen.getByText("welcome.auth.forgotTitle")).toBeTruthy();
|
||||
expect(window.history.length).toBe(entries + 1); // a Back now has somewhere to go
|
||||
expect(new URLSearchParams(window.location.search).get("m")).toBe("forgot");
|
||||
|
||||
back();
|
||||
|
||||
expect(screen.getByText("welcome.auth.signinTitle")).toBeTruthy();
|
||||
expect(screen.queryByText("welcome.auth.forgotTitle")).toBeNull();
|
||||
});
|
||||
|
||||
it("does the same for Create an account and the verification-email link", async () => {
|
||||
render(<Welcome verify={noVerify} />);
|
||||
await screen.findByText("welcome.auth.signinTitle");
|
||||
|
||||
fireEvent.click(screen.getByText("welcome.auth.createLink"));
|
||||
expect(screen.getByText("welcome.auth.createTitle")).toBeTruthy();
|
||||
back();
|
||||
expect(screen.getByText("welcome.auth.signinTitle")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByText("welcome.auth.resendLink"));
|
||||
expect(screen.getByText("welcome.auth.resendTitle")).toBeTruthy();
|
||||
back();
|
||||
expect(screen.getByText("welcome.auth.signinTitle")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// The signed-OUT half of the verification outcome: App confirms the emailed token above its
|
||||
// signed-in branch and hands the result down, so the card no longer posts anything itself. These pin
|
||||
// that each outcome still reaches the right banner — and that the banner is announced (role=status),
|
||||
// which the 2026-09-02 review asked for and which is the whole point when the alternative was a
|
||||
// screen that said nothing at all.
|
||||
describe("the verification outcome handed down from App", () => {
|
||||
const withOutcome = (outcome: VerifyEntry["outcome"]): VerifyEntry => ({
|
||||
outcome,
|
||||
email: "someone@example.com",
|
||||
resetPending: false,
|
||||
dismiss: () => {},
|
||||
});
|
||||
|
||||
it("announces a confirmed address", async () => {
|
||||
render(<Welcome verify={withOutcome("ok")} />);
|
||||
const banner = await screen.findByText("welcome.auth.verifyOk");
|
||||
expect(banner.closest('[role="status"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers the reset flow when the account's password had been revoked", async () => {
|
||||
render(<Welcome verify={withOutcome("ok_reset")} />);
|
||||
expect(await screen.findByText("welcome.auth.verifyOkReset")).toBeTruthy();
|
||||
expect(screen.getByText("welcome.auth.verifyOkResetCta")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers a new link when the token is spent", async () => {
|
||||
render(<Welcome verify={withOutcome("invalid")} />);
|
||||
expect(await screen.findByText("welcome.auth.verifyInvalid")).toBeTruthy();
|
||||
expect(screen.getByText("welcome.auth.resendCta")).toBeTruthy();
|
||||
});
|
||||
|
||||
// DX-16: a transport failure is not a verdict on the token, so it must NOT offer a fresh link —
|
||||
// that would act on a statement we know to be false. Retrying the same link is the way out.
|
||||
it("does not offer a new link when the server was merely unreachable", async () => {
|
||||
render(<Welcome verify={withOutcome("unreachable")} />);
|
||||
expect(await screen.findByText("welcome.auth.verifyUnreachable")).toBeTruthy();
|
||||
expect(screen.queryByText("welcome.auth.resendCta")).toBeNull();
|
||||
expect(screen.queryByText("welcome.auth.verifyInvalid")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no banner when there was no token to confirm", async () => {
|
||||
render(<Welcome verify={noVerify} />);
|
||||
await screen.findByText("welcome.auth.signinTitle");
|
||||
expect(screen.queryByText("welcome.auth.verifyOk")).toBeNull();
|
||||
expect(screen.queryByText("welcome.auth.verifyInvalid")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,9 @@
|
||||
"user_suspend": "User suspended",
|
||||
"user_unsuspend": "User reinstated",
|
||||
"user_delete": "User deleted",
|
||||
"user_activate": "Account activated",
|
||||
"user_verify_resend": "Confirmation link re-sent",
|
||||
"user_setpw_link": "Set-password link issued",
|
||||
"invite_approve": "Access request approved",
|
||||
"invite_deny": "Access request denied",
|
||||
"invite_add": "Email whitelisted",
|
||||
@@ -19,5 +22,7 @@
|
||||
"job_run": "Scheduler job run",
|
||||
"audit_clear": "Audit log cleared",
|
||||
"download_credential_set": "Download cookies set",
|
||||
"download_credential_delete": "Download cookies removed"
|
||||
"download_credential_delete": "Download cookies removed",
|
||||
"invite_delete": "Whitelist entry deleted",
|
||||
"invite_send": "Invitation sent"
|
||||
}
|
||||
|
||||
@@ -216,5 +216,6 @@
|
||||
"plexPickLibraries": "Libraries to expose:",
|
||||
"plexPickHint": "Unchecking all is the same as exposing every library. Remember to Save.",
|
||||
"plexMovies": "Movies",
|
||||
"plexShows": "TV Shows"
|
||||
"plexShows": "TV Shows",
|
||||
"emailOff": "Email sending is not configured — Siftlode silently skips every message: verification links, password resets and access-request notices. Fill in the fields above, then send a test."
|
||||
}
|
||||
|
||||
@@ -229,6 +229,14 @@
|
||||
"tooLarge": "That file is too large (max {{size}}).",
|
||||
"empty": "That file is empty."
|
||||
},
|
||||
"urlBlocked": {
|
||||
"scheme": "Only http:// and https:// links can be downloaded.",
|
||||
"no_host": "That link doesn't contain a website address.",
|
||||
"unresolvable": "That address couldn't be looked up. Check the link and try again.",
|
||||
"private": "That address is on the server's own network, so it can't be downloaded from.",
|
||||
"generic": "That address can't be downloaded from.",
|
||||
"malformed": "That link isn't a valid web address."
|
||||
},
|
||||
"fail": {
|
||||
"title": "Download failed",
|
||||
"detailsLink": "details",
|
||||
@@ -273,6 +281,10 @@
|
||||
"unknown": {
|
||||
"short": "Download failed",
|
||||
"body": "Something went wrong while downloading this item. The technical details below may help."
|
||||
},
|
||||
"quota_exceeded": {
|
||||
"short": "Storage full",
|
||||
"body": "This download was stopped because your storage quota is already used up. Delete something from your library, then try again."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user