feat: release toolbox portal 0.1.0
This commit is contained in:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
coverage
|
||||
artifacts
|
||||
dist
|
||||
build/*
|
||||
!build/toolbox
|
||||
!build/toolbox/**
|
||||
*.log
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
artifacts/
|
||||
coverage/
|
||||
public/THIRD_PARTY_LICENSES.txt
|
||||
*.log
|
||||
.DS_Store
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
@add-ideas:registry=https://git.add-ideas.de/api/packages/zemion/npm/
|
||||
9
.prettierignore
Normal file
9
.prettierignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
coverage
|
||||
dist
|
||||
build
|
||||
artifacts
|
||||
*.zip
|
||||
LICENSE
|
||||
public/LICENSE.txt
|
||||
public/THIRD_PARTY_LICENSES.txt
|
||||
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 80
|
||||
}
|
||||
10
Containerfile
Normal file
10
Containerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
ARG NGINX_IMAGE=nginxinc/nginx-unprivileged:1.31.3-alpine@sha256:18d67281256ded39ff65e010ae4f831be18f19356f83c60bc546492c7eb6dd23
|
||||
FROM ${NGINX_IMAGE}
|
||||
|
||||
COPY --chown=101:101 deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --chown=101:101 build/toolbox/ /usr/share/nginx/html/
|
||||
|
||||
USER 101:101
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --quiet --spider http://127.0.0.1:8080/toolbox.catalog.json || exit 1
|
||||
661
LICENSE
Normal file
661
LICENSE
Normal file
@@ -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/>.
|
||||
222
README.md
Normal file
222
README.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# add·ideas Toolbox Portal
|
||||
|
||||
`toolbox-portal` is the static launcher and release assembler for the add·ideas
|
||||
browser toolbox. Version `0.1.0` reads one same-origin catalogue, shows each app
|
||||
as a full-page launch card, and keeps personal pins, ordering, visibility, and
|
||||
appearance in the current browser.
|
||||
|
||||
The v1 boundary is intentionally small:
|
||||
|
||||
- apps remain separately versioned and released repositories;
|
||||
- `@add-ideas/toolbox-contract` owns manifest, catalogue, and URL validation;
|
||||
- the portal navigates to apps—there are no iframes, plugins, remote modules, or
|
||||
runtime-loaded application code;
|
||||
- the assembler copies already-built, exact ZIP artifacts and never builds app
|
||||
source or resolves a `latest` release.
|
||||
|
||||
## Development
|
||||
|
||||
Requirements: Node.js 22 or newer and npm 11 or newer. The committed `.npmrc`
|
||||
selects the public, token-free package scope on Gitea; registry credentials are
|
||||
not required to install the Toolbox SDK packages. Install and verify the portal:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm test
|
||||
npm run lint
|
||||
npm run build
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The package lock resolves the `^0.1.0` SDK range to the published `0.1.1`
|
||||
packages. A sibling SDK checkout is only needed when intentionally developing
|
||||
the SDK and portal together.
|
||||
|
||||
Vite uses `base: './'`, so the built portal works at `/` or a nested static path.
|
||||
The development catalogue at `public/toolbox.catalog.json` points to assembled
|
||||
paths (`./apps/pdf/` and `./apps/xslt/`). Those manifests will correctly appear
|
||||
as unavailable until an assembled release is being served.
|
||||
|
||||
## Catalogue and launches
|
||||
|
||||
The portal fetches `./toolbox.catalog.json`, validates it with the shared
|
||||
contract, and then validates each enabled manifest. A bad individual manifest is
|
||||
reported without hiding valid apps; a bad or unreachable catalogue gets a retry
|
||||
state. An empty valid catalogue gets its own empty state.
|
||||
|
||||
Internal launch links stay same-origin and receive the exact catalogue URL in a
|
||||
`toolbox` query parameter. For example:
|
||||
|
||||
```text
|
||||
/apps/pdf/?toolbox=https%3A%2F%2Ftoolbox.example%2Ftoolbox.catalog.json
|
||||
```
|
||||
|
||||
Apps discover that context through the shared shell/contract and retain their
|
||||
standalone fallback when no context is present. External catalogue entries open
|
||||
according to their declared launch mode and do not receive toolbox context.
|
||||
|
||||
Personal settings use the namespaced local-storage key
|
||||
`@add-ideas/toolbox-portal:v1:preferences`. The preferences panel can reset,
|
||||
export, and import the versioned JSON document. The portal itself does not
|
||||
transmit preferences. Because Toolbox apps share an origin, code explicitly
|
||||
trusted in an app can access same-origin browser storage; review each app's
|
||||
privacy and executable-code warning. Light, dark, and system modes are supported.
|
||||
|
||||
## Exact release assembly
|
||||
|
||||
Every app release ZIP must put these items at its archive root:
|
||||
|
||||
```text
|
||||
index.html (or the entry declared by toolbox-app.json)
|
||||
toolbox-app.json
|
||||
CHANGELOG.md
|
||||
LICENSES/
|
||||
SOURCE.md
|
||||
...built static assets
|
||||
```
|
||||
|
||||
The app release should also publish a matching `.sha256` sidecar. The manifest
|
||||
must declare the pinned reverse-DNS id and version. Application and portal
|
||||
versions are independent.
|
||||
|
||||
`release/toolbox.lock.json` is the reviewed v0.1.0 lock. Its URLs and checksums
|
||||
pin the published PDF Tools 0.3.4 and XSLT Tools 0.3.2 release bytes. Use
|
||||
`release/toolbox.lock.example.json` as the template for a future release and
|
||||
verify every downloaded asset before committing updated values. Artifacts may be:
|
||||
|
||||
- a path relative to the lock file;
|
||||
- a `file:` URL; or
|
||||
- a credential-free HTTPS URL to an immutable release asset.
|
||||
|
||||
For a private Gitea release, download the exact asset first and put its local
|
||||
path in the lock. Do not put credentials in a lock URL.
|
||||
|
||||
Build the portal and assemble the distribution:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm test
|
||||
npm run build
|
||||
npm run assemble -- \
|
||||
--lock release/toolbox.lock.json \
|
||||
--portal-dist dist \
|
||||
--output build/toolbox \
|
||||
--archive build/add-ideas-toolbox-0.1.0.zip
|
||||
```
|
||||
|
||||
Existing output is refused. Pass `--force` only when replacing those exact
|
||||
paths is intended. A successful run produces:
|
||||
|
||||
```text
|
||||
build/toolbox/
|
||||
├── index.html
|
||||
├── toolbox.catalog.json generated from the lock
|
||||
├── toolbox.release.json ids, versions, targets, checksums
|
||||
├── LICENSE.txt
|
||||
├── SOURCE.md
|
||||
├── THIRD_PARTY_LICENSES.txt
|
||||
├── THIRD_PARTY_NOTICES.md
|
||||
└── apps/
|
||||
├── pdf/
|
||||
└── xslt/
|
||||
|
||||
build/add-ideas-toolbox-0.1.0.zip
|
||||
build/add-ideas-toolbox-0.1.0.zip.sha256
|
||||
```
|
||||
|
||||
The assembler verifies SHA-256 before opening an artifact, validates every app
|
||||
manifest with `@add-ideas/toolbox-contract`, requires its id/version to equal the
|
||||
lock, and smoke-checks each declared entry, icon, and packaged manifest asset
|
||||
after extraction. It rejects insecure or credential-bearing redirects, HTTP,
|
||||
`latest` aliases, ZIP traversal, absolute paths, symlinks, special files,
|
||||
duplicate paths, oversized entries, and unsafe compression ratios. Canonical
|
||||
path checks protect source and output trees even through symlink aliases, and
|
||||
publication rolls all outputs back if a staged rename fails. Artifacts are
|
||||
unpacked only under `apps/<locked-target>`. The assembler never runs artifact
|
||||
content.
|
||||
|
||||
The archive writer sorts file names and fixes ZIP timestamps and modes so the
|
||||
same assembled directory produces stable bytes. To package an already assembled
|
||||
directory separately:
|
||||
|
||||
```sh
|
||||
npm run package:static -- \
|
||||
--input build/toolbox \
|
||||
--output artifacts/add-ideas-toolbox-0.1.0.zip
|
||||
```
|
||||
|
||||
This also emits a `.sha256` sidecar.
|
||||
|
||||
## Container image
|
||||
|
||||
`Containerfile` serves only the assembled files with unprivileged nginx on port 8080. It adds restrictive browser headers, same-origin isolation, immutable
|
||||
caching only for Vite-hashed assets, and revalidation for all other files.
|
||||
Assemble first, then:
|
||||
|
||||
The packaged policy intentionally does not grant CSP `unsafe-eval`. SaxonJS's
|
||||
`ixsl:eval()` extension is therefore unsupported in this deployment profile;
|
||||
normal local XML/XSLT transformations do not require that permission.
|
||||
|
||||
```sh
|
||||
podman build -f Containerfile \
|
||||
-t git.add-ideas.de/zemion/toolbox:0.1.0 .
|
||||
podman run --rm -p 8080:8080 \
|
||||
git.add-ideas.de/zemion/toolbox:0.1.0
|
||||
```
|
||||
|
||||
For a local deployment, copy `compose.example.yaml` to `compose.yaml` and run
|
||||
`docker compose up --build -d` (or the Podman Compose equivalent).
|
||||
|
||||
Publish both common architectures from a buildx-enabled workstation or Gitea
|
||||
runner:
|
||||
|
||||
```sh
|
||||
docker login git.add-ideas.de
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file Containerfile \
|
||||
--tag git.add-ideas.de/zemion/toolbox:0.1.0 \
|
||||
--tag git.add-ideas.de/zemion/toolbox:0.1 \
|
||||
--push .
|
||||
```
|
||||
|
||||
The default unprivileged nginx image is the security-fixed `1.31.3-alpine`
|
||||
baseline pinned to its reviewed multi-architecture digest. Re-resolve and review
|
||||
that digest whenever the base image is upgraded, then record it in the release
|
||||
notes.
|
||||
|
||||
## Manual Gitea publication checklist
|
||||
|
||||
No credentials belong in this repository. Before the first release, create the
|
||||
`zemion/toolbox-portal` Gitea repository and grant the workstation or runner
|
||||
permission to push code, releases, and container packages.
|
||||
|
||||
1. Run `npm ci`, `npm test`, `npm run lint`, and `npm run build` from a clean
|
||||
checkout of the intended portal tag.
|
||||
2. Publish each app's versioned ZIP, `.sha256`, `toolbox-app.json`, changelog,
|
||||
and licence notices in its own Gitea release.
|
||||
3. Verify downloaded app assets with `sha256sum -c <asset>.sha256`; copy those
|
||||
exact values into a reviewed toolbox lock.
|
||||
4. Run the assembler and serve `build/toolbox` from a nested test path. Open
|
||||
both apps, switch between them, and confirm the encoded `toolbox` context.
|
||||
5. Verify the distribution with
|
||||
`(cd build && sha256sum -c add-ideas-toolbox-0.1.0.zip.sha256)`.
|
||||
6. Commit the reviewed lock, tag the portal source (for example `v0.1.0`), and
|
||||
push the branch and tag to Gitea.
|
||||
7. In Gitea, open **Releases → New release**, select the tag, and upload the
|
||||
static ZIP plus its `.sha256` file. Do not use a mutable “latest” URL in a
|
||||
future lock.
|
||||
8. Build and push the AMD64/ARM64 OCI image as shown above. Record the resulting
|
||||
multi-architecture manifest digest in the release notes.
|
||||
|
||||
For Gitea Actions, store registry credentials as repository secrets, check out
|
||||
the exact tag, install with `npm ci`, run the same verification/assembly commands,
|
||||
and upload only the already-created ZIP/checksum and OCI image. The workflow
|
||||
must not re-resolve app versions or substitute a newer release.
|
||||
|
||||
## Licensing
|
||||
|
||||
Portal source is `AGPL-3.0-only`; see `LICENSE`. The contract is Apache-2.0, and
|
||||
each assembled application retains its own licence and notices. An assembled
|
||||
ZIP/container is an aggregate of those independently licensed components; see
|
||||
`THIRD_PARTY_NOTICES.md` and each `apps/<slug>/LICENSES/` directory.
|
||||
21
THIRD_PARTY_NOTICES.md
Normal file
21
THIRD_PARTY_NOTICES.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Third-party notices
|
||||
|
||||
The portal source is licensed under `AGPL-3.0-only`; see `LICENSE`. An assembled
|
||||
toolbox is a collection of independently licensed works, not a relicensing of
|
||||
every included component under one licence.
|
||||
|
||||
Runtime components used by the portal include:
|
||||
|
||||
- React and React DOM — MIT
|
||||
- `@add-ideas/toolbox-contract` — Apache-2.0
|
||||
|
||||
Their complete installed license and notice texts are generated into
|
||||
`THIRD_PARTY_LICENSES.txt` for every production build.
|
||||
|
||||
Build and test tooling includes Vite, TypeScript, Vitest, Testing Library,
|
||||
ESLint, Archiver, and yauzl under their respective licences. Their package
|
||||
metadata and licence texts are available from the installed npm packages.
|
||||
|
||||
Each application release remains independently licensed. Its static files are
|
||||
copied without modification into `apps/<slug>/`; corresponding notices are
|
||||
carried inside that application's `LICENSES/` directory.
|
||||
18
compose.example.yaml
Normal file
18
compose.example.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
toolbox:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Containerfile
|
||||
image: git.add-ideas.de/zemion/toolbox:0.1.0
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
ports:
|
||||
- '8080:8080'
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
- /var/cache/nginx:size=16m,uid=101,gid=101
|
||||
- /var/run:size=1m,uid=101,gid=101
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
42
deploy/nginx.conf
Normal file
42
deploy/nginx.conf
Normal file
@@ -0,0 +1,42 @@
|
||||
map $uri $toolbox_cache_control {
|
||||
default "no-cache";
|
||||
~^/(?:.*/)?assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+$ "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
listen [::]:8080;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
server_tokens off;
|
||||
charset utf-8;
|
||||
etag on;
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'" always;
|
||||
add_header Cache-Control $toolbox_cache_control always;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types application/json application/javascript application/wasm image/svg+xml text/css text/plain text/xml;
|
||||
|
||||
location ~* /(?:toolbox\.catalog|toolbox\.release|toolbox-app)\.json$ {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~* \.(?:css|js|mjs|wasm|woff2?|png|jpe?g|gif|webp|svg|ico|webmanifest)$ {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
32
eslint.config.mjs
Normal file
32
eslint.config.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'build', 'artifacts', 'coverage'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['scripts/**/*.mjs'],
|
||||
languageOptions: { globals: globals.node },
|
||||
}
|
||||
);
|
||||
19
index.html
Normal file
19
index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#172554" />
|
||||
<meta
|
||||
name="description"
|
||||
content="A privacy-transparent browser toolbox for PDF and XSLT workflows."
|
||||
/>
|
||||
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
|
||||
<title>add·ideas Toolbox</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4980
package-lock.json
generated
Normal file
4980
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
package.json
Normal file
53
package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@add-ideas/toolbox-portal",
|
||||
"version": "0.1.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"prebuild": "npm run licenses:generate",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc -b",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"check": "npm run format:check && npm run lint && npm run test && npm run build",
|
||||
"licenses:generate": "node scripts/licenses.mjs",
|
||||
"assemble": "node scripts/assemble.mjs",
|
||||
"package:static": "node scripts/package-static.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "^0.1.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"archiver": "^7.0.1",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"vite": "^8.0.13",
|
||||
"vitest": "^4.1.6",
|
||||
"yauzl": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
661
public/LICENSE.txt
Normal file
661
public/LICENSE.txt
Normal file
@@ -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/>.
|
||||
8
public/SOURCE.md
Normal file
8
public/SOURCE.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Corresponding source
|
||||
|
||||
The source code corresponding to add·ideas Toolbox Portal 0.1.0 is available at:
|
||||
|
||||
https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.1.0
|
||||
|
||||
Each bundled application contains its own `SOURCE.md`, license, and third-party
|
||||
license materials. The application sources are also linked from the portal.
|
||||
11
public/THIRD_PARTY_NOTICES.md
Normal file
11
public/THIRD_PARTY_NOTICES.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Third-party notices
|
||||
|
||||
The add·ideas Toolbox portal is licensed under `AGPL-3.0-only`. An assembled
|
||||
toolbox is a collection of independently licensed works, not a relicensing of
|
||||
every included component under one licence.
|
||||
|
||||
Portal runtime components include React and React DOM (MIT) and
|
||||
`@add-ideas/toolbox-contract` (Apache-2.0). Their complete installed license and
|
||||
notice texts are shipped in `THIRD_PARTY_LICENSES.txt`. Each application remains
|
||||
under its own licence. Application notices are shipped inside its
|
||||
`apps/<slug>/LICENSES/` directory.
|
||||
5
public/favicon.svg
Normal file
5
public/favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<rect width="48" height="48" rx="12" fill="#172554"/>
|
||||
<path d="M8 15 24 6l16 9v18L24 42 8 33z" fill="#eef2ff" opacity=".15"/>
|
||||
<path d="m8 15 16 9 16-9M24 24v18M16 10.5l16 9v9L24 33l-8-4.5z" fill="none" stroke="#eef2ff" stroke-linecap="round" stroke-linejoin="round" stroke-width="3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 359 B |
21
public/toolbox.catalog.json
Normal file
21
public/toolbox.catalog.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.toolbox",
|
||||
"name": "add·ideas Toolbox",
|
||||
"home": "./",
|
||||
"theme": {
|
||||
"mode": "system",
|
||||
"brand": "add·ideas"
|
||||
},
|
||||
"apps": [
|
||||
{
|
||||
"manifest": "./apps/pdf/toolbox-app.json",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"manifest": "./apps/xslt/toolbox-app.json",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
70
release/toolbox-release-lock.schema.json
Normal file
70
release/toolbox-release-lock.schema.json
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.add-ideas.de/zemion/toolbox-portal/raw/branch/main/release/toolbox-release-lock.schema.json",
|
||||
"title": "add·ideas Toolbox release lock v1",
|
||||
"description": "Pins the exact portal and checksummed application archives used to assemble one static toolbox release.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"releaseVersion",
|
||||
"portalVersion",
|
||||
"catalogue",
|
||||
"apps"
|
||||
],
|
||||
"properties": {
|
||||
"$schema": { "type": "string", "format": "uri-reference" },
|
||||
"schemaVersion": { "const": 1 },
|
||||
"releaseVersion": { "$ref": "#/$defs/version" },
|
||||
"portalVersion": { "$ref": "#/$defs/version" },
|
||||
"catalogue": {
|
||||
"type": "object",
|
||||
"required": ["id", "name", "home", "theme"],
|
||||
"properties": {
|
||||
"id": { "$ref": "#/$defs/id" },
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"home": { "type": "string", "minLength": 1 },
|
||||
"theme": {
|
||||
"type": "object",
|
||||
"required": ["mode", "brand"],
|
||||
"properties": {
|
||||
"mode": { "enum": ["light", "dark", "system"] },
|
||||
"brand": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"apps": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id", "version", "artifact", "sha256", "target"],
|
||||
"properties": {
|
||||
"id": { "$ref": "#/$defs/id" },
|
||||
"version": { "$ref": "#/$defs/version" },
|
||||
"artifact": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "A path relative to this lock, a file: URL, or an HTTPS URL. Latest aliases are rejected at runtime."
|
||||
},
|
||||
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
||||
"target": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"id": { "type": "string", "pattern": "^[a-z0-9]+(?:[.-][a-z0-9]+)+$" },
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
31
release/toolbox.lock.example.json
Normal file
31
release/toolbox.lock.example.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "./toolbox-release-lock.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"releaseVersion": "0.1.0",
|
||||
"portalVersion": "0.1.0",
|
||||
"catalogue": {
|
||||
"id": "de.add-ideas.toolbox",
|
||||
"name": "add·ideas Toolbox",
|
||||
"home": "./",
|
||||
"theme": {
|
||||
"mode": "system",
|
||||
"brand": "add·ideas"
|
||||
}
|
||||
},
|
||||
"apps": [
|
||||
{
|
||||
"id": "de.add-ideas.pdf-tools",
|
||||
"version": "0.3.4",
|
||||
"artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.3.4/pdf-tools-0.3.4.zip",
|
||||
"sha256": "090492aaee4bd5cde51917ee3d9d0c971f071c27b7dbe4c918876ffd752dd930",
|
||||
"target": "pdf"
|
||||
},
|
||||
{
|
||||
"id": "de.add-ideas.xslt-tools",
|
||||
"version": "0.3.2",
|
||||
"artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.3.2/xslt-tools-0.3.2.zip",
|
||||
"sha256": "e1746ac60f7143037ce77d7f04c62f7da39a92d8cef42e7167acb2bc00786de1",
|
||||
"target": "xslt"
|
||||
}
|
||||
]
|
||||
}
|
||||
31
release/toolbox.lock.json
Normal file
31
release/toolbox.lock.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "./toolbox-release-lock.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"releaseVersion": "0.1.0",
|
||||
"portalVersion": "0.1.0",
|
||||
"catalogue": {
|
||||
"id": "de.add-ideas.toolbox",
|
||||
"name": "add·ideas Toolbox",
|
||||
"home": "./",
|
||||
"theme": {
|
||||
"mode": "system",
|
||||
"brand": "add·ideas"
|
||||
}
|
||||
},
|
||||
"apps": [
|
||||
{
|
||||
"id": "de.add-ideas.pdf-tools",
|
||||
"version": "0.3.4",
|
||||
"artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.3.4/pdf-tools-0.3.4.zip",
|
||||
"sha256": "090492aaee4bd5cde51917ee3d9d0c971f071c27b7dbe4c918876ffd752dd930",
|
||||
"target": "pdf"
|
||||
},
|
||||
{
|
||||
"id": "de.add-ideas.xslt-tools",
|
||||
"version": "0.3.2",
|
||||
"artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.3.2/xslt-tools-0.3.2.zip",
|
||||
"sha256": "e1746ac60f7143037ce77d7f04c62f7da39a92d8cef42e7167acb2bc00786de1",
|
||||
"target": "xslt"
|
||||
}
|
||||
]
|
||||
}
|
||||
77
scripts/archive.mjs
Normal file
77
scripts/archive.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import { mkdir, readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import archiver from 'archiver';
|
||||
import {
|
||||
canonicalExistingPath,
|
||||
canonicalPathsOverlap,
|
||||
canonicalProspectivePath,
|
||||
} from './path-safety.mjs';
|
||||
|
||||
const ARCHIVE_DATE = new Date('1980-01-01T00:00:00.000Z');
|
||||
|
||||
function compareCodePoints(left, right) {
|
||||
const leftPoints = Array.from(left, (value) => value.codePointAt(0));
|
||||
const rightPoints = Array.from(right, (value) => value.codePointAt(0));
|
||||
const length = Math.min(leftPoints.length, rightPoints.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (leftPoints[index] !== rightPoints[index])
|
||||
return leftPoints[index] - rightPoints[index];
|
||||
}
|
||||
return leftPoints.length - rightPoints.length;
|
||||
}
|
||||
|
||||
async function listFiles(root, relative = '') {
|
||||
const directory = path.join(root, relative);
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries.sort((left, right) =>
|
||||
compareCodePoints(left.name, right.name)
|
||||
)) {
|
||||
const child = path.posix.join(
|
||||
relative.split(path.sep).join('/'),
|
||||
entry.name
|
||||
);
|
||||
if (entry.isDirectory()) files.push(...(await listFiles(root, child)));
|
||||
else if (entry.isFile()) files.push(child);
|
||||
else
|
||||
throw new Error(
|
||||
`Archive input contains an unsupported file type: ${child}`
|
||||
);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function createStaticArchive(inputDirectory, outputFile) {
|
||||
const source = await canonicalExistingPath(
|
||||
inputDirectory,
|
||||
'Archive input directory'
|
||||
);
|
||||
const output = await canonicalProspectivePath(outputFile);
|
||||
if (canonicalPathsOverlap(source, output))
|
||||
throw new Error('Archive output and input directory must not overlap.');
|
||||
const sourceStat = await stat(source);
|
||||
if (!sourceStat.isDirectory())
|
||||
throw new Error(`Archive input is not a directory: ${source}`);
|
||||
await mkdir(path.dirname(output), { recursive: true });
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
const destination = createWriteStream(output, { flags: 'wx' });
|
||||
archive.on('warning', (error) => {
|
||||
if (error.code !== 'ENOENT') destination.destroy(error);
|
||||
});
|
||||
archive.on('error', (error) => destination.destroy(error));
|
||||
|
||||
const writing = pipeline(archive, destination);
|
||||
for (const relative of await listFiles(source)) {
|
||||
archive.append(createReadStream(path.join(source, relative)), {
|
||||
name: relative,
|
||||
date: ARCHIVE_DATE,
|
||||
mode: 0o644,
|
||||
});
|
||||
}
|
||||
await archive.finalize();
|
||||
await writing;
|
||||
return output;
|
||||
}
|
||||
145
scripts/artifact.mjs
Normal file
145
scripts/artifact.mjs
Normal file
@@ -0,0 +1,145 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { constants, createReadStream, createWriteStream } from 'node:fs';
|
||||
import { copyFile, lstat, mkdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Readable, Transform } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const MAX_DOWNLOAD_SIZE = 1024 * 1024 * 1024;
|
||||
const MAX_REDIRECTS = 5;
|
||||
|
||||
function usesLatestAlias(value) {
|
||||
let decoded = value;
|
||||
try {
|
||||
decoded = decodeURIComponent(value);
|
||||
} catch {
|
||||
// The URL parser below reports malformed encodings where relevant.
|
||||
}
|
||||
return /(?:^|[^a-z0-9])latest(?:[^a-z0-9]|$)/i.test(decoded);
|
||||
}
|
||||
|
||||
function parseArtifactSource(source, lockDirectory) {
|
||||
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(source)) {
|
||||
const url = new URL(source);
|
||||
if (url.protocol === 'file:')
|
||||
return { type: 'local', path: fileURLToPath(url) };
|
||||
if (url.protocol === 'https:') {
|
||||
return { type: 'https', url: validateHttpsArtifactUrl(url) };
|
||||
}
|
||||
throw new Error(`Artifact URL protocol is not allowed: ${url.protocol}`);
|
||||
}
|
||||
return { type: 'local', path: path.resolve(lockDirectory, source) };
|
||||
}
|
||||
|
||||
function validateHttpsArtifactUrl(value, label = 'Artifact HTTPS URL') {
|
||||
const url = value instanceof URL ? value : new URL(value);
|
||||
if (url.protocol !== 'https:')
|
||||
throw new Error(`${label} must use HTTPS: ${url.href}`);
|
||||
if (url.username || url.password)
|
||||
throw new Error(`${label} must not contain credentials.`);
|
||||
if (usesLatestAlias(url.href))
|
||||
throw new Error(`${label} must not use a latest alias.`);
|
||||
return url;
|
||||
}
|
||||
|
||||
async function fetchArtifact(url) {
|
||||
let current = validateHttpsArtifactUrl(url);
|
||||
|
||||
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
|
||||
const response = await fetch(current, {
|
||||
redirect: 'manual',
|
||||
headers: { Accept: 'application/zip, application/octet-stream' },
|
||||
});
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
if (redirects === MAX_REDIRECTS)
|
||||
throw new Error(
|
||||
`Artifact download exceeded ${MAX_REDIRECTS} redirects.`
|
||||
);
|
||||
const location = response.headers.get('location');
|
||||
if (!location)
|
||||
throw new Error('Artifact redirect did not include a Location header.');
|
||||
current = validateHttpsArtifactUrl(
|
||||
new URL(location, current),
|
||||
'Artifact redirect URL'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (response.url) {
|
||||
const finalUrl = validateHttpsArtifactUrl(
|
||||
new URL(response.url),
|
||||
'Artifact final response URL'
|
||||
);
|
||||
if (finalUrl.href !== current.href) {
|
||||
throw new Error(
|
||||
`Artifact fetch unexpectedly changed URL from ${current.href} to ${finalUrl.href}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
return { response, finalUrl: current };
|
||||
}
|
||||
|
||||
throw new Error(`Artifact download exceeded ${MAX_REDIRECTS} redirects.`);
|
||||
}
|
||||
|
||||
export async function sha256File(file) {
|
||||
const hash = createHash('sha256');
|
||||
for await (const chunk of createReadStream(file)) hash.update(chunk);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
export async function acquireArtifact(
|
||||
source,
|
||||
expectedSha256,
|
||||
lockDirectory,
|
||||
downloadDirectory
|
||||
) {
|
||||
const parsed = parseArtifactSource(source, lockDirectory);
|
||||
await mkdir(downloadDirectory, { recursive: true });
|
||||
const file = path.join(downloadDirectory, `${expectedSha256}.zip`);
|
||||
let downloaded = false;
|
||||
if (parsed.type === 'local') {
|
||||
const details = await lstat(parsed.path);
|
||||
if (!details.isFile() || details.isSymbolicLink())
|
||||
throw new Error(`Local artifact is not a regular file: ${parsed.path}`);
|
||||
// Work from a private snapshot. The checksum below therefore covers the
|
||||
// exact bytes later inspected and extracted, even if the source is replaced.
|
||||
await copyFile(parsed.path, file, constants.COPYFILE_EXCL);
|
||||
} else {
|
||||
const { response, finalUrl } = await fetchArtifact(parsed.url);
|
||||
if (!response.ok || !response.body)
|
||||
throw new Error(
|
||||
`Artifact download failed with HTTP ${response.status}: ${finalUrl.href}`
|
||||
);
|
||||
const declaredLength = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > MAX_DOWNLOAD_SIZE)
|
||||
throw new Error('Artifact download exceeds the size limit.');
|
||||
let bytes = 0;
|
||||
const limiter = new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
bytes += chunk.length;
|
||||
callback(
|
||||
bytes > MAX_DOWNLOAD_SIZE
|
||||
? new Error('Artifact download exceeds the size limit.')
|
||||
: null,
|
||||
chunk
|
||||
);
|
||||
},
|
||||
});
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body),
|
||||
limiter,
|
||||
createWriteStream(file, { flags: 'wx', mode: 0o600 })
|
||||
);
|
||||
downloaded = true;
|
||||
}
|
||||
|
||||
const actualSha256 = await sha256File(file);
|
||||
if (actualSha256 !== expectedSha256) {
|
||||
throw new Error(
|
||||
`SHA-256 mismatch for ${source}: expected ${expectedSha256}, received ${actualSha256}.`
|
||||
);
|
||||
}
|
||||
return { file, downloaded, sha256: actualSha256 };
|
||||
}
|
||||
381
scripts/assemble.mjs
Normal file
381
scripts/assemble.mjs
Normal file
@@ -0,0 +1,381 @@
|
||||
#!/usr/bin/env node
|
||||
import { constants } from 'node:fs';
|
||||
import {
|
||||
access,
|
||||
copyFile,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { parseToolboxApp } from '@add-ideas/toolbox-contract';
|
||||
import { acquireArtifact, sha256File } from './artifact.mjs';
|
||||
import { createStaticArchive } from './archive.mjs';
|
||||
import {
|
||||
assertSafeRemovalTarget,
|
||||
canonicalExistingPath,
|
||||
canonicalPathsOverlap,
|
||||
} from './path-safety.mjs';
|
||||
import { publishStagedTargets } from './publication.mjs';
|
||||
import { buildCatalogue, parseReleaseLock } from './release-lock.mjs';
|
||||
import {
|
||||
extractInspectedArchive,
|
||||
inspectAppArchive,
|
||||
safeEntryPath,
|
||||
} from './zip-security.mjs';
|
||||
|
||||
function exists(file) {
|
||||
return access(file).then(
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
}
|
||||
|
||||
async function readJson(file, label) {
|
||||
try {
|
||||
return JSON.parse(await readFile(file, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`${label} is not valid JSON: ${file}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async function copyDirectoryContents(source, destination) {
|
||||
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||
if (entry.isSymbolicLink())
|
||||
throw new Error(`Portal dist contains a symbolic link: ${entry.name}`);
|
||||
if (!entry.isDirectory() && !entry.isFile())
|
||||
throw new Error(
|
||||
`Portal dist contains an unsupported file: ${entry.name}`
|
||||
);
|
||||
const sourcePath = path.join(source, entry.name);
|
||||
const destinationPath = path.join(destination, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await mkdir(destinationPath);
|
||||
await copyDirectoryContents(sourcePath, destinationPath);
|
||||
} else {
|
||||
await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function manifestLocalFile(reference, field, directoryIndex = false) {
|
||||
if (
|
||||
/^[A-Za-z][A-Za-z0-9+.-]*:/.test(reference) ||
|
||||
reference.startsWith('/') ||
|
||||
reference.startsWith('//') ||
|
||||
reference.includes('?') ||
|
||||
reference.includes('#')
|
||||
) {
|
||||
throw new Error(
|
||||
`${field} must be a relative packaged path without query parameters or fragments.`
|
||||
);
|
||||
}
|
||||
const normalized = safeEntryPath(reference);
|
||||
if (normalized.endsWith('/') || normalized === '.') {
|
||||
if (directoryIndex) return path.posix.join(normalized, 'index.html');
|
||||
throw new Error(`${field} must identify a packaged file.`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function requireManifestFile(root, reference, appId, field, options) {
|
||||
const relative = manifestLocalFile(reference, field, options?.directoryIndex);
|
||||
const canonicalRoot = path.resolve(root);
|
||||
const file = path.resolve(canonicalRoot, ...relative.split('/'));
|
||||
if (!file.startsWith(`${canonicalRoot}${path.sep}`))
|
||||
throw new Error(`${field} escapes its target: ${appId}`);
|
||||
let details;
|
||||
try {
|
||||
details = await stat(file);
|
||||
} catch (error) {
|
||||
throw new Error(`${field} is missing for ${appId}: ${reference}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (!details.isFile())
|
||||
throw new Error(`${field} is not a file for ${appId}: ${reference}`);
|
||||
}
|
||||
|
||||
async function smokeCheck(output, lock, manifests) {
|
||||
const index = path.join(output, 'index.html');
|
||||
if (!(await exists(index)))
|
||||
throw new Error('Portal dist does not contain index.html.');
|
||||
const catalogue = await readJson(
|
||||
path.join(output, 'toolbox.catalog.json'),
|
||||
'Generated catalogue'
|
||||
);
|
||||
if (catalogue.apps.length !== lock.apps.length)
|
||||
throw new Error('Generated catalogue app count does not match the lock.');
|
||||
|
||||
for (let index = 0; index < lock.apps.length; index += 1) {
|
||||
const app = lock.apps[index];
|
||||
const manifest = manifests[index];
|
||||
const root = path.join(output, 'apps', app.target);
|
||||
await requireManifestFile(root, manifest.entry, app.id, 'manifest.entry', {
|
||||
directoryIndex: true,
|
||||
});
|
||||
await requireManifestFile(root, manifest.icon, app.id, 'manifest.icon');
|
||||
for (const [assetIndex, asset] of (manifest.assets ?? []).entries()) {
|
||||
await requireManifestFile(
|
||||
root,
|
||||
asset,
|
||||
app.id,
|
||||
`manifest.assets[${assetIndex}]`
|
||||
);
|
||||
}
|
||||
const copiedManifest = parseToolboxApp(
|
||||
await readJson(path.join(root, 'toolbox-app.json'), 'Copied app manifest')
|
||||
);
|
||||
if (copiedManifest.id !== app.id || copiedManifest.version !== app.version)
|
||||
throw new Error(`Copied manifest identity changed for ${app.id}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function provenance(lock) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
releaseVersion: lock.releaseVersion,
|
||||
portalVersion: lock.portalVersion,
|
||||
apps: lock.apps.map(({ id, version, sha256, target }) => ({
|
||||
id,
|
||||
version,
|
||||
sha256,
|
||||
target,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function assembleRelease({
|
||||
lockFile,
|
||||
portalDist,
|
||||
outputDirectory,
|
||||
archiveFile,
|
||||
force = false,
|
||||
}) {
|
||||
const lockPath = await canonicalExistingPath(lockFile, 'Release lock');
|
||||
const portalPath = await canonicalExistingPath(portalDist, 'Portal dist');
|
||||
const outputPath = await assertSafeRemovalTarget(outputDirectory);
|
||||
const archivePath = archiveFile
|
||||
? await assertSafeRemovalTarget(archiveFile)
|
||||
: undefined;
|
||||
const checksumPath = archivePath
|
||||
? await assertSafeRemovalTarget(`${archivePath}.sha256`)
|
||||
: undefined;
|
||||
if (archivePath && path.extname(archivePath).toLocaleLowerCase() !== '.zip')
|
||||
throw new Error('Archive output must use a .zip extension.');
|
||||
if (canonicalPathsOverlap(outputPath, portalPath))
|
||||
throw new Error('Output and portal dist directories must not overlap.');
|
||||
if (canonicalPathsOverlap(outputPath, lockPath))
|
||||
throw new Error('Output must not contain or replace the release lock.');
|
||||
if (archivePath && canonicalPathsOverlap(archivePath, outputPath))
|
||||
throw new Error('Archive must be outside the assembled output directory.');
|
||||
if (checksumPath && canonicalPathsOverlap(checksumPath, outputPath))
|
||||
throw new Error('Checksum must be outside the assembled output directory.');
|
||||
if (archivePath && canonicalPathsOverlap(archivePath, portalPath))
|
||||
throw new Error('Archive must be outside the portal dist directory.');
|
||||
if (checksumPath && canonicalPathsOverlap(checksumPath, portalPath))
|
||||
throw new Error('Checksum must be outside the portal dist directory.');
|
||||
if (archivePath && canonicalPathsOverlap(archivePath, lockPath))
|
||||
throw new Error('Archive must not replace the release lock.');
|
||||
if (checksumPath && canonicalPathsOverlap(checksumPath, lockPath))
|
||||
throw new Error('Checksum must not replace the release lock.');
|
||||
|
||||
const lock = parseReleaseLock(await readJson(lockPath, 'Release lock'));
|
||||
const generatedCatalogue = buildCatalogue(lock);
|
||||
const portalPackage = await readJson(
|
||||
fileURLToPath(new URL('../package.json', import.meta.url)),
|
||||
'Portal package'
|
||||
);
|
||||
if (portalPackage.version !== lock.portalVersion) {
|
||||
throw new Error(
|
||||
`Portal version mismatch: lock pins ${lock.portalVersion}, package is ${portalPackage.version}.`
|
||||
);
|
||||
}
|
||||
const portalDetails = await stat(portalPath);
|
||||
if (!portalDetails.isDirectory())
|
||||
throw new Error('Portal dist must be a regular directory.');
|
||||
if (!(await exists(path.join(portalPath, 'index.html'))))
|
||||
throw new Error(
|
||||
'Portal dist does not contain index.html; run npm run build first.'
|
||||
);
|
||||
|
||||
const outputExists = await exists(outputPath);
|
||||
const archiveExists = archivePath ? await exists(archivePath) : false;
|
||||
const checksumExists = checksumPath ? await exists(checksumPath) : false;
|
||||
if (outputExists && !force)
|
||||
throw new Error(
|
||||
`Output already exists (use --force to replace it): ${outputPath}`
|
||||
);
|
||||
if (archivePath && archiveExists && !force)
|
||||
throw new Error(
|
||||
`Archive already exists (use --force to replace it): ${archivePath}`
|
||||
);
|
||||
if (checksumPath && checksumExists && !force)
|
||||
throw new Error(
|
||||
`Checksum file already exists (use --force to replace it): ${checksumPath}`
|
||||
);
|
||||
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
const staging = await mkdtemp(
|
||||
path.join(path.dirname(outputPath), '.toolbox-assemble-')
|
||||
);
|
||||
const downloads = path.join(staging, '.downloads');
|
||||
const manifests = [];
|
||||
let archiveStaging;
|
||||
try {
|
||||
await copyDirectoryContents(portalPath, staging);
|
||||
await mkdir(path.join(staging, 'apps'), { recursive: true });
|
||||
|
||||
for (const app of lock.apps) {
|
||||
const artifact = await acquireArtifact(
|
||||
app.artifact,
|
||||
app.sha256,
|
||||
path.dirname(lockPath),
|
||||
downloads
|
||||
);
|
||||
const inspection = await inspectAppArchive(artifact.file);
|
||||
const manifest = parseToolboxApp(inspection.manifest);
|
||||
if (manifest.id !== app.id)
|
||||
throw new Error(
|
||||
`Manifest id mismatch for ${app.target}: expected ${app.id}, received ${manifest.id}.`
|
||||
);
|
||||
if (manifest.version !== app.version)
|
||||
throw new Error(
|
||||
`Manifest version mismatch for ${app.id}: expected ${app.version}, received ${manifest.version}.`
|
||||
);
|
||||
await extractInspectedArchive(
|
||||
artifact.file,
|
||||
path.join(staging, 'apps', app.target),
|
||||
inspection
|
||||
);
|
||||
manifests.push(manifest);
|
||||
}
|
||||
|
||||
await rm(downloads, { recursive: true, force: true });
|
||||
await writeFile(
|
||||
path.join(staging, 'toolbox.catalog.json'),
|
||||
`${JSON.stringify(generatedCatalogue, null, 2)}\n`,
|
||||
{ flag: 'w' }
|
||||
);
|
||||
await writeFile(
|
||||
path.join(staging, 'toolbox.release.json'),
|
||||
`${JSON.stringify(provenance(lock), null, 2)}\n`,
|
||||
{ flag: 'wx' }
|
||||
);
|
||||
await smokeCheck(staging, lock, manifests);
|
||||
|
||||
if (archivePath) {
|
||||
await mkdir(path.dirname(archivePath), { recursive: true });
|
||||
archiveStaging = await mkdtemp(
|
||||
path.join(path.dirname(archivePath), '.toolbox-archive-')
|
||||
);
|
||||
const stagedArchive = path.join(
|
||||
archiveStaging,
|
||||
path.basename(archivePath)
|
||||
);
|
||||
const stagedChecksum = `${stagedArchive}.sha256`;
|
||||
await createStaticArchive(staging, stagedArchive);
|
||||
const checksum = await sha256File(stagedArchive);
|
||||
await writeFile(
|
||||
stagedChecksum,
|
||||
`${checksum} ${path.basename(archivePath)}\n`,
|
||||
{ flag: 'wx' }
|
||||
);
|
||||
}
|
||||
|
||||
const publicationTargets = [{ staged: staging, target: outputPath }];
|
||||
if (archivePath) {
|
||||
publicationTargets.push(
|
||||
{
|
||||
staged: path.join(archiveStaging, path.basename(archivePath)),
|
||||
target: archivePath,
|
||||
},
|
||||
{
|
||||
staged: path.join(
|
||||
archiveStaging,
|
||||
`${path.basename(archivePath)}.sha256`
|
||||
),
|
||||
target: checksumPath,
|
||||
}
|
||||
);
|
||||
}
|
||||
await publishStagedTargets(publicationTargets, { force });
|
||||
if (archivePath) {
|
||||
await rm(archiveStaging, { recursive: true, force: true });
|
||||
archiveStaging = undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
await rm(staging, { recursive: true, force: true });
|
||||
if (archiveStaging)
|
||||
await rm(archiveStaging, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
outputDirectory: outputPath,
|
||||
archiveFile: archivePath,
|
||||
checksumFile: checksumPath,
|
||||
releaseVersion: lock.releaseVersion,
|
||||
};
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return `Usage: node scripts/assemble.mjs --lock FILE [options]\n\nOptions:\n --portal-dist DIR Built portal directory (default: dist)\n --output DIR Assembled static directory (default: build/toolbox)\n --archive FILE Static ZIP (default: build/add-ideas-toolbox-VERSION.zip)\n --force Replace the exact output paths if they exist\n --help Show this message\n`;
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const values = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === '--force' || argument === '--help')
|
||||
values[argument.slice(2)] = true;
|
||||
else if (
|
||||
['--lock', '--portal-dist', '--output', '--archive'].includes(argument)
|
||||
) {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith('--'))
|
||||
throw new Error(`${argument} requires a value.`);
|
||||
values[argument.slice(2)] = value;
|
||||
index += 1;
|
||||
} else throw new Error(`Unknown argument: ${argument}`);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const arguments_ = parseArguments(process.argv.slice(2));
|
||||
if (arguments_.help) {
|
||||
process.stdout.write(usage());
|
||||
return;
|
||||
}
|
||||
if (!arguments_.lock) throw new Error(`--lock is required.\n\n${usage()}`);
|
||||
const rawLock = parseReleaseLock(
|
||||
await readJson(path.resolve(arguments_.lock), 'Release lock')
|
||||
);
|
||||
const result = await assembleRelease({
|
||||
lockFile: arguments_.lock,
|
||||
portalDist: arguments_['portal-dist'] ?? 'dist',
|
||||
outputDirectory: arguments_.output ?? 'build/toolbox',
|
||||
archiveFile:
|
||||
arguments_.archive ??
|
||||
`build/add-ideas-toolbox-${rawLock.releaseVersion}.zip`,
|
||||
force: Boolean(arguments_.force),
|
||||
});
|
||||
process.stdout.write(
|
||||
`Assembled toolbox ${result.releaseVersion} at ${result.outputDirectory}\nArchive: ${result.archiveFile}\nChecksum: ${result.checksumFile}\n`
|
||||
);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(
|
||||
`Assembly failed: ${error instanceof Error ? error.message : String(error)}\n`
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
487
scripts/assembler.test.ts
Normal file
487
scripts/assembler.test.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
// @vitest-environment node
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import {
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import archiver from 'archiver';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { assembleRelease } from './assemble.mjs';
|
||||
import { createStaticArchive } from './archive.mjs';
|
||||
import { acquireArtifact, sha256File } from './artifact.mjs';
|
||||
import { publishStagedTargets } from './publication.mjs';
|
||||
import { parseReleaseLock } from './release-lock.mjs';
|
||||
import { inspectAppArchive } from './zip-security.mjs';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { recursive: true, force: true }))
|
||||
);
|
||||
});
|
||||
|
||||
async function temporaryDirectory() {
|
||||
const directory = await mkdtemp(
|
||||
path.join(os.tmpdir(), 'toolbox-portal-test-')
|
||||
);
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function zipEntries(
|
||||
output: string,
|
||||
entries: Array<{ name: string; content: string }>
|
||||
) {
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
const writing = pipeline(archive, createWriteStream(output, { flags: 'wx' }));
|
||||
for (const entry of entries)
|
||||
archive.append(entry.content, { name: entry.name });
|
||||
await archive.finalize();
|
||||
await writing;
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
id: 'de.add-ideas.test-tool',
|
||||
name: 'Test tool',
|
||||
version: '1.2.3',
|
||||
description: 'Test fixture',
|
||||
entry: './',
|
||||
icon: './favicon.svg',
|
||||
categories: ['test'],
|
||||
tags: [],
|
||||
integration: {
|
||||
contextVersion: 1,
|
||||
launchModes: ['navigate'],
|
||||
embedding: 'unsupported',
|
||||
},
|
||||
requirements: {
|
||||
secureContext: false,
|
||||
workers: false,
|
||||
indexedDb: false,
|
||||
crossOriginIsolated: false,
|
||||
},
|
||||
privacy: { processing: 'local', fileUploads: false, telemetry: false },
|
||||
source: { repository: 'https://example.com/test-tool', license: 'MIT' },
|
||||
};
|
||||
|
||||
function makeLock(artifact: string, sha256: string) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
releaseVersion: '0.1.0',
|
||||
portalVersion: '0.1.0',
|
||||
catalogue: {
|
||||
id: 'de.add-ideas.toolbox',
|
||||
name: 'Test Toolbox',
|
||||
home: './',
|
||||
theme: { mode: 'system', brand: 'add·ideas' },
|
||||
},
|
||||
apps: [
|
||||
{
|
||||
id: manifest.id,
|
||||
version: manifest.version,
|
||||
artifact,
|
||||
sha256,
|
||||
target: 'test',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('release assembly integrity', () => {
|
||||
it('rejects latest aliases and malformed checksums in release locks', () => {
|
||||
expect(() =>
|
||||
parseReleaseLock(
|
||||
makeLock('https://example.com/latest/tool.zip', '0'.repeat(64))
|
||||
)
|
||||
).toThrow(/latest alias/i);
|
||||
expect(() => parseReleaseLock(makeLock('./tool.zip', 'ABC'))).toThrow(
|
||||
/sha256/i
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses an artifact whose SHA-256 does not match', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const artifact = path.join(directory, 'app.zip');
|
||||
await writeFile(artifact, 'not the expected artifact');
|
||||
await expect(
|
||||
acquireArtifact(
|
||||
artifact,
|
||||
'0'.repeat(64),
|
||||
directory,
|
||||
path.join(directory, 'downloads')
|
||||
)
|
||||
).rejects.toThrow(/SHA-256 mismatch/);
|
||||
});
|
||||
|
||||
it('rejects credential-bearing HTTPS sources and self-containing archives', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
await expect(
|
||||
acquireArtifact(
|
||||
'https://user:secret@example.com/tool.zip',
|
||||
'0'.repeat(64),
|
||||
directory,
|
||||
path.join(directory, 'downloads')
|
||||
)
|
||||
).rejects.toThrow(/credentials/i);
|
||||
await expect(
|
||||
createStaticArchive(directory, path.join(directory, 'self.zip'))
|
||||
).rejects.toThrow(/overlap/i);
|
||||
});
|
||||
|
||||
it('canonicalizes symlink aliases before archive overlap checks', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const input = path.join(directory, 'input');
|
||||
const alias = path.join(directory, 'input-alias');
|
||||
await mkdir(input);
|
||||
await writeFile(path.join(input, 'index.html'), 'release input');
|
||||
await symlink(input, alias, 'dir');
|
||||
|
||||
await expect(
|
||||
createStaticArchive(alias, path.join(input, 'self.zip'))
|
||||
).rejects.toThrow(/overlap/i);
|
||||
});
|
||||
|
||||
it('rejects a dangling symlink in an archive output parent', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const input = path.join(directory, 'input');
|
||||
const dangling = path.join(directory, 'dangling');
|
||||
await mkdir(input);
|
||||
await writeFile(path.join(input, 'index.html'), 'release input');
|
||||
await symlink(path.join(directory, 'missing'), dangling, 'dir');
|
||||
|
||||
await expect(
|
||||
createStaticArchive(input, path.join(dangling, 'release.zip'))
|
||||
).rejects.toThrow(/dangling symbolic link/i);
|
||||
});
|
||||
|
||||
it('rejects insecure or credential-bearing redirect targets', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: 'http://example.com/tool.zip' },
|
||||
})
|
||||
)
|
||||
);
|
||||
await expect(
|
||||
acquireArtifact(
|
||||
'https://example.com/tool.zip',
|
||||
'0'.repeat(64),
|
||||
directory,
|
||||
path.join(directory, 'downloads')
|
||||
)
|
||||
).rejects.toThrow(/redirect URL must use HTTPS/i);
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: 'https://user:secret@example.com/tool.zip' },
|
||||
})
|
||||
)
|
||||
);
|
||||
await expect(
|
||||
acquireArtifact(
|
||||
'https://example.com/tool.zip',
|
||||
'0'.repeat(64),
|
||||
directory,
|
||||
path.join(directory, 'other-downloads')
|
||||
)
|
||||
).rejects.toThrow(/redirect URL must not contain credentials/i);
|
||||
});
|
||||
|
||||
it('blocks ZIP path traversal before extraction', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const safeZip = path.join(directory, 'safe.zip');
|
||||
const hostileZip = path.join(directory, 'hostile.zip');
|
||||
await zipEntries(safeZip, [
|
||||
{ name: 'toolbox-app.json', content: JSON.stringify(manifest) },
|
||||
{ name: 'xx/evil.txt', content: 'escape attempt' },
|
||||
]);
|
||||
const buffer = await readFile(safeZip);
|
||||
const before = Buffer.from('xx/evil.txt');
|
||||
const after = Buffer.from('../evil.txt');
|
||||
let replacements = 0;
|
||||
for (
|
||||
let offset = buffer.indexOf(before);
|
||||
offset >= 0;
|
||||
offset = buffer.indexOf(before, offset + after.length)
|
||||
) {
|
||||
after.copy(buffer, offset);
|
||||
replacements += 1;
|
||||
}
|
||||
expect(replacements).toBeGreaterThanOrEqual(2);
|
||||
await writeFile(hostileZip, buffer);
|
||||
await expect(inspectAppArchive(hostileZip)).rejects.toThrow(
|
||||
/relative path|traversal/i
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks symbolic links before extraction', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const artifact = path.join(directory, 'symlink.zip');
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
const writing = pipeline(
|
||||
archive,
|
||||
createWriteStream(artifact, { flags: 'wx' })
|
||||
);
|
||||
archive.append(JSON.stringify(manifest), { name: 'toolbox-app.json' });
|
||||
archive.symlink('link', '../outside');
|
||||
await archive.finalize();
|
||||
await writing;
|
||||
await expect(inspectAppArchive(artifact)).rejects.toThrow(/symbolic link/i);
|
||||
});
|
||||
|
||||
it('packages the same files into byte-identical static archives', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const input = path.join(directory, 'input');
|
||||
const first = path.join(directory, 'first.zip');
|
||||
const second = path.join(directory, 'second.zip');
|
||||
await mkdir(input);
|
||||
await writeFile(path.join(input, 'z.txt'), 'last');
|
||||
await writeFile(path.join(input, 'ä.txt'), 'middle');
|
||||
await writeFile(path.join(input, '😀.txt'), 'astral');
|
||||
await createStaticArchive(input, first);
|
||||
await createStaticArchive(input, second);
|
||||
expect(await sha256File(first)).toBe(await sha256File(second));
|
||||
});
|
||||
|
||||
it('packages byte-identical static archives in different host time zones', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const input = path.join(directory, 'input');
|
||||
const utcArchive = path.join(directory, 'utc.zip');
|
||||
const honoluluArchive = path.join(directory, 'honolulu.zip');
|
||||
await mkdir(input);
|
||||
await writeFile(path.join(input, 'index.html'), 'timezone invariant');
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(process.cwd(), 'scripts/archive.mjs')
|
||||
).href;
|
||||
const run = (timezone: string, output: string) => {
|
||||
const program = `
|
||||
import { createStaticArchive } from ${JSON.stringify(moduleUrl)};
|
||||
await createStaticArchive(${JSON.stringify(input)}, ${JSON.stringify(output)});
|
||||
`;
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
['--input-type=module', '--eval', program],
|
||||
{ env: { ...process.env, TZ: timezone } }
|
||||
);
|
||||
};
|
||||
run('UTC', utcArchive);
|
||||
run('Pacific/Honolulu', honoluluArchive);
|
||||
expect(await sha256File(utcArchive)).toBe(
|
||||
await sha256File(honoluluArchive)
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a valid archive whose manifest identity is not the lock identity', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const portalDist = path.join(directory, 'portal-dist');
|
||||
const artifact = path.join(directory, 'wrong-tool.zip');
|
||||
await mkdir(portalDist);
|
||||
await writeFile(
|
||||
path.join(portalDist, 'index.html'),
|
||||
'<!doctype html><title>Toolbox</title>'
|
||||
);
|
||||
await zipEntries(artifact, [
|
||||
{
|
||||
name: 'toolbox-app.json',
|
||||
content: JSON.stringify({ ...manifest, id: 'de.add-ideas.other-tool' }),
|
||||
},
|
||||
]);
|
||||
const lockFile = path.join(directory, 'toolbox.lock.json');
|
||||
await writeFile(
|
||||
lockFile,
|
||||
JSON.stringify(makeLock(artifact, await sha256File(artifact)))
|
||||
);
|
||||
await expect(
|
||||
assembleRelease({
|
||||
lockFile,
|
||||
portalDist,
|
||||
outputDirectory: path.join(directory, 'assembled'),
|
||||
})
|
||||
).rejects.toThrow(/manifest id mismatch/i);
|
||||
});
|
||||
|
||||
it('refuses a force output symlink that aliases the portal input', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const portalDist = path.join(directory, 'portal-dist');
|
||||
const outputAlias = path.join(directory, 'output-alias');
|
||||
const lockFile = path.join(directory, 'toolbox.lock.json');
|
||||
await mkdir(portalDist);
|
||||
await writeFile(path.join(portalDist, 'index.html'), 'portal remains');
|
||||
await symlink(portalDist, outputAlias, 'dir');
|
||||
await writeFile(lockFile, '{}');
|
||||
|
||||
await expect(
|
||||
assembleRelease({
|
||||
lockFile,
|
||||
portalDist,
|
||||
outputDirectory: outputAlias,
|
||||
force: true,
|
||||
})
|
||||
).rejects.toThrow(/must not overlap/i);
|
||||
expect(await readFile(path.join(portalDist, 'index.html'), 'utf8')).toBe(
|
||||
'portal remains'
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls all targets back if transactional publication fails', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const stagedFirst = path.join(directory, 'staged-first');
|
||||
const stagedSecond = path.join(directory, 'staged-second');
|
||||
const targetFirst = path.join(directory, 'target-first');
|
||||
const targetSecond = path.join(directory, 'target-second');
|
||||
await writeFile(stagedFirst, 'new first');
|
||||
await writeFile(stagedSecond, 'new second');
|
||||
await writeFile(targetFirst, 'old first');
|
||||
await writeFile(targetSecond, 'old second');
|
||||
|
||||
const renameWithFailure = async (source: string, target: string) => {
|
||||
if (source === stagedSecond) throw new Error('injected rename failure');
|
||||
await rename(source, target);
|
||||
};
|
||||
await expect(
|
||||
publishStagedTargets(
|
||||
[
|
||||
{ staged: stagedFirst, target: targetFirst },
|
||||
{ staged: stagedSecond, target: targetSecond },
|
||||
],
|
||||
{ force: true, renamePath: renameWithFailure }
|
||||
)
|
||||
).rejects.toThrow(/injected rename failure/i);
|
||||
expect(await readFile(targetFirst, 'utf8')).toBe('old first');
|
||||
expect(await readFile(targetSecond, 'utf8')).toBe('old second');
|
||||
});
|
||||
|
||||
it('requires every manifest icon and declared asset in the app archive', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const portalDist = path.join(directory, 'portal-dist');
|
||||
const artifact = path.join(directory, 'missing-assets.zip');
|
||||
await mkdir(portalDist);
|
||||
await writeFile(
|
||||
path.join(portalDist, 'index.html'),
|
||||
'<title>Portal</title>'
|
||||
);
|
||||
await zipEntries(artifact, [
|
||||
{ name: 'index.html', content: '<title>App</title>' },
|
||||
{
|
||||
name: 'toolbox-app.json',
|
||||
content: JSON.stringify({
|
||||
...manifest,
|
||||
assets: ['./runtime.js'],
|
||||
}),
|
||||
},
|
||||
]);
|
||||
const lockFile = path.join(directory, 'toolbox.lock.json');
|
||||
await writeFile(
|
||||
lockFile,
|
||||
JSON.stringify(makeLock(artifact, await sha256File(artifact)))
|
||||
);
|
||||
|
||||
await expect(
|
||||
assembleRelease({
|
||||
lockFile,
|
||||
portalDist,
|
||||
outputDirectory: path.join(directory, 'assembled'),
|
||||
})
|
||||
).rejects.toThrow(/manifest\.icon is missing/i);
|
||||
|
||||
const artifactWithIcon = path.join(directory, 'missing-runtime.zip');
|
||||
await zipEntries(artifactWithIcon, [
|
||||
{ name: 'index.html', content: '<title>App</title>' },
|
||||
{ name: 'favicon.svg', content: '<svg />' },
|
||||
{
|
||||
name: 'toolbox-app.json',
|
||||
content: JSON.stringify({
|
||||
...manifest,
|
||||
assets: ['./runtime.js'],
|
||||
}),
|
||||
},
|
||||
]);
|
||||
await writeFile(
|
||||
lockFile,
|
||||
JSON.stringify(
|
||||
makeLock(artifactWithIcon, await sha256File(artifactWithIcon))
|
||||
)
|
||||
);
|
||||
await expect(
|
||||
assembleRelease({
|
||||
lockFile,
|
||||
portalDist,
|
||||
outputDirectory: path.join(directory, 'assembled-again'),
|
||||
})
|
||||
).rejects.toThrow(/manifest\.assets\[0\] is missing/i);
|
||||
});
|
||||
|
||||
it('assembles only the pinned artifact, validates its manifest, and emits provenance', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const portalDist = path.join(directory, 'portal-dist');
|
||||
const artifact = path.join(directory, 'test-tool-1.2.3.zip');
|
||||
const output = path.join(directory, 'assembled');
|
||||
const archive = path.join(directory, 'toolbox-0.1.0.zip');
|
||||
await mkdir(portalDist);
|
||||
await writeFile(
|
||||
path.join(portalDist, 'index.html'),
|
||||
'<!doctype html><title>Toolbox</title>'
|
||||
);
|
||||
await zipEntries(artifact, [
|
||||
{ name: 'index.html', content: '<!doctype html><title>Test app</title>' },
|
||||
{
|
||||
name: 'favicon.svg',
|
||||
content: '<svg xmlns="http://www.w3.org/2000/svg"/>',
|
||||
},
|
||||
{ name: 'toolbox-app.json', content: JSON.stringify(manifest) },
|
||||
]);
|
||||
const checksum = await sha256File(artifact);
|
||||
const lockFile = path.join(directory, 'toolbox.lock.json');
|
||||
await writeFile(lockFile, JSON.stringify(makeLock(artifact, checksum)));
|
||||
|
||||
await assembleRelease({
|
||||
lockFile,
|
||||
portalDist,
|
||||
outputDirectory: output,
|
||||
archiveFile: archive,
|
||||
});
|
||||
const catalogue = JSON.parse(
|
||||
await readFile(path.join(output, 'toolbox.catalog.json'), 'utf8')
|
||||
);
|
||||
const release = JSON.parse(
|
||||
await readFile(path.join(output, 'toolbox.release.json'), 'utf8')
|
||||
);
|
||||
expect(catalogue.apps).toEqual([
|
||||
{ manifest: './apps/test/toolbox-app.json', enabled: true },
|
||||
]);
|
||||
expect(release.apps[0]).toEqual({
|
||||
id: manifest.id,
|
||||
version: manifest.version,
|
||||
sha256: checksum,
|
||||
target: 'test',
|
||||
});
|
||||
expect(
|
||||
await readFile(path.join(output, 'apps/test/index.html'), 'utf8')
|
||||
).toContain('Test app');
|
||||
expect((await readFile(archive)).length).toBeGreaterThan(0);
|
||||
expect(await readFile(`${archive}.sha256`, 'utf8')).toBe(
|
||||
`${await sha256File(archive)} ${path.basename(archive)}\n`
|
||||
);
|
||||
});
|
||||
});
|
||||
4
scripts/licenses.d.ts
vendored
Normal file
4
scripts/licenses.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
export function createRuntimeLicenseBundle(
|
||||
projectRoot: string,
|
||||
packageNames?: string[]
|
||||
): Promise<string>;
|
||||
100
scripts/licenses.mjs
Normal file
100
scripts/licenses.mjs
Normal file
@@ -0,0 +1,100 @@
|
||||
import { readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const LICENSE_FILE = /^(?:licen[cs]e|copying|notice)(?:[._-].*)?$/iu;
|
||||
const RUNTIME_PACKAGES = [
|
||||
'@add-ideas/toolbox-contract',
|
||||
'react',
|
||||
'react-dom',
|
||||
'scheduler',
|
||||
];
|
||||
|
||||
function compareCodePoints(left, right) {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
export async function createRuntimeLicenseBundle(
|
||||
projectRoot,
|
||||
packageNames = RUNTIME_PACKAGES
|
||||
) {
|
||||
const sections = [];
|
||||
for (const packageName of [...new Set(packageNames)].sort(
|
||||
compareCodePoints
|
||||
)) {
|
||||
const packageDirectory = path.join(
|
||||
projectRoot,
|
||||
'node_modules',
|
||||
...packageName.split('/')
|
||||
);
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(packageDirectory, 'package.json'), 'utf8')
|
||||
);
|
||||
const licenseFiles = await collectLicenseFiles(packageDirectory);
|
||||
if (licenseFiles.length === 0)
|
||||
throw new Error(`No license text found for ${packageName}.`);
|
||||
|
||||
const contents = [];
|
||||
for (const licenseFile of licenseFiles) {
|
||||
const text = await readFile(
|
||||
path.join(packageDirectory, ...licenseFile.split('/')),
|
||||
'utf8'
|
||||
);
|
||||
contents.push(
|
||||
`--- ${licenseFile} ---\n${text.replaceAll('\r\n', '\n').trim()}\n`
|
||||
);
|
||||
}
|
||||
sections.push(
|
||||
[
|
||||
'================================================================================',
|
||||
`${packageJson.name ?? packageName}@${packageJson.version ?? 'unknown'}`,
|
||||
`Declared license: ${String(packageJson.license ?? 'unspecified')}`,
|
||||
'================================================================================',
|
||||
...contents,
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
return [
|
||||
'Third-party software license texts bundled with add·ideas Toolbox',
|
||||
'Generated deterministically from the installed runtime packages.',
|
||||
'',
|
||||
...sections,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function collectLicenseFiles(directory, prefix = '') {
|
||||
const matches = [];
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
entries.sort((left, right) => compareCodePoints(left.name, right.name));
|
||||
for (const entry of entries) {
|
||||
if (entry.name === 'node_modules') continue;
|
||||
const relativePath = path.posix.join(prefix, entry.name);
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory())
|
||||
matches.push(...(await collectLicenseFiles(absolutePath, relativePath)));
|
||||
else if (entry.isFile() && LICENSE_FILE.test(entry.name))
|
||||
matches.push(relativePath);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const projectRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..'
|
||||
);
|
||||
const output = path.join(projectRoot, 'public', 'THIRD_PARTY_LICENSES.txt');
|
||||
await writeFile(
|
||||
output,
|
||||
await createRuntimeLicenseBundle(projectRoot),
|
||||
'utf8'
|
||||
);
|
||||
process.stdout.write('Generated public/THIRD_PARTY_LICENSES.txt\n');
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
)
|
||||
await main();
|
||||
14
scripts/licenses.test.ts
Normal file
14
scripts/licenses.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createRuntimeLicenseBundle } from './licenses.mjs';
|
||||
|
||||
describe('runtime license bundle', () => {
|
||||
it('contains the complete installed runtime package notices', async () => {
|
||||
const bundle = await createRuntimeLicenseBundle(process.cwd());
|
||||
expect(bundle).toContain('@add-ideas/toolbox-contract@');
|
||||
expect(bundle).toContain('react@');
|
||||
expect(bundle).toContain('react-dom@');
|
||||
expect(bundle).toContain('scheduler@');
|
||||
expect(bundle).toContain('Apache License');
|
||||
expect(bundle).toContain('MIT License');
|
||||
});
|
||||
});
|
||||
71
scripts/package-static.mjs
Normal file
71
scripts/package-static.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env node
|
||||
import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createStaticArchive } from './archive.mjs';
|
||||
import { sha256File } from './artifact.mjs';
|
||||
import {
|
||||
assertSafeRemovalTarget,
|
||||
canonicalExistingPath,
|
||||
canonicalPathsOverlap,
|
||||
} from './path-safety.mjs';
|
||||
import { publishStagedTargets } from './publication.mjs';
|
||||
|
||||
function argument(name, fallback) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : fallback;
|
||||
}
|
||||
|
||||
const input = await canonicalExistingPath(
|
||||
argument('--input', 'build/toolbox'),
|
||||
'Static archive input'
|
||||
);
|
||||
const output = await assertSafeRemovalTarget(
|
||||
argument('--output', 'artifacts/add-ideas-toolbox-static.zip')
|
||||
);
|
||||
const checksumOutput = await assertSafeRemovalTarget(`${output}.sha256`);
|
||||
const force = process.argv.includes('--force');
|
||||
|
||||
if (path.extname(output).toLocaleLowerCase() !== '.zip')
|
||||
throw new Error('Archive output must use a .zip extension.');
|
||||
if (canonicalPathsOverlap(output, input))
|
||||
throw new Error('Archive output and input directory must not overlap.');
|
||||
|
||||
const outputExists = await access(output).then(
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
const checksumExists = await access(checksumOutput).then(
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
if (outputExists && !force)
|
||||
throw new Error(
|
||||
`Archive already exists (use --force to replace it): ${output}`
|
||||
);
|
||||
if (checksumExists && !force)
|
||||
throw new Error(
|
||||
`Checksum already exists (use --force to replace it): ${checksumOutput}`
|
||||
);
|
||||
await mkdir(path.dirname(output), { recursive: true });
|
||||
const staging = await mkdtemp(
|
||||
path.join(path.dirname(output), '.toolbox-package-')
|
||||
);
|
||||
const stagedArchive = path.join(staging, path.basename(output));
|
||||
const stagedChecksum = `${stagedArchive}.sha256`;
|
||||
try {
|
||||
await createStaticArchive(input, stagedArchive);
|
||||
const checksum = await sha256File(stagedArchive);
|
||||
await writeFile(stagedChecksum, `${checksum} ${path.basename(output)}\n`, {
|
||||
flag: 'wx',
|
||||
});
|
||||
await publishStagedTargets(
|
||||
[
|
||||
{ staged: stagedArchive, target: output },
|
||||
{ staged: stagedChecksum, target: checksumOutput },
|
||||
],
|
||||
{ force }
|
||||
);
|
||||
} finally {
|
||||
await rm(staging, { recursive: true, force: true });
|
||||
}
|
||||
process.stdout.write(`Created ${output}\nChecksum: ${checksumOutput}\n`);
|
||||
93
scripts/path-safety.mjs
Normal file
93
scripts/path-safety.mjs
Normal file
@@ -0,0 +1,93 @@
|
||||
import { lstat, realpath, stat } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
function isMissing(error) {
|
||||
return error && typeof error === 'object' && error.code === 'ENOENT';
|
||||
}
|
||||
|
||||
export async function canonicalExistingPath(candidate, label = 'Path') {
|
||||
const absolute = path.resolve(candidate);
|
||||
try {
|
||||
return await realpath(absolute);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`${label} does not resolve to an existing path: ${absolute}`,
|
||||
{
|
||||
cause: error,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function canonicalProspectivePath(candidate) {
|
||||
const absolute = path.resolve(candidate);
|
||||
const suffix = [];
|
||||
let current = absolute;
|
||||
|
||||
while (true) {
|
||||
let details;
|
||||
try {
|
||||
details = await lstat(current);
|
||||
} catch (error) {
|
||||
if (!isMissing(error)) throw error;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) throw error;
|
||||
suffix.unshift(path.basename(current));
|
||||
current = parent;
|
||||
continue;
|
||||
}
|
||||
|
||||
let canonical;
|
||||
try {
|
||||
canonical = await realpath(current);
|
||||
} catch (error) {
|
||||
if (details.isSymbolicLink() && isMissing(error)) {
|
||||
throw new Error(`Path contains a dangling symbolic link: ${current}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (suffix.length > 0) {
|
||||
const canonicalDetails = await stat(canonical);
|
||||
if (!canonicalDetails.isDirectory()) {
|
||||
throw new Error(
|
||||
`Path has a non-directory existing ancestor: ${current}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return path.join(canonical, ...suffix);
|
||||
}
|
||||
}
|
||||
|
||||
export function canonicalPathsOverlap(left, right) {
|
||||
const resolvedLeft = path.resolve(left);
|
||||
const resolvedRight = path.resolve(right);
|
||||
return (
|
||||
resolvedLeft === resolvedRight ||
|
||||
resolvedLeft.startsWith(`${resolvedRight}${path.sep}`) ||
|
||||
resolvedRight.startsWith(`${resolvedLeft}${path.sep}`)
|
||||
);
|
||||
}
|
||||
|
||||
export async function assertSafeRemovalTarget(target) {
|
||||
const canonical = await canonicalProspectivePath(target);
|
||||
const root = path.parse(canonical).root;
|
||||
const protectedPaths = await Promise.all([
|
||||
canonicalProspectivePath(process.cwd()),
|
||||
canonicalProspectivePath(os.homedir()),
|
||||
]);
|
||||
if (
|
||||
canonical === root ||
|
||||
protectedPaths.some(
|
||||
(protectedPath) =>
|
||||
protectedPath === canonical ||
|
||||
protectedPath.startsWith(`${canonical}${path.sep}`)
|
||||
)
|
||||
) {
|
||||
throw new Error(`Refusing unsafe output target: ${canonical}`);
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
84
scripts/publication.mjs
Normal file
84
scripts/publication.mjs
Normal file
@@ -0,0 +1,84 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { access, rename, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
async function exists(candidate) {
|
||||
return access(candidate).then(
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
}
|
||||
|
||||
function backupPath(target) {
|
||||
return path.join(
|
||||
path.dirname(target),
|
||||
`.${path.basename(target)}.toolbox-backup-${randomUUID()}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function publishStagedTargets(
|
||||
targets,
|
||||
{ force = false, renamePath = rename, removePath = rm } = {}
|
||||
) {
|
||||
if (!Array.isArray(targets) || targets.length === 0)
|
||||
throw new Error('At least one staged publication target is required.');
|
||||
|
||||
const normalized = targets.map(({ staged, target }) => ({
|
||||
staged: path.resolve(staged),
|
||||
target: path.resolve(target),
|
||||
}));
|
||||
if (
|
||||
new Set(normalized.map(({ target }) => target)).size !== normalized.length
|
||||
)
|
||||
throw new Error('Publication targets must be unique.');
|
||||
|
||||
for (const item of normalized) {
|
||||
if (!(await exists(item.staged)))
|
||||
throw new Error(`Staged publication input is missing: ${item.staged}`);
|
||||
if (!force && (await exists(item.target)))
|
||||
throw new Error(`Publication target already exists: ${item.target}`);
|
||||
}
|
||||
|
||||
const backups = [];
|
||||
const published = [];
|
||||
try {
|
||||
for (const item of normalized) {
|
||||
if (!(await exists(item.target))) continue;
|
||||
const backup = backupPath(item.target);
|
||||
await renamePath(item.target, backup);
|
||||
backups.push({ target: item.target, backup });
|
||||
}
|
||||
|
||||
for (const item of normalized) {
|
||||
await renamePath(item.staged, item.target);
|
||||
published.push(item.target);
|
||||
}
|
||||
} catch (publicationError) {
|
||||
const rollbackErrors = [];
|
||||
for (const target of [...published].reverse()) {
|
||||
try {
|
||||
await removePath(target, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
rollbackErrors.push(error);
|
||||
}
|
||||
}
|
||||
for (const item of [...backups].reverse()) {
|
||||
try {
|
||||
await renamePath(item.backup, item.target);
|
||||
} catch (error) {
|
||||
rollbackErrors.push(error);
|
||||
}
|
||||
}
|
||||
if (rollbackErrors.length > 0) {
|
||||
throw new AggregateError(
|
||||
[publicationError, ...rollbackErrors],
|
||||
'Release publication failed and rollback was incomplete.'
|
||||
);
|
||||
}
|
||||
throw publicationError;
|
||||
}
|
||||
|
||||
for (const { backup } of backups) {
|
||||
await removePath(backup, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
137
scripts/release-lock.mjs
Normal file
137
scripts/release-lock.mjs
Normal file
@@ -0,0 +1,137 @@
|
||||
import { parseToolboxCatalog } from '@add-ideas/toolbox-contract';
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
const REVERSE_DNS_ID = /^[a-z0-9]+(?:[.-][a-z0-9]+)+$/;
|
||||
const TARGET_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
function record(value, field) {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
||||
throw new Error(`${field} must be an object.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value, field) {
|
||||
if (typeof value !== 'string' || value.trim() === '')
|
||||
throw new Error(`${field} must be a non-empty string.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function onlyKeys(value, allowed, field) {
|
||||
const unexpected = Object.keys(value).filter((key) => !allowed.has(key));
|
||||
if (unexpected.length > 0)
|
||||
throw new Error(`${field} contains unsupported field: ${unexpected[0]}.`);
|
||||
}
|
||||
|
||||
function usesLatestAlias(value) {
|
||||
let decoded = value;
|
||||
try {
|
||||
decoded = decodeURIComponent(value);
|
||||
} catch {
|
||||
// An invalid encoded URL will be rejected when the artifact is acquired.
|
||||
}
|
||||
return /(?:^|[^a-z0-9])latest(?:[^a-z0-9]|$)/i.test(decoded);
|
||||
}
|
||||
|
||||
export function parseReleaseLock(value) {
|
||||
const lock = record(value, 'lock');
|
||||
onlyKeys(
|
||||
lock,
|
||||
new Set([
|
||||
'$schema',
|
||||
'schemaVersion',
|
||||
'releaseVersion',
|
||||
'portalVersion',
|
||||
'catalogue',
|
||||
'apps',
|
||||
]),
|
||||
'lock'
|
||||
);
|
||||
if (lock.schemaVersion !== 1)
|
||||
throw new Error('lock.schemaVersion must be 1.');
|
||||
string(lock.releaseVersion, 'lock.releaseVersion');
|
||||
if (!SEMVER.test(lock.releaseVersion))
|
||||
throw new Error('lock.releaseVersion must use semantic versioning.');
|
||||
string(lock.portalVersion, 'lock.portalVersion');
|
||||
if (!SEMVER.test(lock.portalVersion))
|
||||
throw new Error('lock.portalVersion must use semantic versioning.');
|
||||
|
||||
const catalogue = record(lock.catalogue, 'lock.catalogue');
|
||||
onlyKeys(
|
||||
catalogue,
|
||||
new Set(['id', 'name', 'home', 'theme']),
|
||||
'lock.catalogue'
|
||||
);
|
||||
string(catalogue.id, 'lock.catalogue.id');
|
||||
if (!REVERSE_DNS_ID.test(catalogue.id))
|
||||
throw new Error('lock.catalogue.id must be a reverse-DNS identifier.');
|
||||
string(catalogue.name, 'lock.catalogue.name');
|
||||
string(catalogue.home, 'lock.catalogue.home');
|
||||
const theme = record(catalogue.theme, 'lock.catalogue.theme');
|
||||
onlyKeys(theme, new Set(['mode', 'brand']), 'lock.catalogue.theme');
|
||||
if (!['light', 'dark', 'system'].includes(theme.mode))
|
||||
throw new Error('lock.catalogue.theme.mode is invalid.');
|
||||
string(theme.brand, 'lock.catalogue.theme.brand');
|
||||
|
||||
if (!Array.isArray(lock.apps) || lock.apps.length === 0)
|
||||
throw new Error('lock.apps must contain at least one pinned app.');
|
||||
const ids = new Set();
|
||||
const targets = new Set();
|
||||
lock.apps.forEach((rawApp, index) => {
|
||||
const app = record(rawApp, `lock.apps[${index}]`);
|
||||
onlyKeys(
|
||||
app,
|
||||
new Set(['id', 'version', 'artifact', 'sha256', 'target']),
|
||||
`lock.apps[${index}]`
|
||||
);
|
||||
string(app.id, `lock.apps[${index}].id`);
|
||||
if (!REVERSE_DNS_ID.test(app.id))
|
||||
throw new Error(
|
||||
`lock.apps[${index}].id must be a reverse-DNS identifier.`
|
||||
);
|
||||
string(app.version, `lock.apps[${index}].version`);
|
||||
if (!SEMVER.test(app.version))
|
||||
throw new Error(
|
||||
`lock.apps[${index}].version must use semantic versioning.`
|
||||
);
|
||||
string(app.artifact, `lock.apps[${index}].artifact`);
|
||||
if (usesLatestAlias(app.artifact))
|
||||
throw new Error(
|
||||
`lock.apps[${index}].artifact must not use a latest alias.`
|
||||
);
|
||||
string(app.sha256, `lock.apps[${index}].sha256`);
|
||||
if (!SHA256.test(app.sha256))
|
||||
throw new Error(
|
||||
`lock.apps[${index}].sha256 must be 64 lowercase hexadecimal characters.`
|
||||
);
|
||||
string(app.target, `lock.apps[${index}].target`);
|
||||
if (!TARGET_SLUG.test(app.target))
|
||||
throw new Error(`lock.apps[${index}].target must be a safe URL slug.`);
|
||||
if (ids.has(app.id))
|
||||
throw new Error(`Duplicate app id in lock: ${app.id}.`);
|
||||
if (targets.has(app.target))
|
||||
throw new Error(`Duplicate app target in lock: ${app.target}.`);
|
||||
ids.add(app.id);
|
||||
targets.add(app.target);
|
||||
});
|
||||
return lock;
|
||||
}
|
||||
|
||||
export function buildCatalogue(lock) {
|
||||
const catalogue = {
|
||||
schemaVersion: 1,
|
||||
id: lock.catalogue.id,
|
||||
name: lock.catalogue.name,
|
||||
home: lock.catalogue.home,
|
||||
theme: lock.catalogue.theme,
|
||||
apps: lock.apps.map((app) => ({
|
||||
manifest: `./apps/${app.target}/toolbox-app.json`,
|
||||
enabled: true,
|
||||
})),
|
||||
};
|
||||
return {
|
||||
$schema:
|
||||
'https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json',
|
||||
...parseToolboxCatalog(catalogue),
|
||||
};
|
||||
}
|
||||
206
scripts/zip-security.mjs
Normal file
206
scripts/zip-security.mjs
Normal file
@@ -0,0 +1,206 @@
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import yauzl from 'yauzl';
|
||||
|
||||
const MAX_ENTRIES = 50_000;
|
||||
const MAX_FILE_SIZE = 256 * 1024 * 1024;
|
||||
const MAX_TOTAL_SIZE = 2 * 1024 * 1024 * 1024;
|
||||
const MAX_MANIFEST_SIZE = 1024 * 1024;
|
||||
const MAX_COMPRESSION_RATIO = 1_000;
|
||||
|
||||
function openZip(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
yauzl.open(
|
||||
file,
|
||||
{
|
||||
lazyEntries: true,
|
||||
autoClose: true,
|
||||
decodeStrings: true,
|
||||
validateEntrySizes: true,
|
||||
},
|
||||
(error, zip) => {
|
||||
if (error) reject(error);
|
||||
else resolve(zip);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function readEntry(zip, entry, limit = MAX_FILE_SIZE) {
|
||||
return new Promise((resolve, reject) => {
|
||||
zip.openReadStream(entry, (error, stream) => {
|
||||
if (error) return reject(error);
|
||||
const chunks = [];
|
||||
let length = 0;
|
||||
stream.on('data', (chunk) => {
|
||||
length += chunk.length;
|
||||
if (length > limit)
|
||||
stream.destroy(
|
||||
new Error(`ZIP entry exceeds the ${limit}-byte read limit.`)
|
||||
);
|
||||
else chunks.push(chunk);
|
||||
});
|
||||
stream.once('error', reject);
|
||||
stream.once('end', () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function unixFileType(entry) {
|
||||
const madeBy = entry.versionMadeBy >>> 8;
|
||||
return madeBy === 3 ? (entry.externalFileAttributes >>> 16) & 0o170000 : 0;
|
||||
}
|
||||
|
||||
export function safeEntryPath(fileName) {
|
||||
if (
|
||||
typeof fileName !== 'string' ||
|
||||
fileName.length === 0 ||
|
||||
fileName.includes('\0')
|
||||
)
|
||||
throw new Error('ZIP contains an invalid empty or NUL path.');
|
||||
const portable = fileName.replaceAll('\\', '/');
|
||||
if (portable.startsWith('/') || /^[A-Za-z]:\//.test(portable))
|
||||
throw new Error(`ZIP contains an absolute path: ${fileName}`);
|
||||
const components = portable.split('/');
|
||||
if (components.some((part) => part === '..'))
|
||||
throw new Error(`ZIP path traversal blocked: ${fileName}`);
|
||||
const normalized = path.posix.normalize(portable);
|
||||
if (normalized === '..' || normalized.startsWith('../'))
|
||||
throw new Error(`ZIP path traversal blocked: ${fileName}`);
|
||||
if (normalized === '.' && !portable.endsWith('/'))
|
||||
throw new Error(`ZIP contains an invalid path: ${fileName}`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateMetadata(entry, total) {
|
||||
const normalized = safeEntryPath(entry.fileName);
|
||||
const directory = entry.fileName.endsWith('/');
|
||||
const fileType = unixFileType(entry);
|
||||
if (fileType === 0o120000)
|
||||
throw new Error(`ZIP symbolic link blocked: ${entry.fileName}`);
|
||||
if (fileType !== 0 && fileType !== 0o100000 && fileType !== 0o040000)
|
||||
throw new Error(`ZIP special file blocked: ${entry.fileName}`);
|
||||
if (entry.uncompressedSize > MAX_FILE_SIZE)
|
||||
throw new Error(`ZIP entry is too large: ${entry.fileName}`);
|
||||
if (
|
||||
entry.compressedSize > 0 &&
|
||||
entry.uncompressedSize / entry.compressedSize > MAX_COMPRESSION_RATIO
|
||||
) {
|
||||
throw new Error(
|
||||
`ZIP entry has an unsafe compression ratio: ${entry.fileName}`
|
||||
);
|
||||
}
|
||||
if (total + entry.uncompressedSize > MAX_TOTAL_SIZE)
|
||||
throw new Error('ZIP exceeds the total uncompressed-size limit.');
|
||||
return { normalized, directory };
|
||||
}
|
||||
|
||||
export async function inspectAppArchive(file) {
|
||||
const zip = await openZip(file);
|
||||
const entries = [];
|
||||
const paths = new Set();
|
||||
let total = 0;
|
||||
let manifestBuffer;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const fail = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
zip.close();
|
||||
reject(error);
|
||||
};
|
||||
zip.once('error', fail);
|
||||
zip.once('end', () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve();
|
||||
});
|
||||
zip.on('entry', (entry) => {
|
||||
void (async () => {
|
||||
if (entries.length >= MAX_ENTRIES)
|
||||
throw new Error(`ZIP exceeds the ${MAX_ENTRIES}-entry limit.`);
|
||||
const metadata = validateMetadata(entry, total);
|
||||
total += entry.uncompressedSize;
|
||||
if (paths.has(metadata.normalized))
|
||||
throw new Error(`ZIP contains a duplicate path: ${entry.fileName}`);
|
||||
paths.add(metadata.normalized);
|
||||
entries.push({ fileName: entry.fileName, ...metadata });
|
||||
if (!metadata.directory && metadata.normalized === 'toolbox-app.json') {
|
||||
manifestBuffer = await readEntry(zip, entry, MAX_MANIFEST_SIZE);
|
||||
}
|
||||
zip.readEntry();
|
||||
})().catch(fail);
|
||||
});
|
||||
zip.readEntry();
|
||||
});
|
||||
|
||||
if (!manifestBuffer)
|
||||
throw new Error('ZIP does not contain toolbox-app.json at its root.');
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(manifestBuffer.toString('utf8'));
|
||||
} catch (error) {
|
||||
throw new Error('ZIP toolbox-app.json is not valid JSON.', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
return { entries, manifest };
|
||||
}
|
||||
|
||||
export async function extractInspectedArchive(file, destination, inspection) {
|
||||
const root = path.resolve(destination);
|
||||
await mkdir(root, { recursive: true });
|
||||
const allowed = new Map(
|
||||
inspection.entries.map((entry) => [entry.fileName, entry])
|
||||
);
|
||||
const zip = await openZip(file);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const fail = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
zip.close();
|
||||
reject(error);
|
||||
};
|
||||
zip.once('error', fail);
|
||||
zip.once('end', () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve();
|
||||
});
|
||||
zip.on('entry', (entry) => {
|
||||
void (async () => {
|
||||
const metadata = allowed.get(entry.fileName);
|
||||
if (!metadata)
|
||||
throw new Error(
|
||||
`ZIP changed between inspection and extraction: ${entry.fileName}`
|
||||
);
|
||||
const target = path.resolve(root, ...metadata.normalized.split('/'));
|
||||
if (target !== root && !target.startsWith(`${root}${path.sep}`))
|
||||
throw new Error(
|
||||
`ZIP path escaped extraction root: ${entry.fileName}`
|
||||
);
|
||||
if (metadata.directory) {
|
||||
await mkdir(target, { recursive: true });
|
||||
} else {
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await new Promise((resolveStream, rejectStream) => {
|
||||
zip.openReadStream(entry, (error, stream) => {
|
||||
if (error) return rejectStream(error);
|
||||
pipeline(
|
||||
stream,
|
||||
createWriteStream(target, { flags: 'wx', mode: 0o644 })
|
||||
).then(resolveStream, rejectStream);
|
||||
});
|
||||
});
|
||||
}
|
||||
zip.readEntry();
|
||||
})().catch(fail);
|
||||
});
|
||||
zip.readEntry();
|
||||
});
|
||||
}
|
||||
122
src/App.test.tsx
Normal file
122
src/App.test.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import App from './App';
|
||||
import { PREFERENCES_KEY } from './preferences';
|
||||
import { catalogue, catalogueFetch } from './test/fixtures';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('portal UI', () => {
|
||||
it('renders app cards and persists accessible pin/hide controls', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'PDF Workbench' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Local processing · no file uploads declared · no telemetry declared'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
const sourceLinks = screen.getAllByRole('link', {
|
||||
name: 'Source and license',
|
||||
});
|
||||
expect(sourceLinks).toHaveLength(2);
|
||||
expect(
|
||||
sourceLinks.find(
|
||||
(link) =>
|
||||
link.getAttribute('href') ===
|
||||
'https://git.add-ideas.de/zemion/pdf-tools'
|
||||
)
|
||||
).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
expect(
|
||||
sourceLinks.find((link) =>
|
||||
link.getAttribute('href')?.endsWith('/toolbox-portal/src/tag/v0.1.0')
|
||||
)
|
||||
).toBeDefined();
|
||||
const launch = screen.getByRole('link', { name: /open tool/i });
|
||||
expect(
|
||||
new URL(launch.getAttribute('href')!).searchParams.get('toolbox')
|
||||
).toContain('toolbox.catalog.json');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Pin PDF Workbench' }));
|
||||
await waitFor(() =>
|
||||
expect(localStorage.getItem(PREFERENCES_KEY)).toContain(
|
||||
'de.add-ideas.pdf-tools'
|
||||
)
|
||||
);
|
||||
expect(screen.getByText('Pinned')).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Hide PDF Workbench' })
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole('heading', { name: 'PDF Workbench' })
|
||||
).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /show hidden/i }));
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'PDF Workbench' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a recoverable catalogue error', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response('no', { status: 503 }))
|
||||
.mockImplementation(catalogueFetch());
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
render(<App />);
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Toolbox unavailable' })
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'PDF Workbench' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a dedicated empty state for a valid catalogue', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
catalogueFetch({
|
||||
'/toolbox.catalog.json': Response.json({ ...catalogue, apps: [] }),
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<App />);
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'No tools yet' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('contains preference focus and restores it to Personalize', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<App />);
|
||||
const personalize = screen.getByRole('button', { name: 'Personalize' });
|
||||
|
||||
await user.click(personalize);
|
||||
const dialog = screen.getByRole('dialog', {
|
||||
name: 'Personalize your toolbox',
|
||||
});
|
||||
const close = screen.getByRole('button', { name: 'Close preferences' });
|
||||
expect(close).toHaveFocus();
|
||||
expect(container.querySelector('.site-shell')).toHaveAttribute('inert');
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(screen.getByText(/does not transmit them/i)).toHaveTextContent(
|
||||
/Trusted code in another Toolbox app on this site can access same-origin storage/i
|
||||
);
|
||||
|
||||
const reset = screen.getByRole('button', { name: 'Reset' });
|
||||
reset.focus();
|
||||
await user.tab();
|
||||
expect(close).toHaveFocus();
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(personalize).toHaveFocus();
|
||||
});
|
||||
});
|
||||
356
src/App.tsx
Normal file
356
src/App.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AppCard } from './components/AppCard';
|
||||
import { Logo } from './components/Logo';
|
||||
import { PreferencesPanel } from './components/PreferencesPanel';
|
||||
import { contextualLaunchUrl, loadCatalogue } from './catalogue';
|
||||
import { cleanPreferences, sortAppIds } from './preferences';
|
||||
import type { LoadedCatalogue, ResolvedApp } from './types';
|
||||
import { usePreferences } from './usePreferences';
|
||||
|
||||
const CATALOGUE_HREF = './toolbox.catalog.json';
|
||||
|
||||
type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; value: LoadedCatalogue };
|
||||
|
||||
function appMatches(
|
||||
app: ResolvedApp,
|
||||
query: string,
|
||||
category: string
|
||||
): boolean {
|
||||
if (category && !app.categories.includes(category)) return false;
|
||||
if (!query) return true;
|
||||
const haystack = [app.name, app.description, ...app.categories, ...app.tags]
|
||||
.join(' ')
|
||||
.toLocaleLowerCase();
|
||||
return haystack.includes(query.toLocaleLowerCase());
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [loadState, setLoadState] = useState<LoadState>({ status: 'loading' });
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [preferences, setPreferences, storageAvailable] = usePreferences();
|
||||
const closeSettings = useCallback(() => setSettingsOpen(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
loadCatalogue(CATALOGUE_HREF, controller.signal).then(
|
||||
(value) => setLoadState({ status: 'ready', value }),
|
||||
(error) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setLoadState({
|
||||
status: 'error',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'The catalogue could not be loaded.',
|
||||
});
|
||||
}
|
||||
);
|
||||
return () => controller.abort();
|
||||
}, [reloadKey]);
|
||||
|
||||
const loaded = loadState.status === 'ready' ? loadState.value : undefined;
|
||||
const apps = useMemo(() => loaded?.apps ?? [], [loaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
const clean = cleanPreferences(
|
||||
preferences,
|
||||
loaded.apps.map((app) => app.id)
|
||||
);
|
||||
if (JSON.stringify(clean) !== JSON.stringify(preferences))
|
||||
setPreferences(clean);
|
||||
}, [loaded, preferences, setPreferences]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = preferences.theme;
|
||||
}, [preferences.theme]);
|
||||
|
||||
const categories = useMemo(
|
||||
() =>
|
||||
[...new Set(apps.flatMap((app) => app.categories))].sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
),
|
||||
[apps]
|
||||
);
|
||||
const orderedApps = useMemo(() => {
|
||||
const byId = new Map(apps.map((app) => [app.id, app]));
|
||||
return sortAppIds(
|
||||
apps.map((app) => app.id),
|
||||
preferences
|
||||
).flatMap((id) => {
|
||||
const app = byId.get(id);
|
||||
return app ? [app] : [];
|
||||
});
|
||||
}, [apps, preferences]);
|
||||
const visibleApps = orderedApps.filter((app) => {
|
||||
const hidden = preferences.hidden.includes(app.id);
|
||||
return (showHidden || !hidden) && appMatches(app, query.trim(), category);
|
||||
});
|
||||
|
||||
function toggleListItem(field: 'pinned' | 'hidden', id: string) {
|
||||
setPreferences((current) => ({
|
||||
...current,
|
||||
[field]: current[field].includes(id)
|
||||
? current[field].filter((item) => item !== id)
|
||||
: [...current[field], id],
|
||||
}));
|
||||
}
|
||||
|
||||
function moveApp(id: string, direction: -1 | 1) {
|
||||
const pinned = preferences.pinned.includes(id);
|
||||
const peers = visibleApps.filter(
|
||||
(app) => preferences.pinned.includes(app.id) === pinned
|
||||
);
|
||||
const currentIndex = peers.findIndex((app) => app.id === id);
|
||||
const target = peers[currentIndex + direction];
|
||||
if (!target) return;
|
||||
setPreferences((current) => {
|
||||
const currentPosition = current.order.indexOf(id);
|
||||
const targetPosition = current.order.indexOf(target.id);
|
||||
if (currentPosition < 0 || targetPosition < 0) return current;
|
||||
const order = [...current.order];
|
||||
[order[currentPosition], order[targetPosition]] = [
|
||||
order[targetPosition]!,
|
||||
order[currentPosition]!,
|
||||
];
|
||||
return { ...current, order };
|
||||
});
|
||||
}
|
||||
|
||||
const title = loaded?.catalogue.name ?? 'add·ideas Toolbox';
|
||||
const brand = loaded?.catalogue.theme.brand ?? 'add·ideas';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="site-shell"
|
||||
inert={settingsOpen}
|
||||
aria-hidden={settingsOpen || undefined}
|
||||
>
|
||||
<a className="skip-link" href="#main-content">
|
||||
Skip to tools
|
||||
</a>
|
||||
<header className="site-header">
|
||||
<div className="header-inner">
|
||||
<a
|
||||
className="brand"
|
||||
href={loaded ? loaded.homeUrl : './'}
|
||||
aria-label={`${title} home`}
|
||||
>
|
||||
<Logo />
|
||||
<span>
|
||||
<strong>{brand}</strong>
|
||||
<small>Toolbox</small>
|
||||
</span>
|
||||
</a>
|
||||
<button
|
||||
className="settings-button"
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
<span aria-hidden="true">☼</span> Personalize
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main-content">
|
||||
<section className="hero" aria-labelledby="page-title">
|
||||
<p className="eyebrow">
|
||||
Privacy details up front · useful by default
|
||||
</p>
|
||||
<h1 id="page-title">
|
||||
Your document tools,
|
||||
<br />
|
||||
<span>in one calm place.</span>
|
||||
</h1>
|
||||
<p className="hero-copy">
|
||||
Focused browser utilities with a privacy disclosure for each tool.
|
||||
Choose one and keep your work moving.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{loadState.status === 'loading' && (
|
||||
<section className="state-card" aria-live="polite">
|
||||
<div className="loader" aria-hidden="true" />
|
||||
<h2>Loading your toolbox</h2>
|
||||
<p>Reading the local app catalogue…</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{loadState.status === 'error' && (
|
||||
<section className="state-card state-card--error" role="alert">
|
||||
<span className="state-icon" aria-hidden="true">
|
||||
!
|
||||
</span>
|
||||
<h2>Toolbox unavailable</h2>
|
||||
<p>{loadState.message}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoadState({ status: 'loading' });
|
||||
setReloadKey((key) => key + 1);
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{loaded && (
|
||||
<>
|
||||
<section className="tool-controls" aria-label="Find a tool">
|
||||
<label className="search-field">
|
||||
<span className="visually-hidden">Search tools</span>
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search tools"
|
||||
type="search"
|
||||
/>
|
||||
</label>
|
||||
<label className="category-field">
|
||||
<span className="visually-hidden">Filter by category</span>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(event) => setCategory(event.target.value)}
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
{categories.map((item) => (
|
||||
<option value={item} key={item}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{preferences.hidden.length > 0 && (
|
||||
<label className="hidden-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showHidden}
|
||||
onChange={(event) => setShowHidden(event.target.checked)}
|
||||
/>
|
||||
Show hidden ({preferences.hidden.length})
|
||||
</label>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{apps.length === 0 && loaded.unavailable.length === 0 && (
|
||||
<section className="state-card">
|
||||
<span className="state-icon" aria-hidden="true">
|
||||
◇
|
||||
</span>
|
||||
<h2>No tools yet</h2>
|
||||
<p>
|
||||
The catalogue is valid but does not contain any enabled
|
||||
apps.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{apps.length > 0 && visibleApps.length === 0 && (
|
||||
<section className="state-card">
|
||||
<span className="state-icon" aria-hidden="true">
|
||||
⌕
|
||||
</span>
|
||||
<h2>No matching tools</h2>
|
||||
<p>
|
||||
Try another search or category, or show your hidden tools.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
setCategory('');
|
||||
setShowHidden(true);
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{visibleApps.length > 0 && (
|
||||
<section className="app-grid" aria-label="Available tools">
|
||||
{visibleApps.map((app) => {
|
||||
const pinned = preferences.pinned.includes(app.id);
|
||||
const peers = visibleApps.filter(
|
||||
(peer) => preferences.pinned.includes(peer.id) === pinned
|
||||
);
|
||||
return (
|
||||
<AppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
catalogueUrl={loaded.catalogueUrl.href}
|
||||
contextualUrl={contextualLaunchUrl}
|
||||
pinned={pinned}
|
||||
hidden={preferences.hidden.includes(app.id)}
|
||||
first={peers[0]?.id === app.id}
|
||||
last={peers.at(-1)?.id === app.id}
|
||||
onPin={() => toggleListItem('pinned', app.id)}
|
||||
onHide={() => toggleListItem('hidden', app.id)}
|
||||
onMove={(direction) => moveApp(app.id, direction)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{loaded.unavailable.length > 0 && (
|
||||
<details className="manifest-errors">
|
||||
<summary>
|
||||
{loaded.unavailable.length} configured{' '}
|
||||
{loaded.unavailable.length === 1 ? 'tool is' : 'tools are'}{' '}
|
||||
unavailable
|
||||
</summary>
|
||||
<ul>
|
||||
{loaded.unavailable.map((app) => (
|
||||
<li key={app.id}>
|
||||
<strong>{app.name}</strong>: {app.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="site-footer">
|
||||
<p>
|
||||
<Logo size={22} />{' '}
|
||||
<span>
|
||||
Privacy differs by tool. Review each disclosure before opening.
|
||||
Apps open as full pages—never embedded.
|
||||
</span>
|
||||
</p>
|
||||
<span>
|
||||
Portal v0.1.0 ·{' '}
|
||||
<a
|
||||
href="https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.1.0"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Source and license
|
||||
</a>
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<PreferencesPanel
|
||||
open={settingsOpen}
|
||||
preferences={preferences}
|
||||
storageAvailable={storageAvailable}
|
||||
onChange={setPreferences}
|
||||
onClose={closeSettings}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
102
src/catalogue.test.ts
Normal file
102
src/catalogue.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { contextualLaunchUrl, loadCatalogue } from './catalogue';
|
||||
import { catalogue, catalogueFetch } from './test/fixtures';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('catalogue loading', () => {
|
||||
it('loads and resolves an internal app using the shared contract', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
|
||||
const loaded = await loadCatalogue('/toolbox.catalog.json');
|
||||
expect(loaded.catalogue.name).toBe('add·ideas Toolbox');
|
||||
expect(loaded.homeUrl).toBe('http://localhost:3000/');
|
||||
expect(loaded.apps).toHaveLength(1);
|
||||
expect(loaded.apps[0]).toMatchObject({
|
||||
id: 'de.add-ideas.pdf-tools',
|
||||
name: 'PDF Workbench',
|
||||
entryUrl: 'http://localhost:3000/apps/pdf/',
|
||||
sourceUrl: 'https://git.add-ideas.de/zemion/pdf-tools',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps valid apps available when another manifest fails', async () => {
|
||||
const twoApps = {
|
||||
...catalogue,
|
||||
apps: [
|
||||
...catalogue.apps,
|
||||
{ manifest: './apps/missing/toolbox-app.json', enabled: true },
|
||||
],
|
||||
};
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
catalogueFetch({
|
||||
'/toolbox.catalog.json': Response.json(twoApps),
|
||||
})
|
||||
)
|
||||
);
|
||||
const loaded = await loadCatalogue('/toolbox.catalog.json');
|
||||
expect(loaded.apps.map((app) => app.id)).toEqual([
|
||||
'de.add-ideas.pdf-tools',
|
||||
]);
|
||||
expect(loaded.unavailable).toHaveLength(1);
|
||||
expect(loaded.unavailable[0]?.reason).toMatch(/HTTP 404/);
|
||||
});
|
||||
|
||||
it('rejects cross-origin manifest references', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
catalogueFetch({
|
||||
'/toolbox.catalog.json': Response.json({
|
||||
...catalogue,
|
||||
apps: [
|
||||
{
|
||||
manifest: 'https://example.invalid/toolbox-app.json',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
)
|
||||
);
|
||||
await expect(loadCatalogue('/toolbox.catalog.json')).rejects.toThrow(
|
||||
/share the page origin/i
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a cross-origin catalogue home', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
catalogueFetch({
|
||||
'/toolbox.catalog.json': Response.json({
|
||||
...catalogue,
|
||||
home: 'https://example.invalid/',
|
||||
}),
|
||||
})
|
||||
)
|
||||
);
|
||||
await expect(loadCatalogue('/toolbox.catalog.json')).rejects.toThrow(
|
||||
/share the page origin/i
|
||||
);
|
||||
});
|
||||
|
||||
it('adds catalogue context only to same-origin launch links', () => {
|
||||
const contextual = new URL(
|
||||
contextualLaunchUrl(
|
||||
'http://localhost:3000/apps/pdf/',
|
||||
'http://localhost:3000/toolbox.catalog.json'
|
||||
)
|
||||
);
|
||||
expect(contextual.searchParams.get('toolbox')).toBe(
|
||||
'http://localhost:3000/toolbox.catalog.json'
|
||||
);
|
||||
expect(
|
||||
contextualLaunchUrl(
|
||||
'https://example.com/tool',
|
||||
'http://localhost:3000/toolbox.catalog.json'
|
||||
)
|
||||
).toBe('https://example.com/tool');
|
||||
});
|
||||
});
|
||||
143
src/catalogue.ts
Normal file
143
src/catalogue.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
contextualizeToolboxLink,
|
||||
fetchToolboxAppManifest,
|
||||
fetchToolboxCatalog,
|
||||
requireSameOrigin,
|
||||
resolveToolboxApp,
|
||||
resolveWebUrl,
|
||||
type ToolboxCatalogEntry,
|
||||
type ToolboxCatalogExternalEntry,
|
||||
type ToolboxCatalogManifestEntry,
|
||||
} from '@add-ideas/toolbox-contract';
|
||||
import type { LoadedCatalogue, ResolvedApp, UnavailableApp } from './types';
|
||||
|
||||
function isInternal(
|
||||
entry: ToolboxCatalogEntry
|
||||
): entry is ToolboxCatalogManifestEntry {
|
||||
return 'manifest' in entry;
|
||||
}
|
||||
|
||||
function fallbackId(entry: ToolboxCatalogExternalEntry, entryUrl: URL): string {
|
||||
const slug = entry.name
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
let hash = 2166136261;
|
||||
for (const character of `${entry.name}\0${entryUrl.href}`) {
|
||||
hash ^= character.codePointAt(0) ?? 0;
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return `external:${slug || 'tool'}:${(hash >>> 0).toString(36)}`;
|
||||
}
|
||||
|
||||
export async function loadCatalogue(
|
||||
catalogueHref: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<LoadedCatalogue> {
|
||||
const pageUrl = resolveWebUrl(document.baseURI);
|
||||
const catalogueUrl = requireSameOrigin(
|
||||
resolveWebUrl(catalogueHref, pageUrl),
|
||||
pageUrl,
|
||||
'Toolbox catalogue'
|
||||
);
|
||||
const catalogue = await fetchToolboxCatalog(catalogueUrl, {
|
||||
signal,
|
||||
expectedOrigin: pageUrl,
|
||||
});
|
||||
const enabled = catalogue.apps.filter((entry) => entry.enabled);
|
||||
const results = await Promise.all(
|
||||
enabled.map(
|
||||
async (
|
||||
entry
|
||||
): Promise<{ app?: ResolvedApp; unavailable?: UnavailableApp }> => {
|
||||
if (!isInternal(entry)) {
|
||||
const entryUrl = resolveWebUrl(entry.entry, catalogueUrl);
|
||||
return {
|
||||
app: {
|
||||
id: fallbackId(entry, entryUrl),
|
||||
name: entry.name,
|
||||
description: 'External tool',
|
||||
entryUrl: entryUrl.href,
|
||||
categories: [],
|
||||
tags: [],
|
||||
launchModes: [entry.launch],
|
||||
isExternal: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const manifestUrl = requireSameOrigin(
|
||||
resolveWebUrl(entry.manifest, catalogueUrl),
|
||||
pageUrl,
|
||||
'Application manifest'
|
||||
);
|
||||
try {
|
||||
const manifest = await fetchToolboxAppManifest(manifestUrl, {
|
||||
signal,
|
||||
expectedOrigin: pageUrl,
|
||||
});
|
||||
const resolved = resolveToolboxApp(manifest, manifestUrl, pageUrl);
|
||||
return {
|
||||
app: {
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description,
|
||||
entryUrl: resolved.entryUrl.href,
|
||||
iconUrl: resolved.iconUrl.href,
|
||||
categories: manifest.categories,
|
||||
tags: manifest.tags,
|
||||
launchModes: manifest.integration.launchModes,
|
||||
isExternal: false,
|
||||
manifestUrl: resolved.manifestUrl.href,
|
||||
sourceUrl: manifest.source?.repository,
|
||||
requirements: manifest.requirements,
|
||||
privacy: manifest.privacy,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
return {
|
||||
unavailable: {
|
||||
id: `unavailable:${manifestUrl.href}`,
|
||||
name: manifestUrl.pathname.split('/').at(-2) ?? 'Unknown app',
|
||||
manifestUrl: manifestUrl.href,
|
||||
reason:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown manifest error.',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const apps = results.flatMap((result) => (result.app ? [result.app] : []));
|
||||
const ids = new Set<string>();
|
||||
for (const app of apps) {
|
||||
if (ids.has(app.id)) throw new Error(`Duplicate app id: ${app.id}.`);
|
||||
ids.add(app.id);
|
||||
}
|
||||
|
||||
return {
|
||||
catalogue,
|
||||
catalogueUrl,
|
||||
homeUrl: requireSameOrigin(
|
||||
resolveWebUrl(catalogue.home, catalogueUrl),
|
||||
pageUrl,
|
||||
'Toolbox home'
|
||||
).href,
|
||||
apps,
|
||||
unavailable: results.flatMap((result) =>
|
||||
result.unavailable ? [result.unavailable] : []
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function contextualLaunchUrl(
|
||||
entryHref: string,
|
||||
catalogueHref: string
|
||||
): string {
|
||||
return contextualizeToolboxLink(entryHref, catalogueHref);
|
||||
}
|
||||
172
src/components/AppCard.tsx
Normal file
172
src/components/AppCard.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { ResolvedApp } from '../types';
|
||||
|
||||
interface AppCardProps {
|
||||
app: ResolvedApp;
|
||||
catalogueUrl: string;
|
||||
hidden: boolean;
|
||||
pinned: boolean;
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
onHide: () => void;
|
||||
onMove: (direction: -1 | 1) => void;
|
||||
onPin: () => void;
|
||||
contextualUrl: (entryUrl: string, catalogueUrl: string) => string;
|
||||
}
|
||||
|
||||
function RequirementSummary({ app }: { app: ResolvedApp }) {
|
||||
if (!app.requirements) return null;
|
||||
const requirements = [
|
||||
app.requirements.secureContext && 'Secure context',
|
||||
app.requirements.workers && 'Web workers',
|
||||
app.requirements.indexedDb && 'IndexedDB',
|
||||
app.requirements.crossOriginIsolated && 'Cross-origin isolation',
|
||||
].filter(Boolean);
|
||||
if (requirements.length === 0) return null;
|
||||
return <p className="requirements">Needs {requirements.join(' · ')}</p>;
|
||||
}
|
||||
|
||||
function PrivacySummary({ app }: { app: ResolvedApp }) {
|
||||
if (!app.privacy)
|
||||
return (
|
||||
<p className="privacy-summary">
|
||||
External destination · review its privacy terms
|
||||
</p>
|
||||
);
|
||||
if (app.privacy.label)
|
||||
return <p className="privacy-summary">{app.privacy.label}</p>;
|
||||
const processing = {
|
||||
local: 'Local processing',
|
||||
remote: 'Remote processing',
|
||||
mixed: 'Local and remote capabilities',
|
||||
}[app.privacy.processing];
|
||||
return (
|
||||
<p className="privacy-summary">
|
||||
{processing} ·{' '}
|
||||
{app.privacy.fileUploads
|
||||
? 'file/data transmission declared'
|
||||
: 'no file uploads declared'}{' '}
|
||||
· {app.privacy.telemetry ? 'telemetry declared' : 'no telemetry declared'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppCard({
|
||||
app,
|
||||
catalogueUrl,
|
||||
hidden,
|
||||
pinned,
|
||||
first,
|
||||
last,
|
||||
onHide,
|
||||
onMove,
|
||||
onPin,
|
||||
contextualUrl,
|
||||
}: AppCardProps) {
|
||||
const launchUrl = app.isExternal
|
||||
? app.entryUrl
|
||||
: contextualUrl(app.entryUrl, catalogueUrl);
|
||||
const target = app.launchModes.includes('navigate') ? undefined : '_blank';
|
||||
return (
|
||||
<article
|
||||
className={`app-card${hidden ? ' app-card--hidden' : ''}`}
|
||||
data-testid={`app-card-${app.id}`}
|
||||
>
|
||||
<div className="app-card__top">
|
||||
<div className="app-card__icon" aria-hidden="true">
|
||||
{app.iconUrl ? (
|
||||
<img src={app.iconUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span>{app.name.slice(0, 1).toLocaleUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="app-card__actions"
|
||||
aria-label={`Personalize ${app.name}`}
|
||||
>
|
||||
<button
|
||||
className={`icon-button${pinned ? ' is-active' : ''}`}
|
||||
type="button"
|
||||
aria-label={pinned ? `Unpin ${app.name}` : `Pin ${app.name}`}
|
||||
aria-pressed={pinned}
|
||||
title={pinned ? 'Unpin' : 'Pin'}
|
||||
onClick={onPin}
|
||||
>
|
||||
<span aria-hidden="true">⌁</span>
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
aria-label={`Move ${app.name} earlier`}
|
||||
disabled={first}
|
||||
onClick={() => onMove(-1)}
|
||||
>
|
||||
<span aria-hidden="true">←</span>
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
aria-label={`Move ${app.name} later`}
|
||||
disabled={last}
|
||||
onClick={() => onMove(1)}
|
||||
>
|
||||
<span aria-hidden="true">→</span>
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
aria-label={hidden ? `Show ${app.name}` : `Hide ${app.name}`}
|
||||
onClick={onHide}
|
||||
>
|
||||
<span aria-hidden="true">{hidden ? '○' : '—'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-card__body">
|
||||
<div className="title-line">
|
||||
<h2>{app.name}</h2>
|
||||
{pinned && <span className="status-chip">Pinned</span>}
|
||||
{hidden && (
|
||||
<span className="status-chip status-chip--muted">Hidden</span>
|
||||
)}
|
||||
{app.isExternal && (
|
||||
<span className="status-chip status-chip--muted">External</span>
|
||||
)}
|
||||
</div>
|
||||
<p>{app.description}</p>
|
||||
<PrivacySummary app={app} />
|
||||
<RequirementSummary app={app} />
|
||||
<div className="tag-row" aria-label="Categories">
|
||||
{app.categories.slice(0, 3).map((category) => (
|
||||
<span key={category}>{category}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<footer className="app-card__footer">
|
||||
<div className="app-card__meta">
|
||||
<span className="version">
|
||||
{app.version ? `v${app.version}` : 'External service'}
|
||||
</span>
|
||||
{app.sourceUrl && (
|
||||
<a
|
||||
className="source-link"
|
||||
href={app.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Source and license
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
className="launch-button"
|
||||
href={launchUrl}
|
||||
target={target}
|
||||
rel={target ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{target ? 'Open in new tab' : 'Open tool'}{' '}
|
||||
<span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
36
src/components/Logo.tsx
Normal file
36
src/components/Logo.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
interface LogoProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function Logo({ size = 38 }: LogoProps) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="logo"
|
||||
height={size}
|
||||
viewBox="0 0 48 48"
|
||||
width={size}
|
||||
>
|
||||
<path
|
||||
d="M7 14.5 24 5l17 9.5v19L24 43 7 33.5z"
|
||||
fill="currentColor"
|
||||
opacity=".12"
|
||||
/>
|
||||
<path
|
||||
d="m8 15 16 9 16-9M24 24v18"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path
|
||||
d="m16 10.5 16 9v9L24 33l-8-4.5z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
237
src/components/PreferencesPanel.tsx
Normal file
237
src/components/PreferencesPanel.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||
import {
|
||||
defaultPreferences,
|
||||
parsePreferences,
|
||||
type Preferences,
|
||||
} from '../preferences';
|
||||
import type { ThemeMode } from '../types';
|
||||
|
||||
interface PreferencesPanelProps {
|
||||
open: boolean;
|
||||
preferences: Preferences;
|
||||
storageAvailable: boolean;
|
||||
onChange: (preferences: Preferences) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PreferencesPanel({
|
||||
open,
|
||||
preferences,
|
||||
storageAvailable,
|
||||
onChange,
|
||||
onClose,
|
||||
}: PreferencesPanelProps) {
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const returnFocus =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
const dialog = panel.current;
|
||||
const focusableElements = () =>
|
||||
dialog
|
||||
? [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
|
||||
(element) => !element.hasAttribute('disabled')
|
||||
)
|
||||
: [];
|
||||
|
||||
focusableElements()[0]?.focus();
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
const elements = focusableElements();
|
||||
if (elements.length === 0) {
|
||||
event.preventDefault();
|
||||
dialog?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const first = elements[0]!;
|
||||
const last = elements.at(-1)!;
|
||||
const activeElement = document.activeElement;
|
||||
if (
|
||||
event.shiftKey &&
|
||||
(activeElement === first || !dialog?.contains(activeElement))
|
||||
) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (
|
||||
!event.shiftKey &&
|
||||
(activeElement === last || !dialog?.contains(activeElement))
|
||||
) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
returnFocus?.focus();
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function setTheme(theme: ThemeMode) {
|
||||
onChange({ ...preferences, theme });
|
||||
}
|
||||
|
||||
function exportPreferences() {
|
||||
const blob = new Blob([`${JSON.stringify(preferences, null, 2)}\n`], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = 'toolbox-preferences.json';
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setMessage('Preferences exported.');
|
||||
}
|
||||
|
||||
async function importPreferences(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
const imported = parsePreferences(JSON.parse(await file.text()));
|
||||
onChange(imported);
|
||||
setMessage('Preferences imported.');
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error
|
||||
? `Import failed: ${error.message}`
|
||||
: 'Import failed.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="dialog-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => event.target === event.currentTarget && onClose()}
|
||||
>
|
||||
<section
|
||||
ref={panel}
|
||||
className="preferences-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="preferences-title"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<p className="eyebrow">On this device</p>
|
||||
<h2 id="preferences-title">Personalize your toolbox</h2>
|
||||
</div>
|
||||
<button
|
||||
className="close-button"
|
||||
type="button"
|
||||
aria-label="Close preferences"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{!storageAvailable && (
|
||||
<p className="inline-warning" role="status">
|
||||
Browser storage is unavailable. Changes will last only until this
|
||||
page closes.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<fieldset className="theme-choice">
|
||||
<legend>Appearance</legend>
|
||||
<div>
|
||||
{(['system', 'light', 'dark'] as const).map((theme) => (
|
||||
<button
|
||||
key={theme}
|
||||
type="button"
|
||||
aria-pressed={preferences.theme === theme}
|
||||
className={preferences.theme === theme ? 'is-selected' : ''}
|
||||
onClick={() => setTheme(theme)}
|
||||
>
|
||||
{theme.slice(0, 1).toLocaleUpperCase() + theme.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="preference-summary">
|
||||
<div>
|
||||
<strong>{preferences.pinned.length}</strong>
|
||||
<span>Pinned</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{preferences.hidden.length}</strong>
|
||||
<span>Hidden</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{preferences.order.length}</strong>
|
||||
<span>Ordered</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="preferences-copy">
|
||||
The portal stores pins, visibility, order, and appearance in this
|
||||
browser and does not transmit them. Trusted code in another Toolbox
|
||||
app on this site can access same-origin storage. Export a JSON backup
|
||||
to move them to another device.
|
||||
</p>
|
||||
|
||||
<div className="preferences-actions">
|
||||
<button type="button" onClick={exportPreferences}>
|
||||
Export JSON
|
||||
</button>
|
||||
<button type="button" onClick={() => fileInput.current?.click()}>
|
||||
Import JSON
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
className="visually-hidden"
|
||||
type="file"
|
||||
tabIndex={-1}
|
||||
accept="application/json,.json"
|
||||
onChange={importPreferences}
|
||||
/>
|
||||
<button
|
||||
className="danger-button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange({ ...defaultPreferences });
|
||||
setMessage('Preferences reset.');
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<p className="action-message" role="status" aria-live="polite">
|
||||
{message}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled]):not([type="hidden"]):not([tabindex="-1"])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
78
src/preferences.test.ts
Normal file
78
src/preferences.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
PREFERENCES_KEY,
|
||||
cleanPreferences,
|
||||
defaultPreferences,
|
||||
moveId,
|
||||
parsePreferences,
|
||||
readPreferences,
|
||||
sortAppIds,
|
||||
writePreferences,
|
||||
} from './preferences';
|
||||
|
||||
describe('preferences', () => {
|
||||
it('round-trips namespaced settings', () => {
|
||||
const preferences = {
|
||||
...defaultPreferences,
|
||||
pinned: ['app.two'],
|
||||
order: ['app.two', 'app.one'],
|
||||
hidden: ['app.one'],
|
||||
theme: 'dark' as const,
|
||||
};
|
||||
writePreferences(preferences);
|
||||
expect(localStorage.getItem(PREFERENCES_KEY)).toContain('app.two');
|
||||
expect(readPreferences()).toEqual(preferences);
|
||||
});
|
||||
|
||||
it('falls back safely for corrupt stored JSON', () => {
|
||||
localStorage.setItem(PREFERENCES_KEY, '{nope');
|
||||
expect(readPreferences()).toEqual(defaultPreferences);
|
||||
});
|
||||
|
||||
it('validates imported JSON and removes duplicate ids', () => {
|
||||
expect(
|
||||
parsePreferences({
|
||||
version: 1,
|
||||
pinned: ['app.one', 'app.one'],
|
||||
order: [],
|
||||
hidden: [],
|
||||
theme: 'system',
|
||||
}).pinned
|
||||
).toEqual(['app.one']);
|
||||
expect(() => parsePreferences({ version: 2 })).toThrow(/version/i);
|
||||
expect(() =>
|
||||
parsePreferences({
|
||||
version: 1,
|
||||
pinned: 'app.one',
|
||||
order: [],
|
||||
hidden: [],
|
||||
theme: 'system',
|
||||
})
|
||||
).toThrow(/pinned/i);
|
||||
});
|
||||
|
||||
it('cleans stale ids, pins first, and supports personal ordering', () => {
|
||||
const cleaned = cleanPreferences(
|
||||
{
|
||||
version: 1,
|
||||
pinned: ['app.three', 'missing'],
|
||||
hidden: ['missing'],
|
||||
order: ['app.two', 'missing'],
|
||||
theme: 'system',
|
||||
},
|
||||
['app.one', 'app.two', 'app.three']
|
||||
);
|
||||
expect(cleaned.order).toEqual(['app.two', 'app.one', 'app.three']);
|
||||
expect(cleaned.hidden).toEqual([]);
|
||||
expect(sortAppIds(['app.one', 'app.two', 'app.three'], cleaned)).toEqual([
|
||||
'app.three',
|
||||
'app.two',
|
||||
'app.one',
|
||||
]);
|
||||
expect(moveId(cleaned.order, 'app.one', -1)).toEqual([
|
||||
'app.one',
|
||||
'app.two',
|
||||
'app.three',
|
||||
]);
|
||||
});
|
||||
});
|
||||
110
src/preferences.ts
Normal file
110
src/preferences.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { ThemeMode } from './types';
|
||||
|
||||
export const PREFERENCES_KEY = '@add-ideas/toolbox-portal:v1:preferences';
|
||||
|
||||
export interface Preferences {
|
||||
version: 1;
|
||||
pinned: string[];
|
||||
order: string[];
|
||||
hidden: string[];
|
||||
theme: ThemeMode;
|
||||
}
|
||||
|
||||
export const defaultPreferences: Preferences = {
|
||||
version: 1,
|
||||
pinned: [],
|
||||
order: [],
|
||||
hidden: [],
|
||||
theme: 'system',
|
||||
};
|
||||
|
||||
function uniqueStrings(value: unknown, field: string): string[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.some((item) => typeof item !== 'string' || item.length === 0)
|
||||
)
|
||||
throw new Error(`${field} must be an array of non-empty strings.`);
|
||||
return [...new Set(value)];
|
||||
}
|
||||
|
||||
export function parsePreferences(value: unknown): Preferences {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error('Preferences must be a JSON object.');
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (candidate.version !== 1)
|
||||
throw new Error('Unsupported preferences version.');
|
||||
const theme = candidate.theme;
|
||||
if (!['light', 'dark', 'system'].includes(String(theme)))
|
||||
throw new Error('Invalid theme preference.');
|
||||
return {
|
||||
version: 1,
|
||||
pinned: uniqueStrings(candidate.pinned, 'pinned'),
|
||||
order: uniqueStrings(candidate.order, 'order'),
|
||||
hidden: uniqueStrings(candidate.hidden, 'hidden'),
|
||||
theme: theme as ThemeMode,
|
||||
};
|
||||
}
|
||||
|
||||
export function readPreferences(storage: Storage = localStorage): Preferences {
|
||||
const value = storage.getItem(PREFERENCES_KEY);
|
||||
if (!value) return { ...defaultPreferences };
|
||||
try {
|
||||
return parsePreferences(JSON.parse(value));
|
||||
} catch {
|
||||
return { ...defaultPreferences };
|
||||
}
|
||||
}
|
||||
|
||||
export function writePreferences(
|
||||
preferences: Preferences,
|
||||
storage: Storage = localStorage
|
||||
): void {
|
||||
storage.setItem(PREFERENCES_KEY, JSON.stringify(preferences));
|
||||
}
|
||||
|
||||
export function cleanPreferences(
|
||||
preferences: Preferences,
|
||||
appIds: string[]
|
||||
): Preferences {
|
||||
const known = new Set(appIds);
|
||||
return {
|
||||
...preferences,
|
||||
pinned: preferences.pinned.filter((id) => known.has(id)),
|
||||
hidden: preferences.hidden.filter((id) => known.has(id)),
|
||||
order: [
|
||||
...preferences.order.filter((id) => known.has(id)),
|
||||
...appIds.filter((id) => !preferences.order.includes(id)),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function sortAppIds(
|
||||
appIds: string[],
|
||||
preferences: Preferences
|
||||
): string[] {
|
||||
const order = new Map(preferences.order.map((id, index) => [id, index]));
|
||||
const pinned = new Set(preferences.pinned);
|
||||
return [...appIds].sort((left, right) => {
|
||||
const pinDifference = Number(pinned.has(right)) - Number(pinned.has(left));
|
||||
if (pinDifference) return pinDifference;
|
||||
return (
|
||||
(order.get(left) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(order.get(right) ?? Number.MAX_SAFE_INTEGER)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function moveId(
|
||||
order: string[],
|
||||
id: string,
|
||||
direction: -1 | 1
|
||||
): string[] {
|
||||
const current = order.indexOf(id);
|
||||
if (current < 0) return order;
|
||||
const target = current + direction;
|
||||
if (target < 0 || target >= order.length) return order;
|
||||
const next = [...order];
|
||||
[next[current], next[target]] = [next[target]!, next[current]!];
|
||||
return next;
|
||||
}
|
||||
814
src/styles.css
Normal file
814
src/styles.css
Normal file
@@ -0,0 +1,814 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family: Inter, Avenir, 'Segoe UI', ui-sans-serif, system-ui, sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
--bg: #f6f7fb;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #eff2f8;
|
||||
--text: #17203b;
|
||||
--muted: #667085;
|
||||
--line: #dfe3ec;
|
||||
--brand: #28366d;
|
||||
--brand-hover: #1c2858;
|
||||
--brand-soft: #e9edff;
|
||||
--accent: #78d8ca;
|
||||
--danger: #a63243;
|
||||
--shadow: 0 16px 44px rgba(24, 34, 68, 0.08);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
--bg: #0f1422;
|
||||
--surface: #171d2d;
|
||||
--surface-soft: #20283a;
|
||||
--text: #edf1fb;
|
||||
--muted: #a9b3ca;
|
||||
--line: #303a50;
|
||||
--brand: #a9b7ff;
|
||||
--brand-hover: #c3ccff;
|
||||
--brand-soft: #252f55;
|
||||
--accent: #66cdbd;
|
||||
--danger: #ff9dac;
|
||||
--shadow: 0 18px 48px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root[data-theme='system'] {
|
||||
color-scheme: dark;
|
||||
--bg: #0f1422;
|
||||
--surface: #171d2d;
|
||||
--surface-soft: #20283a;
|
||||
--text: #edf1fb;
|
||||
--muted: #a9b3ca;
|
||||
--line: #303a50;
|
||||
--brand: #a9b7ff;
|
||||
--brand-hover: #c3ccff;
|
||||
--brand-soft: #252f55;
|
||||
--accent: #66cdbd;
|
||||
--danger: #ff9dac;
|
||||
--shadow: 0 18px 48px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--bg);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 8% 4%,
|
||||
color-mix(in srgb, var(--accent) 13%, transparent) 0,
|
||||
transparent 28rem
|
||||
),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
a,
|
||||
input,
|
||||
select {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--accent) 70%, var(--brand));
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.site-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
top: 0.75rem;
|
||||
left: 0.75rem;
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: 0.55rem;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
transform: translateY(-160%);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.header-inner,
|
||||
main,
|
||||
.site-footer {
|
||||
width: min(1160px, calc(100% - 2.5rem));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
min-height: 74px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand .logo {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.brand > span {
|
||||
display: grid;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-family: Inter, Avenir, 'Segoe UI', ui-sans-serif, sans-serif;
|
||||
font-size: 1.03rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.brand small {
|
||||
margin-top: 0.22rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.17em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.settings-button,
|
||||
.state-card button,
|
||||
.preferences-actions button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-button {
|
||||
padding: 0.62rem 0.82rem;
|
||||
}
|
||||
|
||||
.settings-button:hover,
|
||||
.state-card button:hover,
|
||||
.preferences-actions button:hover {
|
||||
border-color: var(--brand);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hero {
|
||||
max-width: 750px;
|
||||
padding: clamp(4.5rem, 9vw, 8.5rem) 0 clamp(3rem, 6vw, 5.2rem);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--brand);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-family: Inter, Avenir, 'Segoe UI', ui-sans-serif, sans-serif;
|
||||
font-size: clamp(2.65rem, 7vw, 5.65rem);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.065em;
|
||||
}
|
||||
|
||||
.hero h1 span {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
max-width: 575px;
|
||||
margin: 1.75rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: clamp(1rem, 2vw, 1.18rem);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.tool-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.search-field,
|
||||
.category-field {
|
||||
display: flex;
|
||||
min-height: 46px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.search-field {
|
||||
width: min(360px, 100%);
|
||||
padding: 0 0.85rem;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.search-field > span {
|
||||
color: var(--muted);
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.search-field input,
|
||||
.category-field select {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.search-field input::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.category-field {
|
||||
min-width: 175px;
|
||||
padding: 0 0.65rem;
|
||||
}
|
||||
|
||||
.category-field select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hidden-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-left: auto;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.hidden-toggle input {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
accent-color: var(--brand);
|
||||
}
|
||||
|
||||
.app-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 330px), 1fr));
|
||||
gap: 1.1rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-card {
|
||||
min-height: 340px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.2rem;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
transform 180ms ease,
|
||||
box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.app-card:hover {
|
||||
border-color: color-mix(in srgb, var(--brand) 35%, var(--line));
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 20px 54px rgba(24, 34, 68, 0.13);
|
||||
}
|
||||
|
||||
.app-card--hidden {
|
||||
border-style: dashed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.app-card__top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 1.35rem 1.35rem 0;
|
||||
}
|
||||
|
||||
.app-card__icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 0.95rem;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.app-card__icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.app-card__actions {
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.icon-button,
|
||||
.close-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-button:hover:not(:disabled),
|
||||
.icon-button.is-active {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.icon-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.app-card__body {
|
||||
flex: 1;
|
||||
padding: 1.25rem 1.35rem 1rem;
|
||||
}
|
||||
|
||||
.title-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.title-line h2 {
|
||||
margin: 0;
|
||||
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
|
||||
font-size: 1.3rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.status-chip,
|
||||
.tag-row span {
|
||||
border-radius: 999px;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
padding: 0.22rem 0.48rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-chip--muted {
|
||||
background: var(--surface-soft);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.app-card__body > p {
|
||||
margin: 0.75rem 0 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app-card__body .requirements {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.app-card__body .privacy-summary {
|
||||
color: var(--text);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.tag-row span {
|
||||
padding: 0.28rem 0.52rem;
|
||||
background: var(--surface-soft);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.app-card__footer {
|
||||
min-height: 68px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1.35rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.version {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.app-card__meta {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.source-link,
|
||||
.site-footer a {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.launch-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.62rem 0.82rem;
|
||||
border-radius: 0.65rem;
|
||||
background: var(--brand);
|
||||
color: var(--surface);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .launch-button {
|
||||
color: #11182a;
|
||||
}
|
||||
|
||||
.launch-button:hover {
|
||||
background: var(--brand-hover);
|
||||
}
|
||||
|
||||
.state-card {
|
||||
max-width: 600px;
|
||||
margin: 0 auto 5rem;
|
||||
padding: 3rem 2rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.2rem;
|
||||
background: var(--surface);
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.state-card h2 {
|
||||
margin: 0.8rem 0 0.35rem;
|
||||
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.state-card p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.state-card button {
|
||||
margin-top: 1.25rem;
|
||||
padding: 0.7rem 1rem;
|
||||
}
|
||||
|
||||
.state-icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.state-card--error .state-icon {
|
||||
background: color-mix(in srgb, var(--danger) 12%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
margin: 0 auto;
|
||||
border: 3px solid var(--line);
|
||||
border-top-color: var(--brand);
|
||||
border-radius: 50%;
|
||||
animation: spin 700ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.manifest-errors {
|
||||
margin: 1.5rem 0 4rem;
|
||||
padding: 1rem 1.2rem;
|
||||
border: 1px solid color-mix(in srgb, var(--danger) 30%, var(--line));
|
||||
border-radius: 0.8rem;
|
||||
background: color-mix(in srgb, var(--danger) 5%, var(--surface));
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.manifest-errors summary {
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.manifest-errors li + li {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
min-height: 110px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-top: 5rem;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.site-footer p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dialog-backdrop {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: rgba(5, 9, 22, 0.56);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.preferences-panel {
|
||||
width: min(560px, 100%);
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow: auto;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1.2rem;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.preferences-panel > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.preferences-panel h2 {
|
||||
margin: 0;
|
||||
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.preferences-panel .eyebrow {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
flex: 0 0 auto;
|
||||
background: var(--surface-soft);
|
||||
color: var(--text);
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.theme-choice {
|
||||
margin: 1.5rem 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.theme-choice legend {
|
||||
margin-bottom: 0.6rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.theme-choice > div {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.theme-choice button {
|
||||
padding: 0.62rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-choice button.is-selected {
|
||||
background: var(--surface);
|
||||
color: var(--brand);
|
||||
box-shadow: 0 2px 8px rgba(20, 30, 60, 0.09);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preference-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.preference-summary > div {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.9rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.7rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preference-summary strong {
|
||||
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.preference-summary span,
|
||||
.preferences-copy,
|
||||
.action-message {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.preferences-copy {
|
||||
margin: 1.1rem 0;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.preferences-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.preferences-actions button {
|
||||
padding: 0.62rem 0.78rem;
|
||||
}
|
||||
|
||||
.preferences-actions .danger-button {
|
||||
margin-left: auto;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.action-message {
|
||||
min-height: 1.2em;
|
||||
margin: 0.8rem 0 0;
|
||||
}
|
||||
|
||||
.inline-warning {
|
||||
padding: 0.7rem 0.8rem;
|
||||
border-radius: 0.6rem;
|
||||
background: color-mix(in srgb, var(--danger) 8%, var(--surface));
|
||||
color: var(--danger);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.header-inner,
|
||||
main,
|
||||
.site-footer {
|
||||
width: min(100% - 1.4rem, 1160px);
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
min-height: 66px;
|
||||
}
|
||||
|
||||
.settings-button {
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.settings-button span {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding-top: 3.8rem;
|
||||
}
|
||||
|
||||
.tool-controls {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-field,
|
||||
.category-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hidden-toggle {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.app-card {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
min-height: 130px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
61
src/test/fixtures.ts
Normal file
61
src/test/fixtures.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
ToolboxAppManifest,
|
||||
ToolboxCatalog,
|
||||
} from '@add-ideas/toolbox-contract';
|
||||
|
||||
export const pdfManifest: ToolboxAppManifest = {
|
||||
schemaVersion: 1,
|
||||
id: 'de.add-ideas.pdf-tools',
|
||||
name: 'PDF Workbench',
|
||||
version: '0.3.4',
|
||||
description: 'Page-level PDF operations performed locally in the browser.',
|
||||
entry: './',
|
||||
icon: './favicon.svg',
|
||||
categories: ['documents', 'pdf'],
|
||||
tags: ['merge', 'split'],
|
||||
integration: {
|
||||
contextVersion: 1,
|
||||
launchModes: ['navigate', 'new-tab'],
|
||||
embedding: 'unsupported',
|
||||
},
|
||||
requirements: {
|
||||
secureContext: true,
|
||||
workers: true,
|
||||
indexedDb: true,
|
||||
crossOriginIsolated: false,
|
||||
},
|
||||
privacy: {
|
||||
processing: 'local',
|
||||
fileUploads: false,
|
||||
telemetry: false,
|
||||
},
|
||||
source: {
|
||||
repository: 'https://git.add-ideas.de/zemion/pdf-tools',
|
||||
license: 'AGPL-3.0-only',
|
||||
},
|
||||
};
|
||||
|
||||
export const catalogue: ToolboxCatalog = {
|
||||
schemaVersion: 1,
|
||||
id: 'de.add-ideas.toolbox',
|
||||
name: 'add·ideas Toolbox',
|
||||
home: './',
|
||||
theme: { mode: 'system', brand: 'add·ideas' },
|
||||
apps: [{ manifest: './apps/pdf/toolbox-app.json', enabled: true }],
|
||||
};
|
||||
|
||||
export function catalogueFetch(overrides: Record<string, Response> = {}) {
|
||||
return async (input: string | URL | Request): Promise<Response> => {
|
||||
const url = new URL(
|
||||
input instanceof Request ? input.url : String(input),
|
||||
document.baseURI
|
||||
);
|
||||
const custom = overrides[url.pathname];
|
||||
if (custom) return custom.clone();
|
||||
if (url.pathname.endsWith('/toolbox.catalog.json'))
|
||||
return Response.json(catalogue);
|
||||
if (url.pathname.endsWith('/apps/pdf/toolbox-app.json'))
|
||||
return Response.json(pdfManifest);
|
||||
return new Response('Not found', { status: 404 });
|
||||
};
|
||||
}
|
||||
10
src/test/setup.ts
Normal file
10
src/test/setup.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
if (typeof localStorage !== 'undefined') localStorage.clear();
|
||||
if (typeof document !== 'undefined')
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
});
|
||||
39
src/types.ts
Normal file
39
src/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type {
|
||||
ToolboxAppManifest,
|
||||
ToolboxCatalog,
|
||||
ToolboxRequirements,
|
||||
} from '@add-ideas/toolbox-contract';
|
||||
|
||||
export type ThemeMode = ToolboxCatalog['theme']['mode'];
|
||||
|
||||
export interface ResolvedApp {
|
||||
id: string;
|
||||
name: string;
|
||||
version?: string;
|
||||
description: string;
|
||||
entryUrl: string;
|
||||
iconUrl?: string;
|
||||
categories: readonly string[];
|
||||
tags: readonly string[];
|
||||
launchModes: ToolboxAppManifest['integration']['launchModes'];
|
||||
isExternal: boolean;
|
||||
manifestUrl?: string;
|
||||
sourceUrl?: string;
|
||||
requirements?: ToolboxRequirements;
|
||||
privacy?: ToolboxAppManifest['privacy'];
|
||||
}
|
||||
|
||||
export interface UnavailableApp {
|
||||
id: string;
|
||||
name: string;
|
||||
manifestUrl: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface LoadedCatalogue {
|
||||
catalogue: ToolboxCatalog;
|
||||
catalogueUrl: URL;
|
||||
homeUrl: string;
|
||||
apps: ResolvedApp[];
|
||||
unavailable: UnavailableApp[];
|
||||
}
|
||||
47
src/usePreferences.ts
Normal file
47
src/usePreferences.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
PREFERENCES_KEY,
|
||||
defaultPreferences,
|
||||
readPreferences,
|
||||
writePreferences,
|
||||
type Preferences,
|
||||
} from './preferences';
|
||||
|
||||
interface InitialPreferences {
|
||||
preferences: Preferences;
|
||||
storageAvailable: boolean;
|
||||
}
|
||||
|
||||
function initializePreferences(): InitialPreferences {
|
||||
try {
|
||||
const preferences = readPreferences();
|
||||
const probeKey = `${PREFERENCES_KEY}:probe`;
|
||||
localStorage.setItem(probeKey, '1');
|
||||
localStorage.removeItem(probeKey);
|
||||
return { preferences, storageAvailable: true };
|
||||
} catch {
|
||||
return { preferences: { ...defaultPreferences }, storageAvailable: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function usePreferences(): [
|
||||
Preferences,
|
||||
(next: Preferences | ((current: Preferences) => Preferences)) => void,
|
||||
boolean,
|
||||
] {
|
||||
const [initial] = useState(initializePreferences);
|
||||
const [preferences, setPreferences] = useState<Preferences>(
|
||||
initial.preferences
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
writePreferences(preferences);
|
||||
} catch {
|
||||
// The UI already reports storage that was unavailable during initialization.
|
||||
// A later quota/privacy-mode failure leaves this session's in-memory choices intact.
|
||||
}
|
||||
}, [preferences]);
|
||||
|
||||
return [preferences, setPreferences, initial.storageAvailable];
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
22
tsconfig.app.json
Normal file
22
tsconfig.app.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"noUncheckedIndexedAccess": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
17
tsconfig.node.json
Normal file
17
tsconfig.node.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts", "eslint.config.mjs"]
|
||||
}
|
||||
14
vite.config.ts
Normal file
14
vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
css: true,
|
||||
restoreMocks: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user