Make it public!
16
.env.sample
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
PORT=8000
|
||||||
|
|
||||||
|
POSTGRESQL_HOST="localhost"
|
||||||
|
POSTGRESQL_USER="postgres"
|
||||||
|
POSTGRESQL_PASSWORD="fake"
|
||||||
|
POSTGRESQL_DBNAME="apollo"
|
||||||
|
POSTGRESQL_PORT=5432
|
||||||
|
POSTGRESQL_CAFILE=""
|
||||||
|
|
||||||
|
JWT_SECRET="fake"
|
||||||
|
PASSWORD_SALT="fake"
|
||||||
|
|
||||||
|
BREVO_API_KEY="fake"
|
||||||
|
|
||||||
|
CONFIG_ALLOW_SIGNUPS="false"
|
||||||
|
CONFIG_ENABLED_APPS="dashboard,news,contacts,calendar,tasks,files,notes,photos"
|
||||||
6
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
github: [BrunoBernardino]
|
||||||
|
custom:
|
||||||
|
[
|
||||||
|
'https://paypal.me/brunobernardino',
|
||||||
|
'https://gist.github.com/BrunoBernardino/ff5b54c13dd96ac7f9fee6fbfd825b09',
|
||||||
|
]
|
||||||
33
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Configure SSH
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh/
|
||||||
|
echo "$SSH_KEY" | tr -d '\r' > ~/.ssh/server.key
|
||||||
|
chmod 600 ~/.ssh/server.key
|
||||||
|
cat >>~/.ssh/config <<END
|
||||||
|
Host server
|
||||||
|
HostName $SSH_HOST
|
||||||
|
User $SSH_USER
|
||||||
|
IdentityFile ~/.ssh/server.key
|
||||||
|
StrictHostKeyChecking no
|
||||||
|
END
|
||||||
|
cat ~/.ssh/config
|
||||||
|
env:
|
||||||
|
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||||
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
|
SSH_KEY: ${{ secrets.SSH_KEY }}
|
||||||
|
|
||||||
|
- name: Deploy via SSH
|
||||||
|
run: ssh server 'cd apps/bewcloud-app && git add . && git stash && git pull origin main && git stash clear && git remote prune origin && cp ../../scripts/config/bewcloud-app/.env . && cp ../../scripts/config/bewcloud-app/docker-compose.yml . && docker system prune -f && docker compose up -d --build && docker compose ps && docker compose logs'
|
||||||
15
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
name: Run Tests
|
||||||
|
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: denoland/setup-deno@v1
|
||||||
|
with:
|
||||||
|
deno-version: v1.41.0
|
||||||
|
- run: |
|
||||||
|
make test
|
||||||
|
make build
|
||||||
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Fresh build directory
|
||||||
|
_fresh/
|
||||||
|
|
||||||
|
# npm dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
db/
|
||||||
|
|
||||||
|
# Env var
|
||||||
|
.env
|
||||||
6
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"denoland.vscode-deno",
|
||||||
|
"bradlc.vscode-tailwindcss"
|
||||||
|
]
|
||||||
|
}
|
||||||
23
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"deno.enable": true,
|
||||||
|
"deno.lint": true,
|
||||||
|
"editor.defaultFormatter": "denoland.vscode-deno",
|
||||||
|
"[typescriptreact]": {
|
||||||
|
"editor.defaultFormatter": "denoland.vscode-deno"
|
||||||
|
},
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "denoland.vscode-deno"
|
||||||
|
},
|
||||||
|
"[javascriptreact]": {
|
||||||
|
"editor.defaultFormatter": "denoland.vscode-deno"
|
||||||
|
},
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.defaultFormatter": "denoland.vscode-deno"
|
||||||
|
},
|
||||||
|
"css.customData": [
|
||||||
|
".vscode/tailwind.json"
|
||||||
|
],
|
||||||
|
"yaml.schemas": {
|
||||||
|
"https://json.schemastore.org/github-workflow.json": ".github/workflows/deploy.yml"
|
||||||
|
}
|
||||||
|
}
|
||||||
55
.vscode/tailwind.json
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"version": 1.1,
|
||||||
|
"atDirectives": [
|
||||||
|
{
|
||||||
|
"name": "@tailwind",
|
||||||
|
"description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@apply",
|
||||||
|
"description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#apply"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@responsive",
|
||||||
|
"description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@screen",
|
||||||
|
"description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#screen"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@variants",
|
||||||
|
"description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#variants"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
FROM denoland/deno:alpine-1.41.0
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# These steps will be re-run upon each file change in your working directory:
|
||||||
|
ADD . /app
|
||||||
|
|
||||||
|
RUN rm -fr node_modules _fresh
|
||||||
|
|
||||||
|
# Build fresh
|
||||||
|
RUN deno task build
|
||||||
|
|
||||||
|
RUN chown -R deno:deno /app /deno-dir
|
||||||
|
|
||||||
|
# Prefer not to run as root.
|
||||||
|
USER deno
|
||||||
|
|
||||||
|
# Compile the main app so that it doesn't need to be compiled each startup/entry.
|
||||||
|
RUN deno cache --reload main.ts
|
||||||
|
|
||||||
|
CMD ["run", "--allow-all", "main.ts"]
|
||||||
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/>.
|
||||||
28
Makefile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
.PHONY: start
|
||||||
|
start:
|
||||||
|
deno task start
|
||||||
|
|
||||||
|
.PHONY: format
|
||||||
|
format:
|
||||||
|
deno fmt
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
deno task check
|
||||||
|
deno task test
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build:
|
||||||
|
deno task build
|
||||||
|
|
||||||
|
.PHONY: migrate-db
|
||||||
|
migrate-db:
|
||||||
|
deno run --allow-net --allow-read --allow-env migrate-db.ts
|
||||||
|
|
||||||
|
.PHONY: crons/cleanup
|
||||||
|
crons/cleanup:
|
||||||
|
deno run --allow-net --allow-read --allow-env crons/cleanup.ts
|
||||||
|
|
||||||
|
.PHONY: exec-db
|
||||||
|
exec-db:
|
||||||
|
docker exec -it -u postgres $(shell basename $(CURDIR))-postgresql-1 psql
|
||||||
68
README.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# bewCloud
|
||||||
|
|
||||||
|
[](https://github.com/bewcloud/bewcloud/actions?workflow=Run+Tests)
|
||||||
|
|
||||||
|
This is the [bewCloud app](https://bewcloud.com) built using [Fresh](https://fresh.deno.dev) and deployed using [docker compose](https://docs.docker.com/compose/).
|
||||||
|
|
||||||
|
## Self-host it!
|
||||||
|
|
||||||
|
Check the [Development section below](#development).
|
||||||
|
|
||||||
|
> **NOTE:** You don't need to have emails (Brevo) setup to have the app work. Those are only setup and used for email verification and future needs. You can simply make any `user.status = 'active'` and `user.subscription.expires_at = new Date('2100-01-01')` to "never" expire, in the database, directly.
|
||||||
|
|
||||||
|
> **NOTE 2:** Even with signups disabled (`CONFIG_ALLOW_SIGNUPS="false"`), the first signup will work and become an admin.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
This was tested with [`Deno`](https://deno.land)'s version stated in the `.dvmrc` file, though other versions may work.
|
||||||
|
|
||||||
|
For the postgres dependency (used when running locally or in CI), you should have `Docker` and `docker compose` installed.
|
||||||
|
|
||||||
|
Don't forget to set up your `.env` file based on `.env.sample`.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ docker compose up # (optional) runs docker with postgres, locally
|
||||||
|
$ make migrate-db # runs any missing database migrations
|
||||||
|
$ make start # runs the app
|
||||||
|
$ make format # formats the code
|
||||||
|
$ make test # runs tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Other less-used commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ make exec-db # runs psql inside the postgres container, useful for running direct development queries like `DROP DATABASE "bewcloud"; CREATE DATABASE "bewcloud";`
|
||||||
|
$ make build # generates all static files for production deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
- Routes defined at `routes/`.
|
||||||
|
- Static files are defined at `static/`.
|
||||||
|
- Static frontent components are defined at `components/`.
|
||||||
|
- Interactive frontend components are defined at `islands/`.
|
||||||
|
- Cron jobs are defined at `crons/`.
|
||||||
|
- Reusable bits of code are defined at `lib/`.
|
||||||
|
- Database migrations are defined at `db-migrations/`.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Just push to the `main` branch.
|
||||||
|
|
||||||
|
## Tentative Roadmap:
|
||||||
|
|
||||||
|
- [x] Dashboard with URLs and Notes
|
||||||
|
- [x] News
|
||||||
|
- [x] Contacts / CardDav
|
||||||
|
- [ ] Calendar / CalDav
|
||||||
|
- [ ] Tasks / CalDav
|
||||||
|
- [ ] Files / WebDav
|
||||||
|
- [ ] Notes / WebDav
|
||||||
|
- [ ] Photos / WebDav
|
||||||
|
- [ ] Desktop app for selective file sync (or potentially just `rclone`)
|
||||||
|
- [ ] Mobile app for offline file sync
|
||||||
|
- [ ] Add notes support for mobile app
|
||||||
|
- [ ] Add photos/sync support for mobile client
|
||||||
|
- [ ] Address `TODO:`s in code
|
||||||
174
components/Header.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { Head } from 'fresh/runtime.ts';
|
||||||
|
|
||||||
|
import { User } from '/lib/types.ts';
|
||||||
|
|
||||||
|
interface Data {
|
||||||
|
route: string;
|
||||||
|
user?: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
url: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Header({ route, user }: Data) {
|
||||||
|
const activeClass = 'bg-slate-800 text-white rounded-md px-3 py-2 text-sm font-medium';
|
||||||
|
const defaultClass = 'text-slate-300 hover:bg-slate-700 hover:text-white rounded-md px-3 py-2 text-sm font-medium';
|
||||||
|
|
||||||
|
const mobileActiveClass = 'bg-slate-800 text-white block rounded-md px-3 py-2 text-base font-medium';
|
||||||
|
const mobileDefaultClass =
|
||||||
|
'text-slate-300 hover:bg-slate-700 hover:text-white block rounded-md px-3 py-2 text-base font-medium';
|
||||||
|
|
||||||
|
const iconWidthAndHeightInPixels = 20;
|
||||||
|
|
||||||
|
const menuItems: MenuItem[] = [
|
||||||
|
{
|
||||||
|
url: '/dashboard',
|
||||||
|
label: 'Dashboard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: '/news',
|
||||||
|
label: 'News',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: '/contacts',
|
||||||
|
label: 'Contacts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: '/calendar',
|
||||||
|
label: 'Calendar',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: '/tasks',
|
||||||
|
label: 'Tasks',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: '/files',
|
||||||
|
label: 'Files',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: '/notes',
|
||||||
|
label: 'Notes',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: '/photos',
|
||||||
|
label: 'Photos',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const activeMenu = menuItems.find((menu) => route.startsWith(menu.url));
|
||||||
|
|
||||||
|
let pageLabel = activeMenu?.label || '404 - Page not found';
|
||||||
|
|
||||||
|
if (route.startsWith('/news/feeds')) {
|
||||||
|
pageLabel = 'News feeds';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.startsWith('/settings')) {
|
||||||
|
pageLabel = 'Settings';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{pageLabel} - bewCloud</title>
|
||||||
|
</Head>
|
||||||
|
<nav class='bg-slate-950'>
|
||||||
|
<div class='mx-auto max-w-7xl px-4 sm:px-6 lg:px-8'>
|
||||||
|
<div class='flex h-16 items-center justify-between'>
|
||||||
|
<div class='flex items-center'>
|
||||||
|
<div class='flex-shrink-0'>
|
||||||
|
<a href='/'>
|
||||||
|
<img class='h-12 w-12 drop-shadow-md' src='/images/logomark.svg' alt='a stylized blue cloud' />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class='hidden md:block'>
|
||||||
|
<div class='ml-10 flex items-center space-x-4'>
|
||||||
|
{menuItems.map((menu) => (
|
||||||
|
<a href={menu.url} class={route.startsWith(menu.url) ? activeClass : defaultClass}>
|
||||||
|
<img
|
||||||
|
src={`/images${menu.url}${'.svg'}`}
|
||||||
|
alt={menu.label}
|
||||||
|
title={menu.label}
|
||||||
|
width={iconWidthAndHeightInPixels}
|
||||||
|
height={iconWidthAndHeightInPixels}
|
||||||
|
class='white'
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='ml-4 flex items-center md:ml-6'>
|
||||||
|
<div class='ml-10 flex items-center space-x-4'>
|
||||||
|
<span class='mx-2 text-white text-sm'>{user.email}</span>
|
||||||
|
<a
|
||||||
|
href='/settings'
|
||||||
|
class={route.startsWith('/settings') ? activeClass : defaultClass}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/images/settings.svg'
|
||||||
|
alt='Settings'
|
||||||
|
title='Settings'
|
||||||
|
width={iconWidthAndHeightInPixels}
|
||||||
|
height={iconWidthAndHeightInPixels}
|
||||||
|
class='white'
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href='/logout'
|
||||||
|
class={defaultClass}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/images/logout.svg'
|
||||||
|
alt='Logout'
|
||||||
|
title='Logout'
|
||||||
|
width={iconWidthAndHeightInPixels}
|
||||||
|
height={iconWidthAndHeightInPixels}
|
||||||
|
class='white'
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='md:hidden' id='mobile-menu'>
|
||||||
|
<div class='space-y-1 px-2 pb-3 pt-2 sm:px-3'>
|
||||||
|
{menuItems.map((menu) => (
|
||||||
|
<a href={menu.url} class={route.startsWith(menu.url) ? mobileActiveClass : mobileDefaultClass}>
|
||||||
|
{menu.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class='bg-gray-900 shadow-md'>
|
||||||
|
<div class='mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8'>
|
||||||
|
<h1 class='text-3xl font-bold tracking-tight text-white'>
|
||||||
|
{pageLabel}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header class='px-4 pt-8 pb-2 max-w-screen-md mx-auto flex flex-col items-center justify-center'>
|
||||||
|
<a href='/'>
|
||||||
|
<img
|
||||||
|
class='mt-6 mb-2 drop-shadow-md'
|
||||||
|
src='/images/logo-white.svg'
|
||||||
|
width='250'
|
||||||
|
height='50'
|
||||||
|
alt='the bewCloud logo: a stylized logo'
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
crons/cleanup.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||||
|
|
||||||
|
const db = new Database();
|
||||||
|
|
||||||
|
export async function cleanupSessions() {
|
||||||
|
const yesterday = new Date(new Date().setUTCDate(new Date().getUTCDate() - 1));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await db.query<{ count: number }>(
|
||||||
|
sql`WITH "deleted" AS (
|
||||||
|
DELETE FROM "bewcloud_user_sessions" WHERE "expires_at" <= $1 RETURNING *
|
||||||
|
)
|
||||||
|
SELECT COUNT(*) FROM "deleted"`,
|
||||||
|
[
|
||||||
|
yesterday.toISOString().substring(0, 10),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Deleted', result[0].count, 'user sessions');
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
crons/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Cron } from 'https://deno.land/x/croner@7.0.1/dist/croner.js';
|
||||||
|
|
||||||
|
import { cleanupSessions } from './cleanup.ts';
|
||||||
|
import { fetchNewArticles } from './news.ts';
|
||||||
|
|
||||||
|
export function startCrons() {
|
||||||
|
new Cron(
|
||||||
|
// At 03:06 every day.
|
||||||
|
'6 3 * * *',
|
||||||
|
{
|
||||||
|
name: 'cleanup',
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
await cleanupSessions();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
new Cron(
|
||||||
|
// Every 30 minutes.
|
||||||
|
'*/30 * * * *',
|
||||||
|
{
|
||||||
|
name: 'news',
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
await fetchNewArticles();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Crons starting...');
|
||||||
|
}
|
||||||
25
crons/news.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||||
|
import { NewsFeed } from '/lib/types.ts';
|
||||||
|
import { concurrentPromises } from '/lib/utils.ts';
|
||||||
|
import { crawlNewsFeed } from '/lib/data/news.ts';
|
||||||
|
|
||||||
|
const db = new Database();
|
||||||
|
|
||||||
|
export async function fetchNewArticles(forceFetch = false) {
|
||||||
|
const fourHoursAgo = forceFetch ? new Date() : new Date(new Date().setUTCHours(new Date().getUTCHours() - 4));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const feedsToCrawl = await db.query<NewsFeed>(
|
||||||
|
sql`SELECT * FROM "bewcloud_news_feeds" WHERE "last_crawled_at" IS NULL OR "last_crawled_at" <= $1`,
|
||||||
|
[
|
||||||
|
fourHoursAgo.toISOString().substring(0, 10),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await concurrentPromises(feedsToCrawl.map((newsFeed) => () => crawlNewsFeed(newsFeed)), 3);
|
||||||
|
|
||||||
|
console.log('Crawled', feedsToCrawl.length, 'news feeds');
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
209
db-migrations/001-base.pgsql
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
SET statement_timeout = 0;
|
||||||
|
SET lock_timeout = 0;
|
||||||
|
SET idle_in_transaction_session_timeout = 0;
|
||||||
|
SET client_encoding = 'UTF8';
|
||||||
|
SET standard_conforming_strings = on;
|
||||||
|
SELECT pg_catalog.set_config('search_path', '', false);
|
||||||
|
SET check_function_bodies = false;
|
||||||
|
SET xmloption = content;
|
||||||
|
SET client_min_messages = warning;
|
||||||
|
SET row_security = off;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_user_sessions; Type: TABLE; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.bewcloud_user_sessions (
|
||||||
|
id uuid DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid DEFAULT gen_random_uuid(),
|
||||||
|
expires_at timestamp with time zone NOT NULL,
|
||||||
|
last_seen_at timestamp with time zone DEFAULT now(),
|
||||||
|
created_at timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.bewcloud_user_sessions OWNER TO postgres;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_users; Type: TABLE; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.bewcloud_users (
|
||||||
|
id uuid DEFAULT gen_random_uuid(),
|
||||||
|
email character varying NOT NULL,
|
||||||
|
hashed_password text NOT NULL,
|
||||||
|
subscription jsonb NOT NULL,
|
||||||
|
status character varying NOT NULL,
|
||||||
|
extra jsonb NOT NULL,
|
||||||
|
created_at timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.bewcloud_users OWNER TO postgres;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_verification_codes; Type: TABLE; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.bewcloud_verification_codes (
|
||||||
|
id uuid DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid DEFAULT gen_random_uuid(),
|
||||||
|
code character varying NOT NULL,
|
||||||
|
verification jsonb NOT NULL,
|
||||||
|
expires_at timestamp with time zone NOT NULL,
|
||||||
|
created_at timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.bewcloud_verification_codes OWNER TO postgres;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_dashboards; Type: TABLE; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.bewcloud_dashboards (
|
||||||
|
id uuid DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid DEFAULT gen_random_uuid(),
|
||||||
|
data jsonb NOT NULL,
|
||||||
|
created_at timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.bewcloud_dashboards OWNER TO postgres;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX bewcloud_dashboards_unique_index ON public.bewcloud_dashboards ( user_id );
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_migrations; Type: TABLE; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.bewcloud_migrations (
|
||||||
|
id uuid DEFAULT gen_random_uuid(),
|
||||||
|
name character varying(100) NOT NULL,
|
||||||
|
executed_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.bewcloud_migrations OWNER TO postgres;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_user_sessions bewcloud_user_sessions_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.bewcloud_user_sessions
|
||||||
|
ADD CONSTRAINT bewcloud_user_sessions_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_verification_codes bewcloud_verification_codes_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.bewcloud_verification_codes
|
||||||
|
ADD CONSTRAINT bewcloud_verification_codes_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_users bewcloud_users_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.bewcloud_users
|
||||||
|
ADD CONSTRAINT bewcloud_users_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_dashboards bewcloud_dashboards_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.bewcloud_dashboards
|
||||||
|
ADD CONSTRAINT bewcloud_dashboards_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_dashboards bewcloud_dashboards_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.bewcloud_dashboards
|
||||||
|
ADD CONSTRAINT bewcloud_dashboards_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_migrations bewcloud_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.bewcloud_migrations
|
||||||
|
ADD CONSTRAINT bewcloud_migrations_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_user_sessions bewcloud_user_sessions_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.bewcloud_user_sessions
|
||||||
|
ADD CONSTRAINT bewcloud_user_sessions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_verification_codes bewcloud_verification_codes_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.bewcloud_verification_codes
|
||||||
|
ADD CONSTRAINT bewcloud_verification_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: TABLE bewcloud_user_sessions; Type: ACL; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
GRANT ALL ON TABLE public.bewcloud_user_sessions TO postgres;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: TABLE bewcloud_users; Type: ACL; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
GRANT ALL ON TABLE public.bewcloud_users TO postgres;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: TABLE bewcloud_verification_codes; Type: ACL; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
GRANT ALL ON TABLE public.bewcloud_verification_codes TO postgres;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: TABLE bewcloud_dashboards; Type: ACL; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
GRANT ALL ON TABLE public.bewcloud_dashboards TO postgres;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: TABLE bewcloud_migrations; Type: ACL; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
GRANT ALL ON TABLE public.bewcloud_migrations TO postgres;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres;
|
||||||
106
db-migrations/002-news-app.pgsql
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
SET statement_timeout = 0;
|
||||||
|
SET lock_timeout = 0;
|
||||||
|
SET idle_in_transaction_session_timeout = 0;
|
||||||
|
SET client_encoding = 'UTF8';
|
||||||
|
SET standard_conforming_strings = on;
|
||||||
|
SELECT pg_catalog.set_config('search_path', '', false);
|
||||||
|
SET check_function_bodies = false;
|
||||||
|
SET xmloption = content;
|
||||||
|
SET client_min_messages = warning;
|
||||||
|
SET row_security = off;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_news_feeds; Type: TABLE; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.bewcloud_news_feeds (
|
||||||
|
id uuid DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid DEFAULT gen_random_uuid(),
|
||||||
|
feed_url text NOT NULL,
|
||||||
|
last_crawled_at timestamp with time zone DEFAULT NULL,
|
||||||
|
extra jsonb NOT NULL,
|
||||||
|
created_at timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.bewcloud_news_feeds OWNER TO postgres;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX bewcloud_news_feeds_unique_index ON public.bewcloud_news_feeds ( user_id, feed_url );
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_news_feeds bewcloud_news_feeds_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.bewcloud_news_feeds
|
||||||
|
ADD CONSTRAINT bewcloud_news_feeds_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_news_feeds bewcloud_news_feeds_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.bewcloud_news_feeds
|
||||||
|
ADD CONSTRAINT bewcloud_news_feeds_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: TABLE bewcloud_news_feeds; Type: ACL; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
GRANT ALL ON TABLE public.bewcloud_news_feeds TO postgres;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_news_feed_articles; Type: TABLE; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.bewcloud_news_feed_articles (
|
||||||
|
id uuid DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid DEFAULT gen_random_uuid(),
|
||||||
|
feed_id uuid DEFAULT gen_random_uuid(),
|
||||||
|
article_url text NOT NULL,
|
||||||
|
article_title text NOT NULL,
|
||||||
|
article_summary text NOT NULL,
|
||||||
|
article_date timestamp with time zone NOT NULL,
|
||||||
|
is_read boolean DEFAULT FALSE,
|
||||||
|
extra jsonb NOT NULL,
|
||||||
|
created_at timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.bewcloud_news_feed_articles OWNER TO postgres;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX bewcloud_news_feed_articles_unique_index ON public.bewcloud_news_feed_articles ( user_id, feed_id, article_url );
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_news_feed_articles bewcloud_news_feed_articles_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.bewcloud_news_feed_articles
|
||||||
|
ADD CONSTRAINT bewcloud_news_feed_articles_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_news_feed_articles bewcloud_news_feed_articles_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.bewcloud_news_feed_articles
|
||||||
|
ADD CONSTRAINT bewcloud_news_feed_articles_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_news_feed_articles bewcloud_news_feed_articles_feed_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.bewcloud_news_feed_articles
|
||||||
|
ADD CONSTRAINT bewcloud_news_feed_articles_feed_id_fkey FOREIGN KEY (feed_id) REFERENCES public.bewcloud_news_feeds(id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: TABLE bewcloud_news_feed_articles; Type: ACL; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
GRANT ALL ON TABLE public.bewcloud_news_feed_articles TO postgres;
|
||||||
52
db-migrations/003-contacts-app.pgsql
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
SET statement_timeout = 0;
|
||||||
|
SET lock_timeout = 0;
|
||||||
|
SET idle_in_transaction_session_timeout = 0;
|
||||||
|
SET client_encoding = 'UTF8';
|
||||||
|
SET standard_conforming_strings = on;
|
||||||
|
SELECT pg_catalog.set_config('search_path', '', false);
|
||||||
|
SET check_function_bodies = false;
|
||||||
|
SET xmloption = content;
|
||||||
|
SET client_min_messages = warning;
|
||||||
|
SET row_security = off;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_contacts; Type: TABLE; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.bewcloud_contacts (
|
||||||
|
id uuid DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid DEFAULT gen_random_uuid(),
|
||||||
|
revision text NOT NULL,
|
||||||
|
first_name text NOT NULL,
|
||||||
|
last_name text NOT NULL,
|
||||||
|
extra jsonb NOT NULL,
|
||||||
|
updated_at timestamp with time zone DEFAULT now(),
|
||||||
|
created_at timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.bewcloud_contacts OWNER TO postgres;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_contacts bewcloud_contacts_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.bewcloud_contacts
|
||||||
|
ADD CONSTRAINT bewcloud_contacts_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: bewcloud_contacts bewcloud_contacts_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.bewcloud_contacts
|
||||||
|
ADD CONSTRAINT bewcloud_contacts_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: TABLE bewcloud_contacts; Type: ACL; Schema: public; Owner: postgres
|
||||||
|
--
|
||||||
|
|
||||||
|
GRANT ALL ON TABLE public.bewcloud_contacts TO postgres;
|
||||||
39
deno.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"lock": false,
|
||||||
|
"tasks": {
|
||||||
|
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
|
||||||
|
"cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -",
|
||||||
|
"manifest": "deno task cli manifest $(pwd)",
|
||||||
|
"start": "deno run -A --watch=static/,routes/,lib/,components/,islands/ dev.ts",
|
||||||
|
"build": "deno run -A dev.ts build",
|
||||||
|
"preview": "deno run -A main.ts",
|
||||||
|
"update": "deno run -A -r https://fresh.deno.dev/update .",
|
||||||
|
"test": "deno test -A --check"
|
||||||
|
},
|
||||||
|
"fmt": {
|
||||||
|
"useTabs": false,
|
||||||
|
"lineWidth": 120,
|
||||||
|
"indentWidth": 2,
|
||||||
|
"singleQuote": true,
|
||||||
|
"proseWrap": "preserve"
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"rules": {
|
||||||
|
"tags": [
|
||||||
|
"fresh",
|
||||||
|
"recommended"
|
||||||
|
],
|
||||||
|
"exclude": ["no-explicit-any", "no-empty-interface", "ban-types"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"./_fresh/*",
|
||||||
|
"./node_modules/*"
|
||||||
|
],
|
||||||
|
"importMap": "./import_map.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact"
|
||||||
|
},
|
||||||
|
"nodeModulesDir": true
|
||||||
|
}
|
||||||
8
dev.ts
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env -S deno run -A --watch=static/,routes/,lib/,components/,islands/
|
||||||
|
|
||||||
|
import dev from 'fresh/dev.ts';
|
||||||
|
import config from './fresh.config.ts';
|
||||||
|
|
||||||
|
import 'std/dotenv/load.ts';
|
||||||
|
|
||||||
|
await dev(import.meta.url, './main.ts', config);
|
||||||
20
docker-compose.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
services:
|
||||||
|
postgresql:
|
||||||
|
image: postgres:15
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=postgres
|
||||||
|
- POSTGRES_PASSWORD=fake
|
||||||
|
- POSTGRES_DB=bewcloud
|
||||||
|
restart: on-failure
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
ulimits:
|
||||||
|
memlock:
|
||||||
|
soft: -1
|
||||||
|
hard: -1
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
driver: local
|
||||||
14
fresh.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'fresh/server.ts';
|
||||||
|
import tailwind from 'fresh/plugins/tailwind.ts';
|
||||||
|
|
||||||
|
import { startCrons } from '/crons/index.ts';
|
||||||
|
|
||||||
|
const isBuildMode = Deno.args.includes('build');
|
||||||
|
|
||||||
|
if (!isBuildMode) {
|
||||||
|
startCrons();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwind()],
|
||||||
|
});
|
||||||
93
fresh.gen.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// DO NOT EDIT. This file is generated by Fresh.
|
||||||
|
// This file SHOULD be checked into source version control.
|
||||||
|
// This file is automatically updated during development when running `dev.ts`.
|
||||||
|
|
||||||
|
import * as $_well_known_carddav from './routes/.well-known/carddav.tsx';
|
||||||
|
import * as $_404 from './routes/_404.tsx';
|
||||||
|
import * as $_app from './routes/_app.tsx';
|
||||||
|
import * as $_middleware from './routes/_middleware.tsx';
|
||||||
|
import * as $api_contacts_add from './routes/api/contacts/add.tsx';
|
||||||
|
import * as $api_contacts_delete from './routes/api/contacts/delete.tsx';
|
||||||
|
import * as $api_contacts_get from './routes/api/contacts/get.tsx';
|
||||||
|
import * as $api_contacts_import from './routes/api/contacts/import.tsx';
|
||||||
|
import * as $api_dashboard_save_links from './routes/api/dashboard/save-links.tsx';
|
||||||
|
import * as $api_dashboard_save_notes from './routes/api/dashboard/save-notes.tsx';
|
||||||
|
import * as $api_news_add_feed from './routes/api/news/add-feed.tsx';
|
||||||
|
import * as $api_news_delete_feed from './routes/api/news/delete-feed.tsx';
|
||||||
|
import * as $api_news_import_feeds from './routes/api/news/import-feeds.tsx';
|
||||||
|
import * as $api_news_mark_read from './routes/api/news/mark-read.tsx';
|
||||||
|
import * as $api_news_refresh_articles from './routes/api/news/refresh-articles.tsx';
|
||||||
|
import * as $contacts from './routes/contacts.tsx';
|
||||||
|
import * as $contacts_contactId_ from './routes/contacts/[contactId].tsx';
|
||||||
|
import * as $dashboard from './routes/dashboard.tsx';
|
||||||
|
import * as $dav_addressbooks from './routes/dav/addressbooks.tsx';
|
||||||
|
import * as $dav_addressbooks_contacts from './routes/dav/addressbooks/contacts.tsx';
|
||||||
|
import * as $dav_addressbooks_contacts_contactId_vcf from './routes/dav/addressbooks/contacts/[contactId].vcf.tsx';
|
||||||
|
import * as $dav_files from './routes/dav/files.tsx';
|
||||||
|
import * as $dav_index from './routes/dav/index.tsx';
|
||||||
|
import * as $dav_principals from './routes/dav/principals.tsx';
|
||||||
|
import * as $index from './routes/index.tsx';
|
||||||
|
import * as $login from './routes/login.tsx';
|
||||||
|
import * as $logout from './routes/logout.tsx';
|
||||||
|
import * as $news from './routes/news.tsx';
|
||||||
|
import * as $news_feeds from './routes/news/feeds.tsx';
|
||||||
|
import * as $remote_php_davRoute_ from './routes/remote.php/[davRoute].tsx';
|
||||||
|
import * as $settings from './routes/settings.tsx';
|
||||||
|
import * as $signup from './routes/signup.tsx';
|
||||||
|
import * as $Settings from './islands/Settings.tsx';
|
||||||
|
import * as $contacts_Contacts from './islands/contacts/Contacts.tsx';
|
||||||
|
import * as $contacts_ViewContact from './islands/contacts/ViewContact.tsx';
|
||||||
|
import * as $dashboard_Links from './islands/dashboard/Links.tsx';
|
||||||
|
import * as $dashboard_Notes from './islands/dashboard/Notes.tsx';
|
||||||
|
import * as $news_Articles from './islands/news/Articles.tsx';
|
||||||
|
import * as $news_Feeds from './islands/news/Feeds.tsx';
|
||||||
|
import { type Manifest } from '$fresh/server.ts';
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
routes: {
|
||||||
|
'./routes/.well-known/carddav.tsx': $_well_known_carddav,
|
||||||
|
'./routes/_404.tsx': $_404,
|
||||||
|
'./routes/_app.tsx': $_app,
|
||||||
|
'./routes/_middleware.tsx': $_middleware,
|
||||||
|
'./routes/api/contacts/add.tsx': $api_contacts_add,
|
||||||
|
'./routes/api/contacts/delete.tsx': $api_contacts_delete,
|
||||||
|
'./routes/api/contacts/get.tsx': $api_contacts_get,
|
||||||
|
'./routes/api/contacts/import.tsx': $api_contacts_import,
|
||||||
|
'./routes/api/dashboard/save-links.tsx': $api_dashboard_save_links,
|
||||||
|
'./routes/api/dashboard/save-notes.tsx': $api_dashboard_save_notes,
|
||||||
|
'./routes/api/news/add-feed.tsx': $api_news_add_feed,
|
||||||
|
'./routes/api/news/delete-feed.tsx': $api_news_delete_feed,
|
||||||
|
'./routes/api/news/import-feeds.tsx': $api_news_import_feeds,
|
||||||
|
'./routes/api/news/mark-read.tsx': $api_news_mark_read,
|
||||||
|
'./routes/api/news/refresh-articles.tsx': $api_news_refresh_articles,
|
||||||
|
'./routes/contacts.tsx': $contacts,
|
||||||
|
'./routes/contacts/[contactId].tsx': $contacts_contactId_,
|
||||||
|
'./routes/dashboard.tsx': $dashboard,
|
||||||
|
'./routes/dav/addressbooks.tsx': $dav_addressbooks,
|
||||||
|
'./routes/dav/addressbooks/contacts.tsx': $dav_addressbooks_contacts,
|
||||||
|
'./routes/dav/addressbooks/contacts/[contactId].vcf.tsx': $dav_addressbooks_contacts_contactId_vcf,
|
||||||
|
'./routes/dav/files.tsx': $dav_files,
|
||||||
|
'./routes/dav/index.tsx': $dav_index,
|
||||||
|
'./routes/dav/principals.tsx': $dav_principals,
|
||||||
|
'./routes/index.tsx': $index,
|
||||||
|
'./routes/login.tsx': $login,
|
||||||
|
'./routes/logout.tsx': $logout,
|
||||||
|
'./routes/news.tsx': $news,
|
||||||
|
'./routes/news/feeds.tsx': $news_feeds,
|
||||||
|
'./routes/remote.php/[davRoute].tsx': $remote_php_davRoute_,
|
||||||
|
'./routes/settings.tsx': $settings,
|
||||||
|
'./routes/signup.tsx': $signup,
|
||||||
|
},
|
||||||
|
islands: {
|
||||||
|
'./islands/Settings.tsx': $Settings,
|
||||||
|
'./islands/contacts/Contacts.tsx': $contacts_Contacts,
|
||||||
|
'./islands/contacts/ViewContact.tsx': $contacts_ViewContact,
|
||||||
|
'./islands/dashboard/Links.tsx': $dashboard_Links,
|
||||||
|
'./islands/dashboard/Notes.tsx': $dashboard_Notes,
|
||||||
|
'./islands/news/Articles.tsx': $news_Articles,
|
||||||
|
'./islands/news/Feeds.tsx': $news_Feeds,
|
||||||
|
},
|
||||||
|
baseUrl: import.meta.url,
|
||||||
|
} satisfies Manifest;
|
||||||
|
|
||||||
|
export default manifest;
|
||||||
19
import_map.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"/": "./",
|
||||||
|
"./": "./",
|
||||||
|
"xml/": "https://deno.land/x/xml@2.1.3/",
|
||||||
|
|
||||||
|
"fresh/": "https://deno.land/x/fresh@1.6.5/",
|
||||||
|
"$fresh/": "https://deno.land/x/fresh@1.6.5/",
|
||||||
|
"preact": "https://esm.sh/preact@10.19.2",
|
||||||
|
"preact/": "https://esm.sh/preact@10.19.2/",
|
||||||
|
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.1",
|
||||||
|
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.0",
|
||||||
|
"tailwindcss": "npm:tailwindcss@3.4.1",
|
||||||
|
"tailwindcss/": "npm:/tailwindcss@3.4.1/",
|
||||||
|
"tailwindcss/plugin": "npm:/tailwindcss@3.4.1/plugin.js",
|
||||||
|
"std/": "https://deno.land/std@0.217.0/",
|
||||||
|
"$std/": "https://deno.land/std@0.217.0/"
|
||||||
|
}
|
||||||
|
}
|
||||||
175
islands/Settings.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { convertObjectToFormData, helpEmail } from '/lib/utils.ts';
|
||||||
|
import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx';
|
||||||
|
|
||||||
|
interface SettingsProps {
|
||||||
|
formData: Record<string, any>;
|
||||||
|
error?: {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
notice?: {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Action =
|
||||||
|
| 'change-email'
|
||||||
|
| 'verify-change-email'
|
||||||
|
| 'change-password'
|
||||||
|
| 'change-dav-password'
|
||||||
|
| 'delete-account';
|
||||||
|
|
||||||
|
export const actionWords = new Map<Action, string>([
|
||||||
|
['change-email', 'change email'],
|
||||||
|
['verify-change-email', 'change email'],
|
||||||
|
['change-password', 'change password'],
|
||||||
|
['change-dav-password', 'change DAV password'],
|
||||||
|
['delete-account', 'delete account'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function formFields(action: Action, formData: FormData) {
|
||||||
|
const fields: FormField[] = [
|
||||||
|
{
|
||||||
|
name: 'action',
|
||||||
|
label: '',
|
||||||
|
type: 'hidden',
|
||||||
|
value: action,
|
||||||
|
overrideValue: action,
|
||||||
|
required: true,
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (action === 'change-email') {
|
||||||
|
fields.push({
|
||||||
|
name: 'email',
|
||||||
|
label: 'Email',
|
||||||
|
type: 'email',
|
||||||
|
placeholder: 'jane.doe@example.com',
|
||||||
|
value: getFormDataField(formData, 'email'),
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
} else if (action === 'verify-change-email') {
|
||||||
|
fields.push({
|
||||||
|
name: 'email',
|
||||||
|
label: 'Email',
|
||||||
|
type: 'email',
|
||||||
|
placeholder: 'jane.doe@example.com',
|
||||||
|
value: getFormDataField(formData, 'email'),
|
||||||
|
required: true,
|
||||||
|
}, {
|
||||||
|
name: 'verification-code',
|
||||||
|
label: 'Verification Code',
|
||||||
|
description: `The verification code to validate your new email.`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '000000',
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
} else if (action === 'change-password') {
|
||||||
|
fields.push({
|
||||||
|
name: 'current-password',
|
||||||
|
label: 'Current Password',
|
||||||
|
type: 'password',
|
||||||
|
placeholder: 'super-SECRET-passphrase',
|
||||||
|
required: true,
|
||||||
|
}, {
|
||||||
|
name: 'new-password',
|
||||||
|
label: 'New Password',
|
||||||
|
type: 'password',
|
||||||
|
placeholder: 'super-SECRET-passphrase',
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
} else if (action === 'change-dav-password') {
|
||||||
|
fields.push({
|
||||||
|
name: 'new-dav-password',
|
||||||
|
label: 'New DAV Password',
|
||||||
|
type: 'password',
|
||||||
|
placeholder: 'super-SECRET-passphrase',
|
||||||
|
required: true,
|
||||||
|
description: 'Alternative password used for DAV access and/or HTTP Basic Auth.',
|
||||||
|
});
|
||||||
|
} else if (action === 'delete-account') {
|
||||||
|
fields.push({
|
||||||
|
name: 'current-password',
|
||||||
|
label: 'Password',
|
||||||
|
type: 'password',
|
||||||
|
placeholder: 'super-SECRET-passphrase',
|
||||||
|
description: 'You need to input your password in order to delete your account.',
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Settings({ formData: formDataObject, error, notice }: SettingsProps) {
|
||||||
|
const formData = convertObjectToFormData(formDataObject);
|
||||||
|
|
||||||
|
const action = getFormDataField(formData, 'action') as Action;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section class='mx-auto max-w-7xl my-8'>
|
||||||
|
{error
|
||||||
|
? (
|
||||||
|
<section class='notification-error'>
|
||||||
|
<h3>{error.title}</h3>
|
||||||
|
<p>{error.message}</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{notice
|
||||||
|
? (
|
||||||
|
<section class='notification-success'>
|
||||||
|
<h3>{notice.title}</h3>
|
||||||
|
<p>{notice.message}</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
|
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your email</h2>
|
||||||
|
|
||||||
|
<form method='POST' class='mb-12'>
|
||||||
|
{formFields(
|
||||||
|
action === 'change-email' && notice?.message.includes('verify') ? 'verify-change-email' : 'change-email',
|
||||||
|
formData,
|
||||||
|
).map((field) => generateFieldHtml(field, formData))}
|
||||||
|
<section class='flex justify-end mt-8 mb-4'>
|
||||||
|
<button class='button-secondary' type='submit'>Change email</button>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your password</h2>
|
||||||
|
|
||||||
|
<form method='POST' class='mb-12'>
|
||||||
|
{formFields('change-password', formData).map((field) => generateFieldHtml(field, formData))}
|
||||||
|
<section class='flex justify-end mt-8 mb-4'>
|
||||||
|
<button class='button-secondary' type='submit'>Change password</button>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your DAV password</h2>
|
||||||
|
|
||||||
|
<form method='POST' class='mb-12'>
|
||||||
|
{formFields('change-dav-password', formData).map((field) => generateFieldHtml(field, formData))}
|
||||||
|
<section class='flex justify-end mt-8 mb-4'>
|
||||||
|
<button class='button-secondary' type='submit'>Change DAV password</button>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Delete your account</h2>
|
||||||
|
<p class='text-left mt-2 mb-6 px-4 max-w-screen-md mx-auto lg:min-w-96'>
|
||||||
|
Deleting your account is instant and deletes all your data. If you need help, please{' '}
|
||||||
|
<a href={`mailto:${helpEmail}`}>reach out</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method='POST' class='mb-12'>
|
||||||
|
{formFields('delete-account', formData).map((field) => generateFieldHtml(field, formData))}
|
||||||
|
<section class='flex justify-end mt-8 mb-4'>
|
||||||
|
<button class='button-danger' type='submit'>Delete account</button>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
407
islands/contacts/Contacts.tsx
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
import { useSignal } from '@preact/signals';
|
||||||
|
|
||||||
|
import { Contact } from '/lib/types.ts';
|
||||||
|
import { baseUrl, CONTACTS_PER_PAGE_COUNT, formatContactToVCard, parseVCardFromTextContents } from '/lib/utils.ts';
|
||||||
|
import { RequestBody as GetRequestBody, ResponseBody as GetResponseBody } from '/routes/api/contacts/get.tsx';
|
||||||
|
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/contacts/add.tsx';
|
||||||
|
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/contacts/delete.tsx';
|
||||||
|
import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/routes/api/contacts/import.tsx';
|
||||||
|
|
||||||
|
interface ContactsProps {
|
||||||
|
initialContacts: Pick<Contact, 'id' | 'first_name' | 'last_name'>[];
|
||||||
|
page: number;
|
||||||
|
contactsCount: number;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Contacts({ initialContacts, page, contactsCount, search }: ContactsProps) {
|
||||||
|
const isAdding = useSignal<boolean>(false);
|
||||||
|
const isDeleting = useSignal<boolean>(false);
|
||||||
|
const isExporting = useSignal<boolean>(false);
|
||||||
|
const isImporting = useSignal<boolean>(false);
|
||||||
|
const contacts = useSignal<Pick<Contact, 'id' | 'first_name' | 'last_name'>[]>(initialContacts);
|
||||||
|
const isOptionsDropdownOpen = useSignal<boolean>(false);
|
||||||
|
|
||||||
|
async function onClickAddContact() {
|
||||||
|
if (isAdding.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstName = (prompt(`What's the **first name** for the new contact?`) || '').trim();
|
||||||
|
|
||||||
|
if (!firstName) {
|
||||||
|
alert('A first name is required for a new contact!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastName = (prompt(`What's the **last name** for the new contact?`) || '').trim();
|
||||||
|
|
||||||
|
isAdding.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: AddRequestBody = { firstName, lastName, page };
|
||||||
|
const response = await fetch(`/api/contacts/add`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as AddResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to add contact!');
|
||||||
|
}
|
||||||
|
|
||||||
|
contacts.value = [...result.contacts];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdding.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOptionsDropdown() {
|
||||||
|
isOptionsDropdownOpen.value = !isOptionsDropdownOpen.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClickDeleteContact(contactId: string) {
|
||||||
|
if (confirm('Are you sure you want to delete this contact?')) {
|
||||||
|
if (isDeleting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeleting.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: DeleteRequestBody = { contactId, page };
|
||||||
|
const response = await fetch(`/api/contacts/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as DeleteResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to delete contact!');
|
||||||
|
}
|
||||||
|
|
||||||
|
contacts.value = [...result.contacts];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeleting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickImportVCard() {
|
||||||
|
isOptionsDropdownOpen.value = false;
|
||||||
|
|
||||||
|
if (isImporting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.click();
|
||||||
|
|
||||||
|
fileInput.onchange = (event) => {
|
||||||
|
const files = (event.target as HTMLInputElement)?.files!;
|
||||||
|
const file = files[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (fileRead) => {
|
||||||
|
const importFileContents = fileRead.target?.result;
|
||||||
|
|
||||||
|
if (!importFileContents || isImporting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isImporting.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const partialContacts = parseVCardFromTextContents(importFileContents!.toString());
|
||||||
|
|
||||||
|
const requestBody: ImportRequestBody = { partialContacts, page };
|
||||||
|
const response = await fetch(`/api/contacts/import`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as ImportResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to import contact!');
|
||||||
|
}
|
||||||
|
|
||||||
|
contacts.value = [...result.contacts];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isImporting.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file, 'UTF-8');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClickExportVCard() {
|
||||||
|
isOptionsDropdownOpen.value = false;
|
||||||
|
|
||||||
|
if (isExporting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isExporting.value = true;
|
||||||
|
|
||||||
|
const fileName = ['contacts-', new Date().toISOString().substring(0, 19).replace(/:/g, '-'), '.vcf']
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: GetRequestBody = {};
|
||||||
|
const response = await fetch(`/api/contacts/get`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as GetResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to get contact!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportContents = formatContactToVCard([...result.contacts]);
|
||||||
|
|
||||||
|
// Add content-type
|
||||||
|
const vCardContent = ['data:text/vcard; charset=utf-8,', encodeURIComponent(exportContents)].join('');
|
||||||
|
|
||||||
|
// Download the file
|
||||||
|
const data = vCardContent;
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.setAttribute('href', data);
|
||||||
|
link.setAttribute('download', fileName);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isExporting.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagesCount = Math.ceil(contactsCount / CONTACTS_PER_PAGE_COUNT);
|
||||||
|
const pages = Array.from({ length: pagesCount }).map((_value, index) => index + 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section class='flex flex-row items-center justify-between mb-4'>
|
||||||
|
<section class='relative inline-block text-left mr-2'>
|
||||||
|
<form method='GET' action='/contacts' class='m-0 p-0'>
|
||||||
|
<input
|
||||||
|
class='input-field w-60'
|
||||||
|
type='search'
|
||||||
|
name='search'
|
||||||
|
value={search}
|
||||||
|
placeholder='Search contacts...'
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class='flex items-center'>
|
||||||
|
<section class='relative inline-block text-left ml-2'>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
class='inline-flex w-full justify-center gap-x-1.5 rounded-md bg-slate-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-600'
|
||||||
|
id='filter-button'
|
||||||
|
aria-expanded='true'
|
||||||
|
aria-haspopup='true'
|
||||||
|
onClick={() => toggleOptionsDropdown()}
|
||||||
|
>
|
||||||
|
VCF
|
||||||
|
<svg class='-mr-1 h-5 w-5 text-slate-400' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||||
|
<path
|
||||||
|
fill-rule='evenodd'
|
||||||
|
d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z'
|
||||||
|
clip-rule='evenodd'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||||
|
!isOptionsDropdownOpen.value ? 'hidden' : ''
|
||||||
|
}`}
|
||||||
|
role='menu'
|
||||||
|
aria-orientation='vertical'
|
||||||
|
aria-labelledby='filter-button'
|
||||||
|
tabindex={-1}
|
||||||
|
>
|
||||||
|
<div class='py-1'>
|
||||||
|
<button
|
||||||
|
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||||
|
onClick={() => onClickImportVCard()}
|
||||||
|
>
|
||||||
|
Import vCard
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||||
|
onClick={() => onClickExportVCard()}
|
||||||
|
>
|
||||||
|
Export vCard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<button
|
||||||
|
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2'
|
||||||
|
type='button'
|
||||||
|
title='Add new contact'
|
||||||
|
onClick={() => onClickAddContact()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/images/add.svg'
|
||||||
|
alt='Add new contact'
|
||||||
|
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class='mx-auto max-w-7xl my-8'>
|
||||||
|
<table class='w-full border-collapse bg-slate-700 text-left text-sm text-slate-500 shadow-sm'>
|
||||||
|
<thead class='bg-gray-900'>
|
||||||
|
<tr>
|
||||||
|
<th scope='col' class='px-6 py-4 font-medium text-white'>First Name</th>
|
||||||
|
<th scope='col' class='px-6 py-4 font-medium text-white'>Last Name</th>
|
||||||
|
<th scope='col' class='px-6 py-4 font-medium text-white w-20'></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class='divide-y divide-slate-600 border-t border-slate-600'>
|
||||||
|
{contacts.value.map((contact) => (
|
||||||
|
<tr class='hover:bg-slate-600 group'>
|
||||||
|
<td class='flex gap-3 px-6 py-4 font-normal text-white'>
|
||||||
|
<a href={`/contacts/${contact.id}`}>{contact.first_name}</a>
|
||||||
|
</td>
|
||||||
|
<td class='px-6 py-4 text-slate-200'>
|
||||||
|
{contact.last_name}
|
||||||
|
</td>
|
||||||
|
<td class='px-6 py-4'>
|
||||||
|
<span
|
||||||
|
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100'
|
||||||
|
onClick={() => onClickDeleteContact(contact.id)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/images/delete.svg'
|
||||||
|
class='red drop-shadow-md'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
alt='Delete contact'
|
||||||
|
title='Delete contact'
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{contacts.value.length === 0
|
||||||
|
? (
|
||||||
|
<tr>
|
||||||
|
<td class='flex gap-3 px-6 py-4 font-normal' colspan={3}>
|
||||||
|
<div class='text-md'>
|
||||||
|
<div class='font-medium text-slate-400'>No contacts to show</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
|
||||||
|
>
|
||||||
|
{isDeleting.value
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{isExporting.value
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{isImporting.value
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{!isDeleting.value && !isExporting.value && !isImporting.value ? <> </> : null}
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{pagesCount > 0
|
||||||
|
? (
|
||||||
|
<section class='flex justify-end'>
|
||||||
|
<nav class='isolate inline-flex -space-x-px rounded-md shadow-sm' aria-label='Pagination'>
|
||||||
|
<a
|
||||||
|
href={page > 1 ? `/contacts?search=${search}&page=${page - 1}` : 'javascript:void(0)'}
|
||||||
|
class='relative inline-flex items-center rounded-l-md px-2 py-2 text-white hover:bg-slate-600 bg-slate-700'
|
||||||
|
title='Previous'
|
||||||
|
>
|
||||||
|
<svg class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||||
|
<path
|
||||||
|
fill-rule='evenodd'
|
||||||
|
d='M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z'
|
||||||
|
clip-rule='evenodd'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{pages.map((pageNumber) => {
|
||||||
|
const isCurrent = pageNumber === page;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`/contacts?search=${search}&page=${pageNumber}`}
|
||||||
|
aria-current='page'
|
||||||
|
class={`relative inline-flex items-center ${
|
||||||
|
isCurrent ? 'bg-[#51A4FB] hover:bg-sky-400' : 'bg-slate-700 hover:bg-slate-600'
|
||||||
|
} px-4 py-2 text-sm font-semibold text-white`}
|
||||||
|
>
|
||||||
|
{pageNumber}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<a
|
||||||
|
href={page < pagesCount ? `/contacts?search=${search}&page=${page + 1}` : 'javascript:void(0)'}
|
||||||
|
class='relative inline-flex items-center rounded-r-md px-2 py-2 text-white hover:bg-slate-600 bg-slate-700'
|
||||||
|
title='Next'
|
||||||
|
>
|
||||||
|
<svg class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||||
|
<path
|
||||||
|
fill-rule='evenodd'
|
||||||
|
d='M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z'
|
||||||
|
clip-rule='evenodd'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
|
<section class='flex flex-row items-center justify-start my-12'>
|
||||||
|
<span class='font-semibold'>CardDAV URLs:</span>{' '}
|
||||||
|
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/dav/principals/</code>{' '}
|
||||||
|
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/dav/addressbooks/</code>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
471
islands/contacts/ViewContact.tsx
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
import { useSignal } from '@preact/signals';
|
||||||
|
|
||||||
|
import { Contact } from '/lib/types.ts';
|
||||||
|
import { convertObjectToFormData } from '/lib/utils.ts';
|
||||||
|
import { FormField, generateFieldHtml } from '/lib/form-utils.tsx';
|
||||||
|
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/contacts/delete.tsx';
|
||||||
|
|
||||||
|
interface ViewContactProps {
|
||||||
|
initialContact: Contact;
|
||||||
|
formData: Record<string, any>;
|
||||||
|
error?: string;
|
||||||
|
notice?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formFields(contact: Contact) {
|
||||||
|
const fields: FormField[] = [
|
||||||
|
{
|
||||||
|
name: 'name_title',
|
||||||
|
label: 'Honorary title/prefix',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Dr.',
|
||||||
|
value: contact.extra.name_title,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'first_name',
|
||||||
|
label: 'First name',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'John',
|
||||||
|
value: contact.first_name,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'middle_names',
|
||||||
|
label: 'Middle name(s)',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '',
|
||||||
|
value: contact.extra.middle_names?.map((name) => (name || '').trim()).filter(Boolean).join(' '),
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'last_name',
|
||||||
|
label: 'Last name',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Doe',
|
||||||
|
value: contact.last_name,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'birthday',
|
||||||
|
label: 'Birthday',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'YYYYMMDD',
|
||||||
|
value: contact.extra.birthday,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'nickname',
|
||||||
|
label: 'Nickname',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Johnny',
|
||||||
|
value: contact.extra.nickname,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'organization',
|
||||||
|
label: 'Company/Organization',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Acme Corporation',
|
||||||
|
value: contact.extra.organization,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'role',
|
||||||
|
label: 'Job/Role',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '(Super) Genius',
|
||||||
|
value: contact.extra.role,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'photo_url',
|
||||||
|
label: 'Photo URL',
|
||||||
|
type: 'url',
|
||||||
|
placeholder: 'https://example.com/image.jpg',
|
||||||
|
value: contact.extra.photo_url,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Phones
|
||||||
|
const phones = contact.extra.fields?.filter((field) => field.type === 'phone') || [];
|
||||||
|
for (const [index, phone] of phones.entries()) {
|
||||||
|
fields.push({
|
||||||
|
name: 'phone_numbers',
|
||||||
|
label: `Phone number #${index + 1}`,
|
||||||
|
type: 'tel',
|
||||||
|
placeholder: '+44 0000 111 2222',
|
||||||
|
value: phone.value,
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
name: 'phone_labels',
|
||||||
|
label: `Phone label #${index + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Home, Work, etc.',
|
||||||
|
value: phone.name,
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
name: 'phone_numbers',
|
||||||
|
label: `Phone number #${phones.length + 1}`,
|
||||||
|
type: 'tel',
|
||||||
|
placeholder: '+44 0000 111 2222',
|
||||||
|
value: '',
|
||||||
|
required: false,
|
||||||
|
}, {
|
||||||
|
name: 'phone_labels',
|
||||||
|
label: `Phone label #${phones.length + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Home, Work, etc.',
|
||||||
|
value: '',
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emails
|
||||||
|
const emails = contact.extra.fields?.filter((field) => field.type === 'email') || [];
|
||||||
|
for (const [index, email] of emails.entries()) {
|
||||||
|
fields.push({
|
||||||
|
name: 'email_addresses',
|
||||||
|
label: `Email #${index + 1}`,
|
||||||
|
type: 'email',
|
||||||
|
placeholder: 'user@example.com',
|
||||||
|
value: email.value,
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
name: 'email_labels',
|
||||||
|
label: `Email label #${index + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Home, Work, etc.',
|
||||||
|
value: email.name,
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
name: 'email_addresses',
|
||||||
|
label: `Email #${emails.length + 1}`,
|
||||||
|
type: 'email',
|
||||||
|
placeholder: 'user@example.com',
|
||||||
|
value: '',
|
||||||
|
required: false,
|
||||||
|
}, {
|
||||||
|
name: 'email_labels',
|
||||||
|
label: `Email label #${emails.length + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Home, Work, etc.',
|
||||||
|
value: '',
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// URLs
|
||||||
|
const urls = contact.extra.fields?.filter((field) => field.type === 'url') || [];
|
||||||
|
for (const [index, url] of urls.entries()) {
|
||||||
|
fields.push({
|
||||||
|
name: 'url_addresses',
|
||||||
|
label: `URL #${index + 1}`,
|
||||||
|
type: 'url',
|
||||||
|
placeholder: 'https://example.com',
|
||||||
|
value: url.value,
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
name: 'url_labels',
|
||||||
|
label: `URL label #${index + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Home, Work, etc.',
|
||||||
|
value: url.name,
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
name: 'url_addresses',
|
||||||
|
label: `URL #${urls.length + 1}`,
|
||||||
|
type: 'url',
|
||||||
|
placeholder: 'https://example.com',
|
||||||
|
value: '',
|
||||||
|
required: false,
|
||||||
|
}, {
|
||||||
|
name: 'url_labels',
|
||||||
|
label: `URL label #${urls.length + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Home, Work, etc.',
|
||||||
|
value: '',
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Others
|
||||||
|
const others = contact.extra.fields?.filter((field) => field.type === 'other') || [];
|
||||||
|
for (const [index, other] of others.entries()) {
|
||||||
|
fields.push({
|
||||||
|
name: 'other_values',
|
||||||
|
label: `Other contact #${index + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '@acme',
|
||||||
|
value: other.value,
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
name: 'other_labels',
|
||||||
|
label: `Other label #${index + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Home, Work, etc.',
|
||||||
|
value: other.name,
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
name: 'other_values',
|
||||||
|
label: `Other contact #${others.length + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '@acme',
|
||||||
|
value: '',
|
||||||
|
required: false,
|
||||||
|
}, {
|
||||||
|
name: 'other_labels',
|
||||||
|
label: `Other label #${others.length + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Home, Work, etc.',
|
||||||
|
value: '',
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Addresses
|
||||||
|
const addresses = contact.extra.addresses || [];
|
||||||
|
for (const [index, address] of addresses.entries()) {
|
||||||
|
fields.push({
|
||||||
|
name: 'address_line_1s',
|
||||||
|
label: `Address line 1 #${index + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '992 Tyburn Rd',
|
||||||
|
value: address.line_1,
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
name: 'address_line_2s',
|
||||||
|
label: `Address line 2 #${index + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Apt 2',
|
||||||
|
value: address.line_2,
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
name: 'address_cities',
|
||||||
|
label: `Address city #${index + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Birmingham',
|
||||||
|
value: address.city,
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
name: 'address_postal_codes',
|
||||||
|
label: `Address postal code #${index + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'B24 0TL',
|
||||||
|
value: address.postal_code,
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
name: 'address_states',
|
||||||
|
label: `Address state #${index + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'West Midlands',
|
||||||
|
value: address.state,
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
name: 'address_countries',
|
||||||
|
label: `Address country #${index + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'United Kingdom',
|
||||||
|
value: address.country,
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
name: 'address_labels',
|
||||||
|
label: `Address label #${index + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Home, Work, etc.',
|
||||||
|
value: address.label,
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
name: 'address_line_1s',
|
||||||
|
label: `Address line 1 #${addresses.length + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '992 Tyburn Rd',
|
||||||
|
value: '',
|
||||||
|
required: false,
|
||||||
|
}, {
|
||||||
|
name: 'address_line_2s',
|
||||||
|
label: `Address line 2 #${addresses.length + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Apt 2',
|
||||||
|
value: '',
|
||||||
|
required: false,
|
||||||
|
}, {
|
||||||
|
name: 'address_cities',
|
||||||
|
label: `Address city #${addresses.length + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Birmingham',
|
||||||
|
value: '',
|
||||||
|
required: false,
|
||||||
|
}, {
|
||||||
|
name: 'address_postal_codes',
|
||||||
|
label: `Address postal code #${addresses.length + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'B24 0TL',
|
||||||
|
value: '',
|
||||||
|
required: false,
|
||||||
|
}, {
|
||||||
|
name: 'address_states',
|
||||||
|
label: `Address state #${addresses.length + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'West Midlands',
|
||||||
|
value: '',
|
||||||
|
required: false,
|
||||||
|
}, {
|
||||||
|
name: 'address_countries',
|
||||||
|
label: `Address country #${addresses.length + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'United Kingdom',
|
||||||
|
value: '',
|
||||||
|
required: false,
|
||||||
|
}, {
|
||||||
|
name: 'address_labels',
|
||||||
|
label: `Address label #${addresses.length + 1}`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Home, Work, etc.',
|
||||||
|
value: '',
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
name: 'notes',
|
||||||
|
label: 'Notes',
|
||||||
|
type: 'textarea',
|
||||||
|
placeholder: 'Some notes...',
|
||||||
|
value: contact.extra.notes,
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ViewContact({ initialContact, formData: formDataObject, error, notice }: ViewContactProps) {
|
||||||
|
const isDeleting = useSignal<boolean>(false);
|
||||||
|
const contact = useSignal<Contact>(initialContact);
|
||||||
|
|
||||||
|
const formData = convertObjectToFormData(formDataObject);
|
||||||
|
|
||||||
|
async function onClickDeleteContact() {
|
||||||
|
if (confirm('Are you sure you want to delete this contact?')) {
|
||||||
|
if (isDeleting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeleting.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: DeleteRequestBody = { contactId: contact.value.id, page: 1 };
|
||||||
|
const response = await fetch(`/api/contacts/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as DeleteResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to delete contact!');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = '/contacts';
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeleting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section class='flex flex-row items-center justify-between mb-4'>
|
||||||
|
<a href='/contacts' class='mr-2'>View contacts</a>
|
||||||
|
<section class='flex items-center'>
|
||||||
|
<button
|
||||||
|
class='inline-block justify-center gap-x-1.5 rounded-md bg-red-800 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-600 ml-2'
|
||||||
|
type='button'
|
||||||
|
title='Delete contact'
|
||||||
|
onClick={() => onClickDeleteContact()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/images/delete.svg'
|
||||||
|
alt='Delete contact'
|
||||||
|
class={`white ${isDeleting.value ? 'animate-spin' : ''}`}
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class='mx-auto max-w-7xl my-8'>
|
||||||
|
{error
|
||||||
|
? (
|
||||||
|
<section class='notification-error'>
|
||||||
|
<h3>Failed to update!</h3>
|
||||||
|
<p>{error}</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{notice
|
||||||
|
? (
|
||||||
|
<section class='notification-success'>
|
||||||
|
<h3>Success!</h3>
|
||||||
|
<p>{notice}</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
|
<form method='POST' class='mb-12'>
|
||||||
|
{formFields(contact.peek()).map((field) => generateFieldHtml(field, formData))}
|
||||||
|
|
||||||
|
<section class='flex justify-end mt-8 mb-4'>
|
||||||
|
<button class='button' type='submit'>Update contact</button>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
|
||||||
|
>
|
||||||
|
{isDeleting.value
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{!isDeleting.value ? <> </> : null}
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
199
islands/dashboard/Links.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { useSignal } from '@preact/signals';
|
||||||
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
|
import { DashboardLink } from '/lib/types.ts';
|
||||||
|
import { validateUrl } from '/lib/utils.ts';
|
||||||
|
import { RequestBody, ResponseBody } from '/routes/api/dashboard/save-links.tsx';
|
||||||
|
|
||||||
|
interface LinksProps {
|
||||||
|
initialLinks: DashboardLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Links({ initialLinks }: LinksProps) {
|
||||||
|
const hasSavedTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
||||||
|
const isSaving = useSignal<boolean>(false);
|
||||||
|
const hasSaved = useSignal<boolean>(false);
|
||||||
|
const links = useSignal<DashboardLink[]>(initialLinks);
|
||||||
|
|
||||||
|
async function saveLinks(newLinks: DashboardLink[]) {
|
||||||
|
if (isSaving.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSaved.value = false;
|
||||||
|
isSaving.value = true;
|
||||||
|
|
||||||
|
const oldLinks = [...links.value];
|
||||||
|
|
||||||
|
links.value = newLinks;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: RequestBody = { links: newLinks };
|
||||||
|
const response = await fetch(`/api/dashboard/save-links`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as ResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to save notes!');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
links.value = [...oldLinks];
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaving.value = false;
|
||||||
|
hasSaved.value = true;
|
||||||
|
|
||||||
|
if (hasSavedTimeout.value) {
|
||||||
|
clearTimeout(hasSavedTimeout.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSavedTimeout.value = setTimeout(() => {
|
||||||
|
hasSaved.value = false;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (hasSavedTimeout.value) {
|
||||||
|
clearTimeout(hasSavedTimeout.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function onClickAddLink() {
|
||||||
|
const name = (prompt(`What's the **name** for the new link?`) || '').trim();
|
||||||
|
const url = (prompt(`What's the **URL** for the new link?`) || '').trim();
|
||||||
|
|
||||||
|
if (!name || !url) {
|
||||||
|
alert('A name and URL are required for a new link!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateUrl(url)) {
|
||||||
|
alert('Invalid URL!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newLinks = [...links.value, { name, url }];
|
||||||
|
|
||||||
|
saveLinks(newLinks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickDeleteLink(indexToDelete: number) {
|
||||||
|
if (confirm('Are you sure you want to delete this link?')) {
|
||||||
|
const newLinks = [...links.value];
|
||||||
|
|
||||||
|
newLinks.splice(indexToDelete, 1);
|
||||||
|
|
||||||
|
saveLinks(newLinks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickMoveLeftLink(indexToMoveLeft: number) {
|
||||||
|
if (indexToMoveLeft <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirm('Are you sure you want to move this link left?')) {
|
||||||
|
const newLinks = [...links.value];
|
||||||
|
|
||||||
|
const linkToMove = newLinks.splice(indexToMoveLeft, 1);
|
||||||
|
|
||||||
|
newLinks.splice(indexToMoveLeft - 1, 0, linkToMove[0]);
|
||||||
|
|
||||||
|
saveLinks(newLinks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section class='flex flex-row items-center justify-end mb-4'>
|
||||||
|
<section class='flex items-center'>
|
||||||
|
<button
|
||||||
|
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2'
|
||||||
|
type='button'
|
||||||
|
title='Add new link'
|
||||||
|
onClick={() => onClickAddLink()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/images/add.svg'
|
||||||
|
alt='Add new link'
|
||||||
|
class={`white`}
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class='mx-auto max-w-7xl px-6 lg:px-8 my-8'>
|
||||||
|
<section class='group grid grid-cols-1 gap-x-8 gap-y-16 text-center lg:grid-cols-3'>
|
||||||
|
{links.value.map((link, index) => (
|
||||||
|
<div class='group mx-auto flex max-w-xs flex-col gap-y-4 rounded shadow-md bg-slate-700 relative hover:bg-slate-600'>
|
||||||
|
<article class='order-first text-3xl font-semibold tracking-tight sm:text-2xl'>
|
||||||
|
<a href={link.url} class='text-white py-4 px-8 block' target='_blank' rel='noreferrer noopener'>
|
||||||
|
{link.name}
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
<span
|
||||||
|
class='invisible group-hover:visible absolute top-0 right-0 -mr-3 -mt-3 cursor-pointer'
|
||||||
|
onClick={() => onClickDeleteLink(index)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/images/delete.svg'
|
||||||
|
class='red drop-shadow-md'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
alt='Delete link'
|
||||||
|
title='Delete link'
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{index > 0
|
||||||
|
? (
|
||||||
|
<span
|
||||||
|
class='invisible group-hover:visible absolute top-0 left-0 -ml-3 -mt-3 cursor-pointer'
|
||||||
|
onClick={() => onClickMoveLeftLink(index)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/images/left-circle.svg'
|
||||||
|
class='gray'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
alt='Move link left'
|
||||||
|
title='Move link left'
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class={`flex justify-end items-center text-sm mt-1 mx-2 ${
|
||||||
|
hasSaved.value ? 'text-green-600' : 'text-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSaving.value
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{hasSaved.value
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<img src='/images/check.svg' class='green mr-2' width={18} height={18} />Saved!
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{!isSaving.value && !hasSaved.value ? <> </> : null}
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
islands/dashboard/Notes.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useSignal, useSignalEffect } from '@preact/signals';
|
||||||
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
|
import { RequestBody, ResponseBody } from '/routes/api/dashboard/save-notes.tsx';
|
||||||
|
|
||||||
|
interface NotesProps {
|
||||||
|
initialNotes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Notes({ initialNotes }: NotesProps) {
|
||||||
|
const saveTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
||||||
|
const hasSavedTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
||||||
|
const isSaving = useSignal<boolean>(false);
|
||||||
|
const hasSaved = useSignal<boolean>(false);
|
||||||
|
|
||||||
|
function saveNotes(newNotes: string) {
|
||||||
|
if (saveTimeout.value) {
|
||||||
|
clearTimeout(saveTimeout.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTimeout.value = setTimeout(async () => {
|
||||||
|
hasSaved.value = false;
|
||||||
|
isSaving.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: RequestBody = { notes: newNotes };
|
||||||
|
const response = await fetch(`/api/dashboard/save-notes`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as ResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to save notes!');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaving.value = false;
|
||||||
|
hasSaved.value = true;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
useSignalEffect(() => {
|
||||||
|
if (hasSaved.value && !hasSavedTimeout.value) {
|
||||||
|
hasSavedTimeout.value = setTimeout(() => {
|
||||||
|
hasSaved.value = false;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveTimeout.value) {
|
||||||
|
clearTimeout(saveTimeout.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSavedTimeout.value) {
|
||||||
|
clearTimeout(hasSavedTimeout.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section class='flex flex-col'>
|
||||||
|
<textarea
|
||||||
|
class='my-2 input-field text-sm font-mono'
|
||||||
|
onInput={(event) => saveNotes(event.currentTarget.value)}
|
||||||
|
rows={10}
|
||||||
|
>
|
||||||
|
{initialNotes}
|
||||||
|
</textarea>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class={`flex justify-end items-center text-sm mt-1 mx-2 ${
|
||||||
|
hasSaved.value ? 'text-green-600' : 'text-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSaving.value
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{hasSaved.value
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<img src='/images/check.svg' class='green mr-2' width={18} height={18} />Saved!
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{!isSaving.value && !hasSaved.value ? <> </> : null}
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
265
islands/news/Articles.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { useSignal } from '@preact/signals';
|
||||||
|
|
||||||
|
import { NewsFeedArticle } from '/lib/types.ts';
|
||||||
|
import {
|
||||||
|
RequestBody as RefreshRequestBody,
|
||||||
|
ResponseBody as RefreshResponseBody,
|
||||||
|
} from '/routes/api/news/refresh-articles.tsx';
|
||||||
|
import { RequestBody as ReadRequestBody, ResponseBody as ReadResponseBody } from '/routes/api/news/mark-read.tsx';
|
||||||
|
|
||||||
|
interface ArticlesProps {
|
||||||
|
initialArticles: NewsFeedArticle[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Filter {
|
||||||
|
status: 'all' | 'unread';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Articles({ initialArticles }: ArticlesProps) {
|
||||||
|
const isRefreshing = useSignal<boolean>(false);
|
||||||
|
const articles = useSignal<NewsFeedArticle[]>(initialArticles);
|
||||||
|
const filter = useSignal<Filter>({ status: 'unread' });
|
||||||
|
const sessionReadArticleIds = useSignal<Set<string>>(new Set());
|
||||||
|
const isFilterDropdownOpen = useSignal<boolean>(false);
|
||||||
|
|
||||||
|
const dateFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium' });
|
||||||
|
|
||||||
|
async function refreshArticles() {
|
||||||
|
if (isRefreshing.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRefreshing.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: RefreshRequestBody = {};
|
||||||
|
const response = await fetch(`/api/news/refresh-articles`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as RefreshResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to refresh articles!');
|
||||||
|
}
|
||||||
|
|
||||||
|
articles.value = [...result.newArticles];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isRefreshing.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredArticles = articles.value.filter((article) => {
|
||||||
|
if (filter.value.status === 'unread') {
|
||||||
|
if (article.is_read && !sessionReadArticleIds.value.has(article.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onClickView(articleId: string) {
|
||||||
|
const newArticles = [...articles.value];
|
||||||
|
|
||||||
|
const matchingArticle = newArticles.find((article) => article.id === articleId);
|
||||||
|
if (matchingArticle) {
|
||||||
|
if (matchingArticle.is_read) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
matchingArticle.is_read = true;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionReadArticleIds.value.add(articleId);
|
||||||
|
|
||||||
|
articles.value = [...newArticles];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: ReadRequestBody = { articleId };
|
||||||
|
const response = await fetch(`/api/news/mark-read`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as ReadResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to mark article as read!');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClickMarkAllRead() {
|
||||||
|
const newArticles = [...articles.value].map((article) => {
|
||||||
|
article.is_read = true;
|
||||||
|
|
||||||
|
sessionReadArticleIds.value.add(article.id);
|
||||||
|
|
||||||
|
return article;
|
||||||
|
});
|
||||||
|
|
||||||
|
articles.value = [...newArticles];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: ReadRequestBody = { articleId: 'all' };
|
||||||
|
const response = await fetch(`/api/news/mark-read`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as ReadResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to mark all articles as read!');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFilterDropdown() {
|
||||||
|
isFilterDropdownOpen.value = !isFilterDropdownOpen.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNewFilter(newFilter: Partial<Filter>) {
|
||||||
|
filter.value = { ...filter.value, ...newFilter };
|
||||||
|
|
||||||
|
isFilterDropdownOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section class='flex flex-row items-center justify-between mb-4'>
|
||||||
|
<a href='/news/feeds' class='mr-2'>Manage feeds</a>
|
||||||
|
<section class='flex items-center'>
|
||||||
|
<section class='relative inline-block text-left ml-2'>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
class='inline-flex w-full justify-center gap-x-1.5 rounded-md bg-slate-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-600'
|
||||||
|
id='filter-button'
|
||||||
|
aria-expanded='true'
|
||||||
|
aria-haspopup='true'
|
||||||
|
onClick={() => toggleFilterDropdown()}
|
||||||
|
>
|
||||||
|
Filter
|
||||||
|
<svg class='-mr-1 h-5 w-5 text-slate-400' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||||
|
<path
|
||||||
|
fill-rule='evenodd'
|
||||||
|
d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z'
|
||||||
|
clip-rule='evenodd'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||||
|
!isFilterDropdownOpen.value ? 'hidden' : ''
|
||||||
|
}`}
|
||||||
|
role='menu'
|
||||||
|
aria-orientation='vertical'
|
||||||
|
aria-labelledby='filter-button'
|
||||||
|
tabindex={-1}
|
||||||
|
>
|
||||||
|
<div class='py-1'>
|
||||||
|
<button
|
||||||
|
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600 ${
|
||||||
|
filter.value.status === 'unread' ? 'font-semibold' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => setNewFilter({ status: 'unread' })}
|
||||||
|
>
|
||||||
|
Show only unread
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600 ${
|
||||||
|
filter.value.status === 'all' ? 'font-semibold' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => setNewFilter({ status: 'all' })}
|
||||||
|
>
|
||||||
|
Show all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class='inline-block justify-center gap-x-1.5 rounded-md bg-slate-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-600 ml-2'
|
||||||
|
type='button'
|
||||||
|
title='Mark all read'
|
||||||
|
onClick={() => onClickMarkAllRead()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/images/check-all.svg'
|
||||||
|
alt='Mark all read'
|
||||||
|
class={`white`}
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2'
|
||||||
|
type='button'
|
||||||
|
title='Fetch new articles'
|
||||||
|
onClick={() => refreshArticles()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/images/refresh.svg'
|
||||||
|
alt='Fetch new articles'
|
||||||
|
class={`white ${isRefreshing.value ? 'animate-spin' : ''}`}
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class='mx-auto max-w-7xl my-8'>
|
||||||
|
{filteredArticles.length === 0
|
||||||
|
? <p class='my-4 block text-center text-lg text-slate-400'>There are no new articles to show.</p>
|
||||||
|
: (
|
||||||
|
<section class='divide-y divide-slate-800 shadow-sm rounded-md'>
|
||||||
|
{filteredArticles.map((article) => (
|
||||||
|
<details
|
||||||
|
class={`group order-first mx-auto max-w-full relative bg-slate-700 duration-150 first:rounded-tl-md first:rounded-tr-md last:rounded-bl-md last:rounded-br-md`}
|
||||||
|
>
|
||||||
|
<summary
|
||||||
|
class={`bg-slate-700 hover:bg-slate-600 px-4 py-4 cursor-pointer flex justify-between group-[:first-child]:rounded-tl-md group-[:first-child]:rounded-tr-md ${
|
||||||
|
article.is_read ? 'opacity-50' : 'font-semibold'
|
||||||
|
}`}
|
||||||
|
onClick={() => onClickView(article.id)}
|
||||||
|
>
|
||||||
|
<span class='mr-2'>{article.article_title}</span>
|
||||||
|
<span class='text-sm text-slate-300 ml-2 font-normal'>
|
||||||
|
{dateFormat.format(new Date(article.article_date))}
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<article class='overflow-auto max-w-full max-h-80 py-2 px-4 font-mono text-sm whitespace-pre-wrap border-t border-b border-slate-600'>
|
||||||
|
{article.article_summary}
|
||||||
|
</article>
|
||||||
|
<a
|
||||||
|
href={article.article_url}
|
||||||
|
class='py-4 px-8 flex justify-between text-right hover:bg-slate-600 group-[:last-child]:rounded-bl-md group-[:last-child]:rounded-br-md'
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer noopener'
|
||||||
|
onClick={() => onClickView(article.id)}
|
||||||
|
>
|
||||||
|
<span class='text-sm text-slate-400 mr-2 font-normal'>{article.article_url}</span>
|
||||||
|
<span class='ml-2'>View article</span>
|
||||||
|
</a>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
372
islands/news/Feeds.tsx
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
import { useSignal } from '@preact/signals';
|
||||||
|
|
||||||
|
import { NewsFeed } from '/lib/types.ts';
|
||||||
|
import { escapeHtml, validateUrl } from '/lib/utils.ts';
|
||||||
|
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/news/add-feed.tsx';
|
||||||
|
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/news/delete-feed.tsx';
|
||||||
|
import {
|
||||||
|
RequestBody as ImportRequestBody,
|
||||||
|
ResponseBody as ImportResponseBody,
|
||||||
|
} from '/routes/api/news/import-feeds.tsx';
|
||||||
|
|
||||||
|
interface FeedsProps {
|
||||||
|
initialFeeds: NewsFeed[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNewsFeedsToOpml(feeds: NewsFeed[]) {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<opml version="2.0">
|
||||||
|
<head>
|
||||||
|
<title>Subscriptions</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${
|
||||||
|
feeds.map((feed) =>
|
||||||
|
`<outline title="${escapeHtml(feed.extra.title || '')}" text="${escapeHtml(feed.extra.title || '')}" type="${
|
||||||
|
feed.extra.feed_type || 'rss'
|
||||||
|
}" xmlUrl="${escapeHtml(feed.feed_url)}" htmlUrl="${escapeHtml(feed.feed_url)}"/>`
|
||||||
|
).join('\n ')
|
||||||
|
}
|
||||||
|
</body>
|
||||||
|
</opml>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOpmlFromTextContents(html: string): string[] {
|
||||||
|
const feedUrls: string[] = [];
|
||||||
|
|
||||||
|
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
|
||||||
|
const feeds = Array.from(document.getElementsByTagName('outline'));
|
||||||
|
|
||||||
|
for (const feed of feeds) {
|
||||||
|
const url = (feed.getAttribute('xmlUrl') || feed.getAttribute('htmlUrl') || '').trim();
|
||||||
|
|
||||||
|
if (validateUrl(url)) {
|
||||||
|
feedUrls.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return feedUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Feeds({ initialFeeds }: FeedsProps) {
|
||||||
|
const isAdding = useSignal<boolean>(false);
|
||||||
|
const isDeleting = useSignal<boolean>(false);
|
||||||
|
const isExporting = useSignal<boolean>(false);
|
||||||
|
const isImporting = useSignal<boolean>(false);
|
||||||
|
const feeds = useSignal<NewsFeed[]>(initialFeeds);
|
||||||
|
const isOptionsDropdownOpen = useSignal<boolean>(false);
|
||||||
|
|
||||||
|
const dateFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
|
||||||
|
async function onClickAddFeed() {
|
||||||
|
if (isAdding.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = (prompt(`What's the **URL** for the new feed?`) || '').trim();
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
alert('A URL is required for a new feed!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateUrl(url)) {
|
||||||
|
alert('Invalid URL!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdding.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: AddRequestBody = { feedUrl: url };
|
||||||
|
const response = await fetch(`/api/news/add-feed`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as AddResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to add feed!');
|
||||||
|
}
|
||||||
|
|
||||||
|
feeds.value = [...result.newFeeds];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdding.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOptionsDropdown() {
|
||||||
|
isOptionsDropdownOpen.value = !isOptionsDropdownOpen.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClickDeleteFeed(feedId: string) {
|
||||||
|
if (confirm('Are you sure you want to delete this feed and all its articles?')) {
|
||||||
|
if (isDeleting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeleting.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody: DeleteRequestBody = { feedId };
|
||||||
|
const response = await fetch(`/api/news/delete-feed`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as DeleteResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to delete feed!');
|
||||||
|
}
|
||||||
|
|
||||||
|
feeds.value = [...result.newFeeds];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeleting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickImportOpml() {
|
||||||
|
isOptionsDropdownOpen.value = false;
|
||||||
|
|
||||||
|
if (isImporting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.click();
|
||||||
|
|
||||||
|
fileInput.onchange = (event) => {
|
||||||
|
const files = (event.target as HTMLInputElement)?.files!;
|
||||||
|
const file = files[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (fileRead) => {
|
||||||
|
const importFileContents = fileRead.target?.result;
|
||||||
|
|
||||||
|
if (!importFileContents || isImporting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isImporting.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const feedUrls = parseOpmlFromTextContents(importFileContents!.toString());
|
||||||
|
|
||||||
|
const requestBody: ImportRequestBody = { feedUrls };
|
||||||
|
const response = await fetch(`/api/news/import-feeds`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
const result = await response.json() as ImportResponseBody;
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to import feeds!');
|
||||||
|
}
|
||||||
|
|
||||||
|
feeds.value = [...result.newFeeds];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
isImporting.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file, 'UTF-8');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickExportOpml() {
|
||||||
|
isOptionsDropdownOpen.value = false;
|
||||||
|
|
||||||
|
if (isExporting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isExporting.value = true;
|
||||||
|
|
||||||
|
const fileName = ['feeds-', new Date().toISOString().substring(0, 19).replace(/:/g, '-'), '.opml']
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const exportContents = formatNewsFeedsToOpml([...feeds.peek()]);
|
||||||
|
|
||||||
|
// Add content-type
|
||||||
|
const xmlContent = ['data:application/xml; charset=utf-8,', exportContents].join('');
|
||||||
|
|
||||||
|
// Download the file
|
||||||
|
const data = encodeURI(xmlContent);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.setAttribute('href', data);
|
||||||
|
link.setAttribute('download', fileName);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
|
||||||
|
isExporting.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section class='flex flex-row items-center justify-between mb-4'>
|
||||||
|
<a href='/news' class='mr-2'>View articles</a>
|
||||||
|
<section class='flex items-center'>
|
||||||
|
<section class='relative inline-block text-left ml-2'>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
class='inline-flex w-full justify-center gap-x-1.5 rounded-md bg-slate-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-600'
|
||||||
|
id='filter-button'
|
||||||
|
aria-expanded='true'
|
||||||
|
aria-haspopup='true'
|
||||||
|
onClick={() => toggleOptionsDropdown()}
|
||||||
|
>
|
||||||
|
OPML
|
||||||
|
<svg class='-mr-1 h-5 w-5 text-slate-400' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||||
|
<path
|
||||||
|
fill-rule='evenodd'
|
||||||
|
d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z'
|
||||||
|
clip-rule='evenodd'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||||
|
!isOptionsDropdownOpen.value ? 'hidden' : ''
|
||||||
|
}`}
|
||||||
|
role='menu'
|
||||||
|
aria-orientation='vertical'
|
||||||
|
aria-labelledby='filter-button'
|
||||||
|
tabindex={-1}
|
||||||
|
>
|
||||||
|
<div class='py-1'>
|
||||||
|
<button
|
||||||
|
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||||
|
onClick={() => onClickImportOpml()}
|
||||||
|
>
|
||||||
|
Import OPML
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||||
|
onClick={() => onClickExportOpml()}
|
||||||
|
>
|
||||||
|
Export OPML
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<button
|
||||||
|
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2'
|
||||||
|
type='button'
|
||||||
|
title='Add new feed'
|
||||||
|
onClick={() => onClickAddFeed()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/images/add.svg'
|
||||||
|
alt='Add new feed'
|
||||||
|
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class='mx-auto max-w-7xl my-8'>
|
||||||
|
<table class='w-full border-collapse bg-slate-700 text-left text-sm text-slate-500 shadow-sm'>
|
||||||
|
<thead class='bg-gray-900'>
|
||||||
|
<tr>
|
||||||
|
<th scope='col' class='px-6 py-4 font-medium text-white'>Title & URL</th>
|
||||||
|
<th scope='col' class='px-6 py-4 font-medium text-white'>Last Crawl</th>
|
||||||
|
<th scope='col' class='px-6 py-4 font-medium text-white'>Type</th>
|
||||||
|
<th scope='col' class='px-6 py-4 font-medium text-white w-20'></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class='divide-y divide-slate-600 border-t border-slate-600'>
|
||||||
|
{feeds.value.map((newsFeed) => (
|
||||||
|
<tr class='hover:bg-slate-600 group'>
|
||||||
|
<td class='flex gap-3 px-6 py-4 font-normal text-white'>
|
||||||
|
<div class='text-sm'>
|
||||||
|
<div class='font-medium text-white'>{newsFeed.extra.title || 'N/A'}</div>
|
||||||
|
<div class='text-slate-400'>{newsFeed.feed_url}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class='px-6 py-4 text-slate-200'>
|
||||||
|
{newsFeed.last_crawled_at ? dateFormat.format(new Date(newsFeed.last_crawled_at)) : 'N/A'}
|
||||||
|
</td>
|
||||||
|
<td class='px-6 py-4'>
|
||||||
|
<div class='text-xs font-semibold text-slate-200'>
|
||||||
|
{newsFeed.extra.feed_type?.split('').map((character) => character.toUpperCase()).join('') || 'N/A'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class='px-6 py-4'>
|
||||||
|
<span
|
||||||
|
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100'
|
||||||
|
onClick={() => onClickDeleteFeed(newsFeed.id)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src='/images/delete.svg'
|
||||||
|
class='red drop-shadow-md'
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
alt='Delete feed'
|
||||||
|
title='Delete feed'
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{feeds.value.length === 0
|
||||||
|
? (
|
||||||
|
<tr class='hover:bg-slate-600'>
|
||||||
|
<td class='flex gap-3 px-6 py-4 font-normal' colspan={4}>
|
||||||
|
<div class='text-md'>
|
||||||
|
<div class='font-medium text-slate-400'>No feeds to show</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
|
||||||
|
>
|
||||||
|
{isDeleting.value
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{isExporting.value
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{isImporting.value
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{!isDeleting.value && !isExporting.value && !isImporting.value ? <> </> : null}
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
lib/auth.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { decodeBase64Url, encodeBase64Url } from 'std/encoding/base64url.ts';
|
||||||
|
import { decodeBase64 } from 'std/encoding/base64.ts';
|
||||||
|
import { Cookie, getCookies, setCookie } from 'std/http/cookie.ts';
|
||||||
|
import 'std/dotenv/load.ts';
|
||||||
|
|
||||||
|
import { baseUrl, generateHash, isRunningLocally } from './utils.ts';
|
||||||
|
import { User, UserSession } from './types.ts';
|
||||||
|
import { createUserSession, deleteUserSession, getUserByEmail, validateUserAndSession } from './data/user.ts';
|
||||||
|
|
||||||
|
const JWT_SECRET = Deno.env.get('JWT_SECRET') || '';
|
||||||
|
export const PASSWORD_SALT = Deno.env.get('PASSWORD_SALT') || '';
|
||||||
|
export const COOKIE_NAME = 'bewcloud-app-v1';
|
||||||
|
|
||||||
|
export interface JwtData {
|
||||||
|
data: {
|
||||||
|
user_id: string;
|
||||||
|
session_id: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const textToData = (text: string) => new TextEncoder().encode(text);
|
||||||
|
|
||||||
|
export const dataToText = (data: Uint8Array) => new TextDecoder().decode(data);
|
||||||
|
|
||||||
|
const generateKey = async (key: string) =>
|
||||||
|
await crypto.subtle.importKey('raw', textToData(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
|
||||||
|
|
||||||
|
async function signAuthJwt(key: CryptoKey, data: JwtData) {
|
||||||
|
const payload = encodeBase64Url(textToData(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))) + '.' +
|
||||||
|
encodeBase64Url(textToData(JSON.stringify(data) || ''));
|
||||||
|
const signature = encodeBase64Url(
|
||||||
|
new Uint8Array(await crypto.subtle.sign({ name: 'HMAC' }, key, textToData(payload))),
|
||||||
|
);
|
||||||
|
return `${payload}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyAuthJwt(key: CryptoKey, jwt: string) {
|
||||||
|
const jwtParts = jwt.split('.');
|
||||||
|
if (jwtParts.length !== 3) {
|
||||||
|
throw new Error('Malformed JWT');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = textToData(jwtParts[0] + '.' + jwtParts[1]);
|
||||||
|
if (await crypto.subtle.verify({ name: 'HMAC' }, key, decodeBase64Url(jwtParts[2]), data) === true) {
|
||||||
|
return JSON.parse(dataToText(decodeBase64Url(jwtParts[1]))) as JwtData;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Invalid JWT');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDataFromRequest(request: Request) {
|
||||||
|
const cookies = getCookies(request.headers);
|
||||||
|
const authorizationHeader = request.headers.get('authorization');
|
||||||
|
|
||||||
|
if (cookies[COOKIE_NAME]) {
|
||||||
|
const result = await getDataFromCookie(cookies[COOKIE_NAME]);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authorizationHeader) {
|
||||||
|
const result = await getDataFromAuthorizationHeader(authorizationHeader);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDataFromAuthorizationHeader(authorizationHeader: string) {
|
||||||
|
if (!authorizationHeader) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only basic auth is supported for now
|
||||||
|
if (!authorizationHeader.startsWith('Basic ')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const basicAuthHash = authorizationHeader.split('Basic ')[1] || '';
|
||||||
|
|
||||||
|
const [basicAuthUsername, basicAuthPassword] = dataToText(decodeBase64(basicAuthHash)).split(':');
|
||||||
|
|
||||||
|
const hashedPassword = await generateHash(`${basicAuthPassword}:${PASSWORD_SALT}`, 'SHA-256');
|
||||||
|
|
||||||
|
const user = await getUserByEmail(basicAuthUsername);
|
||||||
|
|
||||||
|
if (!user || (user.hashed_password !== hashedPassword && user.extra.dav_hashed_password !== hashedPassword)) {
|
||||||
|
throw new Error('Email not found or invalid password.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user, session: undefined };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDataFromCookie(cookieValue: string) {
|
||||||
|
if (!cookieValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await generateKey(JWT_SECRET);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await verifyAuthJwt(key, cookieValue) as JwtData;
|
||||||
|
|
||||||
|
const { user, session } = await validateUserAndSession(token.data.user_id, token.data.session_id);
|
||||||
|
|
||||||
|
return { user, session, tokenData: token.data };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateToken(tokenData: JwtData['data']) {
|
||||||
|
const key = await generateKey(JWT_SECRET);
|
||||||
|
|
||||||
|
const token = await signAuthJwt(key, { data: tokenData });
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logoutUser(request: Request) {
|
||||||
|
const tomorrow = new Date(new Date().setDate(new Date().getDate() + 1));
|
||||||
|
|
||||||
|
const cookies = getCookies(request.headers);
|
||||||
|
|
||||||
|
const result = await getDataFromCookie(cookies[COOKIE_NAME]);
|
||||||
|
|
||||||
|
if (!result || !result.tokenData?.session_id || !result.user) {
|
||||||
|
throw new Error('Invalid session');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tokenData } = result;
|
||||||
|
const { session_id } = tokenData;
|
||||||
|
|
||||||
|
// Delete user session
|
||||||
|
await deleteUserSession(session_id);
|
||||||
|
|
||||||
|
// Generate response with empty and expiring cookie
|
||||||
|
const cookie: Cookie = {
|
||||||
|
name: COOKIE_NAME,
|
||||||
|
value: '',
|
||||||
|
expires: tomorrow,
|
||||||
|
domain: isRunningLocally(request) ? 'localhost' : baseUrl.replace('https://', ''),
|
||||||
|
path: '/',
|
||||||
|
secure: isRunningLocally(request) ? false : true,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'Lax',
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = new Response('Logged Out', {
|
||||||
|
status: 303,
|
||||||
|
headers: { 'Location': '/', 'Content-Type': 'text/html; charset=utf-8' },
|
||||||
|
});
|
||||||
|
|
||||||
|
setCookie(response.headers, cookie);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSessionResponse(
|
||||||
|
request: Request,
|
||||||
|
user: User,
|
||||||
|
{ urlToRedirectTo = '/' }: {
|
||||||
|
urlToRedirectTo?: string;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const response = new Response('Logged In', {
|
||||||
|
status: 303,
|
||||||
|
headers: { 'Location': urlToRedirectTo, 'Content-Type': 'text/html; charset=utf-8' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseWithCookie = await createSessionCookie(request, user, response);
|
||||||
|
|
||||||
|
return responseWithCookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSessionCookie(
|
||||||
|
request: Request,
|
||||||
|
user: User,
|
||||||
|
response: Response,
|
||||||
|
isShortLived = false,
|
||||||
|
) {
|
||||||
|
const newSession = await createUserSession(user, isShortLived);
|
||||||
|
|
||||||
|
// Generate response with session cookie
|
||||||
|
const token = await generateToken({ user_id: user.id, session_id: newSession.id });
|
||||||
|
|
||||||
|
const cookie: Cookie = {
|
||||||
|
name: COOKIE_NAME,
|
||||||
|
value: token,
|
||||||
|
expires: newSession.expires_at,
|
||||||
|
domain: isRunningLocally(request) ? 'localhost' : baseUrl.replace('https://', ''),
|
||||||
|
path: '/',
|
||||||
|
secure: isRunningLocally(request) ? false : true,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'Lax',
|
||||||
|
};
|
||||||
|
|
||||||
|
setCookie(response.headers, cookie);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSessionCookie(
|
||||||
|
response: Response,
|
||||||
|
request: Request,
|
||||||
|
userSession: UserSession,
|
||||||
|
newSessionData: JwtData['data'],
|
||||||
|
) {
|
||||||
|
const token = await generateToken(newSessionData);
|
||||||
|
|
||||||
|
const cookie: Cookie = {
|
||||||
|
name: COOKIE_NAME,
|
||||||
|
value: token,
|
||||||
|
expires: userSession.expires_at,
|
||||||
|
domain: isRunningLocally(request) ? 'localhost' : baseUrl.replace('https://', ''),
|
||||||
|
path: '/',
|
||||||
|
secure: isRunningLocally(request) ? false : true,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'Lax',
|
||||||
|
};
|
||||||
|
|
||||||
|
setCookie(response.headers, cookie);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
15
lib/config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import 'std/dotenv/load.ts';
|
||||||
|
|
||||||
|
import { isThereAnAdmin } from './data/user.ts';
|
||||||
|
|
||||||
|
export async function isSignupAllowed() {
|
||||||
|
const areSignupsAllowed = Deno.env.get('CONFIG_ALLOW_SIGNUPS') === 'true';
|
||||||
|
|
||||||
|
const areThereAdmins = await isThereAnAdmin();
|
||||||
|
|
||||||
|
if (areSignupsAllowed || !areThereAdmins) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
138
lib/data/contacts.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||||
|
import { Contact } from '/lib/types.ts';
|
||||||
|
import { CONTACTS_PER_PAGE_COUNT } from '/lib/utils.ts';
|
||||||
|
import { updateUserContactRevision } from './user.ts';
|
||||||
|
|
||||||
|
const db = new Database();
|
||||||
|
|
||||||
|
export async function getContacts(userId: string, pageIndex: number) {
|
||||||
|
const contacts = await db.query<Pick<Contact, 'id' | 'first_name' | 'last_name'>>(
|
||||||
|
sql`SELECT "id", "first_name", "last_name" FROM "bewcloud_contacts" WHERE "user_id" = $1 ORDER BY "first_name" ASC, "last_name" ASC LIMIT ${CONTACTS_PER_PAGE_COUNT} OFFSET $2`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
pageIndex * CONTACTS_PER_PAGE_COUNT,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return contacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContactsCount(userId: string) {
|
||||||
|
const results = await db.query<{ count: number }>(
|
||||||
|
sql`SELECT COUNT("id") AS "count" FROM "bewcloud_contacts" WHERE "user_id" = $1`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Number(results[0]?.count || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchContacts(search: string, userId: string, pageIndex: number) {
|
||||||
|
const contacts = await db.query<Pick<Contact, 'id' | 'first_name' | 'last_name'>>(
|
||||||
|
sql`SELECT "id", "first_name", "last_name" FROM "bewcloud_contacts" WHERE "user_id" = $1 AND ("first_name" ILIKE $3 OR "last_name" ILIKE $3 OR "extra"::text ILIKE $3) ORDER BY "first_name" ASC, "last_name" ASC LIMIT ${CONTACTS_PER_PAGE_COUNT} OFFSET $2`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
pageIndex * CONTACTS_PER_PAGE_COUNT,
|
||||||
|
`%${search}%`,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return contacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchContactsCount(search: string, userId: string) {
|
||||||
|
const results = await db.query<{ count: number }>(
|
||||||
|
sql`SELECT COUNT("id") AS "count" FROM "bewcloud_contacts" WHERE "user_id" = $1 AND ("first_name" ILIKE $2 OR "last_name" ILIKE $2 OR "extra"::text ILIKE $2)`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
`%${search}%`,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Number(results[0]?.count || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllContacts(userId: string) {
|
||||||
|
const contacts = await db.query<Contact>(sql`SELECT * FROM "bewcloud_contacts" WHERE "user_id" = $1`, [
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return contacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContact(id: string, userId: string) {
|
||||||
|
const contacts = await db.query<Contact>(
|
||||||
|
sql`SELECT * FROM "bewcloud_contacts" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return contacts[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createContact(userId: string, firstName: string, lastName: string) {
|
||||||
|
const extra: Contact['extra'] = {};
|
||||||
|
|
||||||
|
const revision = crypto.randomUUID();
|
||||||
|
|
||||||
|
const newContact = (await db.query<Contact>(
|
||||||
|
sql`INSERT INTO "bewcloud_contacts" (
|
||||||
|
"user_id",
|
||||||
|
"revision",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"extra"
|
||||||
|
) VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
JSON.stringify(extra),
|
||||||
|
],
|
||||||
|
))[0];
|
||||||
|
|
||||||
|
await updateUserContactRevision(userId);
|
||||||
|
|
||||||
|
return newContact;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateContact(contact: Contact) {
|
||||||
|
const revision = crypto.randomUUID();
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
sql`UPDATE "bewcloud_contacts" SET
|
||||||
|
"revision" = $3,
|
||||||
|
"first_name" = $4,
|
||||||
|
"last_name" = $5,
|
||||||
|
"extra" = $6,
|
||||||
|
"updated_at" = now()
|
||||||
|
WHERE "id" = $1 AND "revision" = $2`,
|
||||||
|
[
|
||||||
|
contact.id,
|
||||||
|
contact.revision,
|
||||||
|
revision,
|
||||||
|
contact.first_name,
|
||||||
|
contact.last_name,
|
||||||
|
JSON.stringify(contact.extra),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await updateUserContactRevision(contact.user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteContact(id: string, userId: string) {
|
||||||
|
await db.query(
|
||||||
|
sql`DELETE FROM "bewcloud_contacts" WHERE "id" = $1 AND "user_id" = $2`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await updateUserContactRevision(userId);
|
||||||
|
}
|
||||||
42
lib/data/dashboard.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||||
|
import { Dashboard } from '/lib/types.ts';
|
||||||
|
|
||||||
|
const db = new Database();
|
||||||
|
|
||||||
|
export async function getDashboardByUserId(userId: string) {
|
||||||
|
const dashboard = (await db.query<Dashboard>(sql`SELECT * FROM "bewcloud_dashboards" WHERE "user_id" = $1 LIMIT 1`, [
|
||||||
|
userId,
|
||||||
|
]))[0];
|
||||||
|
|
||||||
|
return dashboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDashboard(userId: string) {
|
||||||
|
const data: Dashboard['data'] = { links: [], notes: '' };
|
||||||
|
|
||||||
|
const newDashboard = (await db.query<Dashboard>(
|
||||||
|
sql`INSERT INTO "bewcloud_dashboards" (
|
||||||
|
"user_id",
|
||||||
|
"data"
|
||||||
|
) VALUES ($1, $2)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
JSON.stringify(data),
|
||||||
|
],
|
||||||
|
))[0];
|
||||||
|
|
||||||
|
return newDashboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDashboard(dashboard: Dashboard) {
|
||||||
|
await db.query(
|
||||||
|
sql`UPDATE "bewcloud_dashboards" SET
|
||||||
|
"data" = $2
|
||||||
|
WHERE "id" = $1`,
|
||||||
|
[
|
||||||
|
dashboard.id,
|
||||||
|
JSON.stringify(dashboard.data),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
298
lib/data/news.ts
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import { Feed } from 'https://deno.land/x/rss@1.0.0/mod.ts';
|
||||||
|
|
||||||
|
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||||
|
import { NewsFeed, NewsFeedArticle } from '/lib/types.ts';
|
||||||
|
import {
|
||||||
|
findFeedInUrl,
|
||||||
|
getArticleUrl,
|
||||||
|
getFeedInfo,
|
||||||
|
JsonFeed,
|
||||||
|
parseTextFromHtml,
|
||||||
|
parseUrl,
|
||||||
|
parseUrlAsGooglebot,
|
||||||
|
parseUrlWithProxy,
|
||||||
|
} from '/lib/feed.ts';
|
||||||
|
|
||||||
|
const db = new Database();
|
||||||
|
|
||||||
|
export async function getNewsFeeds(userId: string) {
|
||||||
|
const newsFeeds = await db.query<NewsFeed>(sql`SELECT * FROM "bewcloud_news_feeds" WHERE "user_id" = $1`, [
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return newsFeeds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNewsFeed(id: string, userId: string) {
|
||||||
|
const newsFeeds = await db.query<NewsFeed>(
|
||||||
|
sql`SELECT * FROM "bewcloud_news_feeds" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return newsFeeds[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNewsArticles(userId: string) {
|
||||||
|
const articles = await db.query<NewsFeedArticle>(
|
||||||
|
sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "user_id" = $1 ORDER BY "article_date" DESC`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return articles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNewsArticlesByFeedId(feedId: string) {
|
||||||
|
const articles = await db.query<NewsFeedArticle>(
|
||||||
|
sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "feed_id" = $1 ORDER BY "article_date" DESC`,
|
||||||
|
[
|
||||||
|
feedId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return articles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNewsArticle(id: string, userId: string) {
|
||||||
|
const articles = await db.query<NewsFeedArticle>(
|
||||||
|
sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return articles[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createNewsFeed(userId: string, feedUrl: string) {
|
||||||
|
const extra: NewsFeed['extra'] = {};
|
||||||
|
|
||||||
|
const newNewsFeed = (await db.query<NewsFeed>(
|
||||||
|
sql`INSERT INTO "bewcloud_news_feeds" (
|
||||||
|
"user_id",
|
||||||
|
"feed_url",
|
||||||
|
"extra"
|
||||||
|
) VALUES ($1, $2, $3)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
feedUrl,
|
||||||
|
JSON.stringify(extra),
|
||||||
|
],
|
||||||
|
))[0];
|
||||||
|
|
||||||
|
return newNewsFeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNewsFeed(newsFeed: NewsFeed) {
|
||||||
|
await db.query(
|
||||||
|
sql`UPDATE "bewcloud_news_feeds" SET
|
||||||
|
"feed_url" = $2,
|
||||||
|
"last_crawled_at" = $3,
|
||||||
|
"extra" = $4
|
||||||
|
WHERE "id" = $1`,
|
||||||
|
[
|
||||||
|
newsFeed.id,
|
||||||
|
newsFeed.feed_url,
|
||||||
|
newsFeed.last_crawled_at,
|
||||||
|
JSON.stringify(newsFeed.extra),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNewsFeed(id: string) {
|
||||||
|
await db.query(
|
||||||
|
sql`DELETE FROM "bewcloud_news_feed_articles" WHERE "feed_id" = $1`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
sql`DELETE FROM "bewcloud_news_feeds" WHERE "id" = $1`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createsNewsArticle(
|
||||||
|
userId: string,
|
||||||
|
feedId: string,
|
||||||
|
article: Omit<NewsFeedArticle, 'id' | 'user_id' | 'feed_id' | 'extra' | 'is_read' | 'created_at'>,
|
||||||
|
) {
|
||||||
|
const extra: NewsFeedArticle['extra'] = {};
|
||||||
|
|
||||||
|
const newNewsArticle = (await db.query<NewsFeedArticle>(
|
||||||
|
sql`INSERT INTO "bewcloud_news_feed_articles" (
|
||||||
|
"user_id",
|
||||||
|
"feed_id",
|
||||||
|
"article_url",
|
||||||
|
"article_title",
|
||||||
|
"article_summary",
|
||||||
|
"article_date",
|
||||||
|
"extra"
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
feedId,
|
||||||
|
article.article_url,
|
||||||
|
article.article_title,
|
||||||
|
article.article_summary,
|
||||||
|
article.article_date,
|
||||||
|
JSON.stringify(extra),
|
||||||
|
],
|
||||||
|
))[0];
|
||||||
|
|
||||||
|
return newNewsArticle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNewsArticle(article: NewsFeedArticle) {
|
||||||
|
await db.query(
|
||||||
|
sql`UPDATE "bewcloud_news_feed_articles" SET
|
||||||
|
"is_read" = $2,
|
||||||
|
"extra" = $3
|
||||||
|
WHERE "id" = $1`,
|
||||||
|
[
|
||||||
|
article.id,
|
||||||
|
article.is_read,
|
||||||
|
JSON.stringify(article.extra),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllArticlesRead(userId: string) {
|
||||||
|
await db.query(
|
||||||
|
sql`UPDATE "bewcloud_news_feed_articles" SET
|
||||||
|
"is_read" = TRUE
|
||||||
|
WHERE "user_id" = $1`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchNewsArticles(newsFeed: NewsFeed): Promise<Feed['entries'] | JsonFeed['items']> {
|
||||||
|
try {
|
||||||
|
if (!newsFeed.extra.title || !newsFeed.extra.feed_type || !newsFeed.extra.crawl_type) {
|
||||||
|
throw new Error('Invalid News Feed!');
|
||||||
|
}
|
||||||
|
|
||||||
|
let feed: JsonFeed | Feed | null = null;
|
||||||
|
|
||||||
|
if (newsFeed.extra.crawl_type === 'direct') {
|
||||||
|
feed = await parseUrl(newsFeed.feed_url);
|
||||||
|
} else if (newsFeed.extra.crawl_type === 'googlebot') {
|
||||||
|
feed = await parseUrlAsGooglebot(newsFeed.feed_url);
|
||||||
|
} else if (newsFeed.extra.crawl_type === 'proxy') {
|
||||||
|
feed = await parseUrlWithProxy(newsFeed.feed_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (feed as Feed)?.entries || (feed as JsonFeed)?.items || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed parsing feed to get articles', newsFeed.feed_url);
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeedArticle = Feed['entries'][number];
|
||||||
|
type JsonFeedArticle = JsonFeed['items'][number];
|
||||||
|
|
||||||
|
const MAX_ARTICLES_CRAWLED_PER_RUN = 10;
|
||||||
|
|
||||||
|
export async function crawlNewsFeed(newsFeed: NewsFeed) {
|
||||||
|
// TODO: Lock this per feedId, so no two processes run this at the same time
|
||||||
|
|
||||||
|
if (!newsFeed.extra.title || !newsFeed.extra.feed_type || !newsFeed.extra.crawl_type) {
|
||||||
|
const feedUrl = await findFeedInUrl(newsFeed.feed_url);
|
||||||
|
|
||||||
|
if (!feedUrl) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid URL for feed: "${feedUrl}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feedUrl !== newsFeed.feed_url) {
|
||||||
|
newsFeed.feed_url = feedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedInfo = await getFeedInfo(newsFeed.feed_url);
|
||||||
|
|
||||||
|
newsFeed.extra.title = feedInfo.title;
|
||||||
|
newsFeed.extra.feed_type = feedInfo.feed_type;
|
||||||
|
newsFeed.extra.crawl_type = feedInfo.crawl_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedArticles = await fetchNewsArticles(newsFeed);
|
||||||
|
|
||||||
|
const articles: Omit<NewsFeedArticle, 'id' | 'user_id' | 'feed_id' | 'extra' | 'is_read' | 'created_at'>[] = [];
|
||||||
|
|
||||||
|
for (const feedArticle of feedArticles) {
|
||||||
|
// Don't add too many articles per run
|
||||||
|
if (articles.length >= MAX_ARTICLES_CRAWLED_PER_RUN) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = (feedArticle as JsonFeedArticle).url || getArticleUrl((feedArticle as FeedArticle).links) ||
|
||||||
|
feedArticle.id;
|
||||||
|
|
||||||
|
const articleIsoDate = (feedArticle as JsonFeedArticle).date_published ||
|
||||||
|
(feedArticle as FeedArticle).published?.toISOString() || (feedArticle as JsonFeedArticle).date_modified ||
|
||||||
|
(feedArticle as FeedArticle).updated?.toISOString();
|
||||||
|
|
||||||
|
const articleDate = articleIsoDate ? new Date(articleIsoDate) : new Date();
|
||||||
|
|
||||||
|
const summary = await parseTextFromHtml(
|
||||||
|
(feedArticle as FeedArticle).description?.value || (feedArticle as FeedArticle).content?.value ||
|
||||||
|
(feedArticle as JsonFeedArticle).content_text || (feedArticle as JsonFeedArticle).content_html ||
|
||||||
|
(feedArticle as JsonFeedArticle).summary || '',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
articles.push({
|
||||||
|
article_title: (feedArticle as FeedArticle).title?.value || (feedArticle as JsonFeedArticle).title ||
|
||||||
|
url.replace('http://', '').replace('https://', ''),
|
||||||
|
article_url: url,
|
||||||
|
article_summary: summary,
|
||||||
|
article_date: articleDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingArticles = await getNewsArticlesByFeedId(newsFeed.id);
|
||||||
|
const existingArticleUrls = new Set<string>(existingArticles.map((article) => article.article_url));
|
||||||
|
const previousLatestArticleUrl = existingArticles[0]?.article_url;
|
||||||
|
let seenPreviousLatestArticleUrl = false;
|
||||||
|
let addedArticlesCount = 0;
|
||||||
|
|
||||||
|
for (const article of articles) {
|
||||||
|
// Stop looking after seeing the previous latest article
|
||||||
|
if (article.article_url === previousLatestArticleUrl) {
|
||||||
|
seenPreviousLatestArticleUrl = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seenPreviousLatestArticleUrl && !existingArticleUrls.has(article.article_url)) {
|
||||||
|
try {
|
||||||
|
await createsNewsArticle(newsFeed.user_id, newsFeed.id, article);
|
||||||
|
++addedArticlesCount;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
console.error(`Failed to add new article: "${article.article_url}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Added', addedArticlesCount, 'new articles');
|
||||||
|
|
||||||
|
newsFeed.last_crawled_at = new Date();
|
||||||
|
|
||||||
|
await updateNewsFeed(newsFeed);
|
||||||
|
}
|
||||||
296
lib/data/user.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||||
|
import { User, UserSession, VerificationCode } from '/lib/types.ts';
|
||||||
|
import { generateRandomCode } from '/lib/utils.ts';
|
||||||
|
|
||||||
|
const db = new Database();
|
||||||
|
|
||||||
|
export async function isThereAnAdmin() {
|
||||||
|
const user =
|
||||||
|
(await db.query<User>(sql`SELECT * FROM "bewcloud_users" WHERE ("extra" ->> 'is_admin')::boolean IS TRUE LIMIT 1`))[
|
||||||
|
0
|
||||||
|
];
|
||||||
|
|
||||||
|
return Boolean(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserByEmail(email: string) {
|
||||||
|
const lowercaseEmail = email.toLowerCase().trim();
|
||||||
|
|
||||||
|
const user = (await db.query<User>(sql`SELECT * FROM "bewcloud_users" WHERE "email" = $1 LIMIT 1`, [
|
||||||
|
lowercaseEmail,
|
||||||
|
]))[0];
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserById(id: string) {
|
||||||
|
const user = (await db.query<User>(sql`SELECT * FROM "bewcloud_users" WHERE "id" = $1 LIMIT 1`, [
|
||||||
|
id,
|
||||||
|
]))[0];
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(email: User['email'], hashedPassword: User['hashed_password']) {
|
||||||
|
const trialDays = 30;
|
||||||
|
const now = new Date();
|
||||||
|
const trialEndDate = new Date(new Date().setUTCDate(new Date().getUTCDate() + trialDays));
|
||||||
|
|
||||||
|
const subscription: User['subscription'] = {
|
||||||
|
external: {},
|
||||||
|
expires_at: trialEndDate.toISOString(),
|
||||||
|
updated_at: now.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const extra: User['extra'] = { is_email_verified: false };
|
||||||
|
|
||||||
|
// First signup will be an admin "forever"
|
||||||
|
if (!(await isThereAnAdmin())) {
|
||||||
|
extra.is_admin = true;
|
||||||
|
subscription.expires_at = new Date('2100-12-31').toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUser = (await db.query<User>(
|
||||||
|
sql`INSERT INTO "bewcloud_users" (
|
||||||
|
"email",
|
||||||
|
"subscription",
|
||||||
|
"status",
|
||||||
|
"hashed_password",
|
||||||
|
"extra"
|
||||||
|
) VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
email,
|
||||||
|
JSON.stringify(subscription),
|
||||||
|
extra.is_admin ? 'active' : 'trial',
|
||||||
|
hashedPassword,
|
||||||
|
JSON.stringify(extra),
|
||||||
|
],
|
||||||
|
))[0];
|
||||||
|
|
||||||
|
return newUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(user: User) {
|
||||||
|
await db.query(
|
||||||
|
sql`UPDATE "bewcloud_users" SET
|
||||||
|
"email" = $2,
|
||||||
|
"subscription" = $3,
|
||||||
|
"status" = $4,
|
||||||
|
"hashed_password" = $5,
|
||||||
|
"extra" = $6
|
||||||
|
WHERE "id" = $1`,
|
||||||
|
[
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
JSON.stringify(user.subscription),
|
||||||
|
user.status,
|
||||||
|
user.hashed_password,
|
||||||
|
JSON.stringify(user.extra),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(userId: string) {
|
||||||
|
await db.query(
|
||||||
|
sql`DELETE FROM "bewcloud_user_sessions" WHERE "user_id" = $1`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
sql`DELETE FROM "bewcloud_verification_codes" WHERE "user_id" = $1`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
sql`DELETE FROM "bewcloud_news_feed_articles" WHERE "user_id" = $1`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
sql`DELETE FROM "bewcloud_news_feeds" WHERE "user_id" = $1`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
sql`DELETE FROM "bewcloud_users" WHERE "id" = $1`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionById(id: string) {
|
||||||
|
const session = (await db.query<UserSession>(
|
||||||
|
sql`SELECT * FROM "bewcloud_user_sessions" WHERE "id" = $1 AND "expires_at" > now() LIMIT 1`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
],
|
||||||
|
))[0];
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUserSession(user: User, isShortLived = false) {
|
||||||
|
const oneMonthFromToday = new Date(new Date().setUTCMonth(new Date().getUTCMonth() + 1));
|
||||||
|
const oneWeekFromToday = new Date(new Date().setUTCDate(new Date().getUTCDate() + 7));
|
||||||
|
|
||||||
|
const newSession: Omit<UserSession, 'id' | 'created_at'> = {
|
||||||
|
user_id: user.id,
|
||||||
|
expires_at: isShortLived ? oneWeekFromToday : oneMonthFromToday,
|
||||||
|
last_seen_at: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const newUserSessionResult = (await db.query<UserSession>(
|
||||||
|
sql`INSERT INTO "bewcloud_user_sessions" (
|
||||||
|
"user_id",
|
||||||
|
"expires_at",
|
||||||
|
"last_seen_at"
|
||||||
|
) VALUES ($1, $2, $3)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
newSession.user_id,
|
||||||
|
newSession.expires_at,
|
||||||
|
newSession.last_seen_at,
|
||||||
|
],
|
||||||
|
))[0];
|
||||||
|
|
||||||
|
return newUserSessionResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSession(session: UserSession) {
|
||||||
|
await db.query(
|
||||||
|
sql`UPDATE "bewcloud_user_sessions" SET
|
||||||
|
"expires_at" = $2,
|
||||||
|
"last_seen_at" = $3
|
||||||
|
WHERE "id" = $1`,
|
||||||
|
[
|
||||||
|
session.id,
|
||||||
|
session.expires_at,
|
||||||
|
session.last_seen_at,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserSession(sessionId: string) {
|
||||||
|
await db.query(
|
||||||
|
sql`DELETE FROM "bewcloud_user_sessions" WHERE "id" = $1`,
|
||||||
|
[
|
||||||
|
sessionId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateUserAndSession(userId: string, sessionId: string) {
|
||||||
|
const user = await getUserById(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('Not Found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getSessionById(sessionId);
|
||||||
|
|
||||||
|
if (!session || session.user_id !== user.id) {
|
||||||
|
throw new Error('Not Found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const oneMonthFromToday = new Date(new Date().setUTCMonth(new Date().getUTCMonth() + 1));
|
||||||
|
|
||||||
|
session.last_seen_at = new Date();
|
||||||
|
session.expires_at = oneMonthFromToday;
|
||||||
|
|
||||||
|
await updateSession(session);
|
||||||
|
|
||||||
|
return { user, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createVerificationCode(
|
||||||
|
user: User,
|
||||||
|
verificationId: string,
|
||||||
|
type: VerificationCode['verification']['type'],
|
||||||
|
) {
|
||||||
|
const inThirtyMinutes = new Date(new Date().setUTCMinutes(new Date().getUTCMinutes() + 30));
|
||||||
|
|
||||||
|
const code = generateRandomCode();
|
||||||
|
|
||||||
|
const newVerificationCode: Omit<VerificationCode, 'id' | 'created_at'> = {
|
||||||
|
user_id: user.id,
|
||||||
|
code,
|
||||||
|
expires_at: inThirtyMinutes,
|
||||||
|
verification: {
|
||||||
|
id: verificationId,
|
||||||
|
type,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
sql`INSERT INTO "bewcloud_verification_codes" (
|
||||||
|
"user_id",
|
||||||
|
"code",
|
||||||
|
"expires_at",
|
||||||
|
"verification"
|
||||||
|
) VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING "id"`,
|
||||||
|
[
|
||||||
|
newVerificationCode.user_id,
|
||||||
|
newVerificationCode.code,
|
||||||
|
newVerificationCode.expires_at,
|
||||||
|
JSON.stringify(newVerificationCode.verification),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateVerificationCode(
|
||||||
|
user: User,
|
||||||
|
verificationId: string,
|
||||||
|
code: string,
|
||||||
|
type: VerificationCode['verification']['type'],
|
||||||
|
) {
|
||||||
|
const verificationCode = (await db.query<VerificationCode>(
|
||||||
|
sql`SELECT * FROM "bewcloud_verification_codes"
|
||||||
|
WHERE "user_id" = $1 AND
|
||||||
|
"code" = $2 AND
|
||||||
|
"verification" ->> 'type' = $3 AND
|
||||||
|
"verification" ->> 'id' = $4 AND
|
||||||
|
"expires_at" > now()
|
||||||
|
LIMIT 1`,
|
||||||
|
[
|
||||||
|
user.id,
|
||||||
|
code,
|
||||||
|
type,
|
||||||
|
verificationId,
|
||||||
|
],
|
||||||
|
))[0];
|
||||||
|
|
||||||
|
if (verificationCode) {
|
||||||
|
await db.query(
|
||||||
|
sql`DELETE FROM "bewcloud_verification_codes" WHERE "id" = $1`,
|
||||||
|
[
|
||||||
|
verificationCode.id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error('Not Found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserContactRevision(id: string) {
|
||||||
|
const user = await getUserById(id);
|
||||||
|
|
||||||
|
const revision = crypto.randomUUID();
|
||||||
|
|
||||||
|
user.extra.contacts_revision = revision;
|
||||||
|
user.extra.contacts_updated_at = new Date().toISOString();
|
||||||
|
|
||||||
|
await updateUser(user);
|
||||||
|
}
|
||||||
233
lib/feed.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import { DOMParser, initParser } from 'https://deno.land/x/deno_dom@v0.1.45/deno-dom-wasm-noinit.ts';
|
||||||
|
import { Feed, parseFeed } from 'https://deno.land/x/rss@1.0.0/mod.ts';
|
||||||
|
import { fetchUrl, fetchUrlAsGooglebot, fetchUrlWithProxy, fetchUrlWithRetries } from './utils.ts';
|
||||||
|
import { NewsFeed, NewsFeedCrawlType, NewsFeedType } from './types.ts';
|
||||||
|
|
||||||
|
export interface JsonFeedItem {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
content_text?: string;
|
||||||
|
content_html?: string;
|
||||||
|
summary?: string;
|
||||||
|
date_modified?: string;
|
||||||
|
date_published: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JsonFeed {
|
||||||
|
version: string;
|
||||||
|
title: string;
|
||||||
|
home_page_url?: string;
|
||||||
|
description?: string;
|
||||||
|
authors?: { name: string; url?: string }[];
|
||||||
|
language?: string;
|
||||||
|
items: JsonFeedItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFeedFromUrlContents(urlContents: string) {
|
||||||
|
try {
|
||||||
|
const jsonFeed = JSON.parse(urlContents) as JsonFeed;
|
||||||
|
return jsonFeed;
|
||||||
|
} catch (_error) {
|
||||||
|
const feed = await parseFeed(urlContents);
|
||||||
|
return feed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseUrl(feedUrl: string) {
|
||||||
|
const urlContents = await fetchUrl(feedUrl);
|
||||||
|
const feed = await getFeedFromUrlContents(urlContents);
|
||||||
|
return feed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseUrlAsGooglebot(feedUrl: string) {
|
||||||
|
const urlContents = await fetchUrlAsGooglebot(feedUrl);
|
||||||
|
const feed = await getFeedFromUrlContents(urlContents);
|
||||||
|
return feed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseUrlWithProxy(feedUrl: string) {
|
||||||
|
const urlContents = await fetchUrlWithProxy(feedUrl);
|
||||||
|
const feed = await getFeedFromUrlContents(urlContents);
|
||||||
|
return feed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseUrlWithRetries(feedUrl: string): Promise<{ feed: JsonFeed | Feed; crawlType: NewsFeedCrawlType }> {
|
||||||
|
try {
|
||||||
|
const feed = await parseUrl(feedUrl);
|
||||||
|
return { feed, crawlType: 'direct' };
|
||||||
|
} catch (_error) {
|
||||||
|
try {
|
||||||
|
const feed = await parseUrlAsGooglebot(feedUrl);
|
||||||
|
return { feed, crawlType: 'googlebot' };
|
||||||
|
} catch (_error) {
|
||||||
|
const feed = await parseUrlWithProxy(feedUrl);
|
||||||
|
return { feed, crawlType: 'proxy' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isValid(feedUrl: string, fastFail = false) {
|
||||||
|
try {
|
||||||
|
console.log('Checking if URL is a valid feed URL', feedUrl);
|
||||||
|
const { feed } = fastFail ? { feed: await parseUrl(feedUrl) } : await parseUrlWithRetries(feedUrl);
|
||||||
|
return Boolean(
|
||||||
|
(feed as Feed).title?.value || (feed as JsonFeed).title || (feed as JsonFeed).items?.length ||
|
||||||
|
(feed as Feed).links?.length > 0 || feed.description,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed parsing feed to check validity', feedUrl);
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFeedInfo(feedUrl: string, fastFail = false): Promise<NewsFeed['extra']> {
|
||||||
|
try {
|
||||||
|
console.log('Getting Feed URL info', feedUrl);
|
||||||
|
|
||||||
|
const { feed, crawlType } = fastFail
|
||||||
|
? { feed: await parseUrl(feedUrl), crawlType: 'direct' as const }
|
||||||
|
: await parseUrlWithRetries(feedUrl);
|
||||||
|
let feedType: NewsFeedType = 'rss';
|
||||||
|
|
||||||
|
if ((feed as JsonFeed).version) {
|
||||||
|
feedType = 'json';
|
||||||
|
} else if ((feed as Feed).type === 'ATOM') {
|
||||||
|
feedType = 'atom';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: (feed as Feed).title?.value || (feed as JsonFeed).title || '',
|
||||||
|
feed_type: feedType,
|
||||||
|
crawl_type: crawlType,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed parsing feed to check validity', feedUrl);
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findFeedInUrl(url: string) {
|
||||||
|
let urlContents = '';
|
||||||
|
try {
|
||||||
|
urlContents = await fetchUrl(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed to fetch URL to find feed', url);
|
||||||
|
console.log(error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await initParser();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const document = new DOMParser().parseFromString(urlContents, 'text/html');
|
||||||
|
|
||||||
|
const urlOptions = [
|
||||||
|
url,
|
||||||
|
document!.querySelector('link[type="application/rss+xml"]')?.getAttribute('href'),
|
||||||
|
document!.querySelector('link[type="application/atom+xml"]')?.getAttribute('href'),
|
||||||
|
document!.querySelector('link[rel="alternate"]')?.getAttribute('href'),
|
||||||
|
// Try some common URL paths
|
||||||
|
'feed',
|
||||||
|
'rss',
|
||||||
|
'rss.xml',
|
||||||
|
'feed.xml',
|
||||||
|
'atom.xml',
|
||||||
|
'atom',
|
||||||
|
'feeds/posts/default',
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
for (const urlOption of urlOptions) {
|
||||||
|
const optionalSlash = urlOption!.startsWith('/') || url.endsWith('/') ? '' : '/';
|
||||||
|
const potentialFeedUrl = urlOption!.startsWith('http') ? urlOption : `${url}${optionalSlash}${urlOption}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isValidFeed = await isValid(potentialFeedUrl!, true);
|
||||||
|
|
||||||
|
if (isValidFeed) {
|
||||||
|
return potentialFeedUrl;
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// Do nothing.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// This error can happen for huge responses, but that usually means the URL works
|
||||||
|
if (error.toString().includes('RangeError: Maximum call stack size exceeded')) {
|
||||||
|
return url;
|
||||||
|
} else {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getArticleUrl(links: Feed['entries'][0]['links']) {
|
||||||
|
try {
|
||||||
|
for (const link of links) {
|
||||||
|
if (link.rel === 'alternate' && link.type?.startsWith('text/html')) {
|
||||||
|
return link.href || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return links[0]?.href || '';
|
||||||
|
} catch (_error) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUrlInfo(url: string): Promise<{ title: string; htmlBody: string; textBody: string } | null> {
|
||||||
|
let urlContents = '';
|
||||||
|
try {
|
||||||
|
urlContents = await fetchUrlWithRetries(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed to fetch URL to get info', url);
|
||||||
|
console.log(error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await initParser();
|
||||||
|
|
||||||
|
const document = new DOMParser().parseFromString(urlContents, 'text/html');
|
||||||
|
|
||||||
|
const title = document!.querySelector('title')?.textContent;
|
||||||
|
let htmlBody = document!.querySelector('body')?.innerHTML;
|
||||||
|
let textBody = document!.querySelector('body')?.textContent;
|
||||||
|
|
||||||
|
const mainHtml = document!.querySelector('main')?.innerHTML;
|
||||||
|
const mainText = document!.querySelector('main')?.textContent;
|
||||||
|
|
||||||
|
const articleHtml = document!.querySelector('article')?.innerHTML;
|
||||||
|
const articleText = document!.querySelector('article')?.textContent;
|
||||||
|
|
||||||
|
if (mainHtml && mainText) {
|
||||||
|
htmlBody = mainHtml;
|
||||||
|
textBody = mainText;
|
||||||
|
} else if (articleHtml && articleText) {
|
||||||
|
htmlBody = articleHtml;
|
||||||
|
textBody = articleText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title || !htmlBody || !textBody) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { title, htmlBody, textBody };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseTextFromHtml(html: string): Promise<string> {
|
||||||
|
let text = '';
|
||||||
|
|
||||||
|
await initParser();
|
||||||
|
|
||||||
|
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
|
||||||
|
text = document!.textContent;
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
185
lib/form-utils.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
export interface FormField {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
value?: string | null;
|
||||||
|
overrideValue?: string;
|
||||||
|
description?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
type:
|
||||||
|
| 'text'
|
||||||
|
| 'email'
|
||||||
|
| 'tel'
|
||||||
|
| 'url'
|
||||||
|
| 'date'
|
||||||
|
| 'number'
|
||||||
|
| 'range'
|
||||||
|
| 'select'
|
||||||
|
| 'textarea'
|
||||||
|
| 'checkbox'
|
||||||
|
| 'hidden'
|
||||||
|
| 'password';
|
||||||
|
step?: string;
|
||||||
|
max?: string;
|
||||||
|
min?: string;
|
||||||
|
rows?: string;
|
||||||
|
options?: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}[];
|
||||||
|
checked?: boolean;
|
||||||
|
multiple?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
extraClasses?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFormDataField(formData: FormData, field: string) {
|
||||||
|
return ((formData.get(field) || '') as string).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFormDataFieldArray(formData: FormData, field: string) {
|
||||||
|
return ((formData.getAll(field) || []) as string[]).map((value) => value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateFieldHtml(
|
||||||
|
field: FormField,
|
||||||
|
formData: FormData,
|
||||||
|
) {
|
||||||
|
let value = field.overrideValue ||
|
||||||
|
(field.multiple ? getFormDataFieldArray(formData, field.name) : getFormDataField(formData, field.name)) ||
|
||||||
|
field.value;
|
||||||
|
|
||||||
|
if (typeof field.overrideValue !== 'undefined') {
|
||||||
|
value = field.overrideValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'hidden') {
|
||||||
|
return generateInputHtml(field, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset class={`block mb-4 ${field.extraClasses || ''}`}>
|
||||||
|
<label class='text-slate-300 block pb-1' for={`field_${field.name}`}>{field.label}</label>
|
||||||
|
{generateInputHtml(field, value)}
|
||||||
|
{field.description
|
||||||
|
? (
|
||||||
|
<aside class={`text-sm text-slate-400 p-2 ${field.type === 'checkbox' ? 'inline' : ''}`}>
|
||||||
|
{field.description}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateInputHtml(
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
placeholder,
|
||||||
|
type,
|
||||||
|
options,
|
||||||
|
step,
|
||||||
|
max,
|
||||||
|
min,
|
||||||
|
rows,
|
||||||
|
checked,
|
||||||
|
multiple,
|
||||||
|
disabled,
|
||||||
|
required,
|
||||||
|
readOnly,
|
||||||
|
}: FormField,
|
||||||
|
value?: string | string[] | null,
|
||||||
|
) {
|
||||||
|
const additionalAttributes: Record<string, string | number | boolean> = {};
|
||||||
|
|
||||||
|
if (typeof step !== 'undefined') {
|
||||||
|
additionalAttributes.step = parseInt(step, 10);
|
||||||
|
}
|
||||||
|
if (typeof max !== 'undefined') {
|
||||||
|
additionalAttributes.max = parseInt(max, 10);
|
||||||
|
}
|
||||||
|
if (typeof min !== 'undefined') {
|
||||||
|
additionalAttributes.min = parseInt(min, 10);
|
||||||
|
}
|
||||||
|
if (typeof rows !== 'undefined') {
|
||||||
|
additionalAttributes.rows = parseInt(rows, 10);
|
||||||
|
}
|
||||||
|
if (checked === true && type === 'checkbox' && value) {
|
||||||
|
additionalAttributes.checked = true;
|
||||||
|
}
|
||||||
|
if (multiple === true) {
|
||||||
|
additionalAttributes.multiple = true;
|
||||||
|
}
|
||||||
|
if (required === true) {
|
||||||
|
additionalAttributes.required = true;
|
||||||
|
}
|
||||||
|
if (disabled === true) {
|
||||||
|
additionalAttributes.disabled = true;
|
||||||
|
}
|
||||||
|
if (readOnly === true) {
|
||||||
|
additionalAttributes.readonly = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'select') {
|
||||||
|
return (
|
||||||
|
<select class='mt-1 input-field' id={`field_${name}`} name={name} type={type} {...additionalAttributes}>
|
||||||
|
{options?.map((option) => (
|
||||||
|
<option
|
||||||
|
value={option.value}
|
||||||
|
selected={option.value === value || (multiple && (value || [])?.includes(option.value))}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'textarea') {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
class='mt-1 input-field'
|
||||||
|
id={`field_${name}`}
|
||||||
|
name={name}
|
||||||
|
rows={6}
|
||||||
|
placeholder={placeholder}
|
||||||
|
{...additionalAttributes}
|
||||||
|
>
|
||||||
|
{(value as string) || ''}
|
||||||
|
</textarea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'checkbox') {
|
||||||
|
return (
|
||||||
|
<input id={`field_${name}`} name={name} type={type} value={value as string || ''} {...additionalAttributes} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'password') {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
class='mt-1 input-field'
|
||||||
|
id={`field_${name}`}
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
placeholder={placeholder || ''}
|
||||||
|
value=''
|
||||||
|
{...additionalAttributes}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
class='mt-1 input-field'
|
||||||
|
id={`field_${name}`}
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
placeholder={placeholder || ''}
|
||||||
|
value={value as string || ''}
|
||||||
|
{...additionalAttributes}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
lib/interfaces/database.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Client } from 'https://deno.land/x/postgres@v0.19.2/mod.ts';
|
||||||
|
import 'std/dotenv/load.ts';
|
||||||
|
|
||||||
|
const POSTGRESQL_HOST = Deno.env.get('POSTGRESQL_HOST') || '';
|
||||||
|
const POSTGRESQL_USER = Deno.env.get('POSTGRESQL_USER') || '';
|
||||||
|
const POSTGRESQL_PASSWORD = Deno.env.get('POSTGRESQL_PASSWORD') || '';
|
||||||
|
const POSTGRESQL_DBNAME = Deno.env.get('POSTGRESQL_DBNAME') || '';
|
||||||
|
const POSTGRESQL_PORT = Deno.env.get('POSTGRESQL_PORT') || '';
|
||||||
|
const POSTGRESQL_CAFILE = Deno.env.get('POSTGRESQL_CAFILE') || '';
|
||||||
|
|
||||||
|
const tls = POSTGRESQL_CAFILE
|
||||||
|
? {
|
||||||
|
enabled: true,
|
||||||
|
enforce: false,
|
||||||
|
caCertificates: [await Deno.readTextFile(POSTGRESQL_CAFILE)],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
enabled: true,
|
||||||
|
enforce: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class Database {
|
||||||
|
protected db?: Client;
|
||||||
|
|
||||||
|
constructor(connectNow = false) {
|
||||||
|
if (connectNow) {
|
||||||
|
this.connectToPostgres();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async connectToPostgres() {
|
||||||
|
if (this.db) {
|
||||||
|
return this.db;
|
||||||
|
}
|
||||||
|
|
||||||
|
const postgresClient = new Client({
|
||||||
|
user: POSTGRESQL_USER,
|
||||||
|
password: POSTGRESQL_PASSWORD,
|
||||||
|
database: POSTGRESQL_DBNAME,
|
||||||
|
hostname: POSTGRESQL_HOST,
|
||||||
|
port: POSTGRESQL_PORT,
|
||||||
|
tls,
|
||||||
|
});
|
||||||
|
|
||||||
|
await postgresClient.connect();
|
||||||
|
|
||||||
|
this.db = postgresClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async disconnectFromPostgres() {
|
||||||
|
if (!this.db) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.end();
|
||||||
|
|
||||||
|
this.db = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public close() {
|
||||||
|
this.disconnectFromPostgres();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async query<T>(sql: string, args?: any[]) {
|
||||||
|
if (!this.db) {
|
||||||
|
await this.connectToPostgres();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.db!.queryObject<T>(sql, args);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This allows us to have nice SQL syntax highlighting in template literals
|
||||||
|
export const sql = String.raw;
|
||||||
83
lib/providers/brevo.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import 'std/dotenv/load.ts';
|
||||||
|
|
||||||
|
import { helpEmail } from '/lib/utils.ts';
|
||||||
|
|
||||||
|
const BREVO_API_KEY = Deno.env.get('BREVO_API_KEY') || '';
|
||||||
|
|
||||||
|
enum BrevoTemplateId {
|
||||||
|
BEWCLOUD_VERIFY_EMAIL = 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrevoResponse {
|
||||||
|
messageId?: string;
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiRequestHeaders() {
|
||||||
|
return {
|
||||||
|
'Api-Key': BREVO_API_KEY,
|
||||||
|
'Accept': 'application/json; charset=utf-8',
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrevoRequestBody {
|
||||||
|
templateId?: number;
|
||||||
|
params: Record<string, any> | null;
|
||||||
|
to: { email: string; name?: string }[];
|
||||||
|
cc?: { email: string; name?: string }[];
|
||||||
|
bcc?: { email: string; name?: string }[];
|
||||||
|
htmlContent?: string;
|
||||||
|
textContent?: string;
|
||||||
|
subject?: string;
|
||||||
|
replyTo: { email: string; name?: string };
|
||||||
|
tags?: string[];
|
||||||
|
attachment?: { name: string; content: string; url: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendEmailWithTemplate(
|
||||||
|
to: string,
|
||||||
|
templateId: BrevoTemplateId,
|
||||||
|
data: BrevoRequestBody['params'],
|
||||||
|
attachments: BrevoRequestBody['attachment'] = [],
|
||||||
|
cc?: string,
|
||||||
|
) {
|
||||||
|
const email: BrevoRequestBody = {
|
||||||
|
templateId,
|
||||||
|
params: data,
|
||||||
|
to: [{ email: to }],
|
||||||
|
replyTo: { email: helpEmail },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (attachments?.length) {
|
||||||
|
email.attachment = attachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cc) {
|
||||||
|
email.cc = [{ email: cc }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const brevoResponse = await fetch('https://api.brevo.com/v3/smtp/email', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getApiRequestHeaders(),
|
||||||
|
body: JSON.stringify(email),
|
||||||
|
});
|
||||||
|
const brevoResult = (await brevoResponse.json()) as BrevoResponse;
|
||||||
|
|
||||||
|
if (brevoResult.code || brevoResult.message) {
|
||||||
|
console.log(JSON.stringify({ brevoResult }, null, 2));
|
||||||
|
throw new Error(`Failed to send email "${templateId}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendVerifyEmailEmail(
|
||||||
|
email: string,
|
||||||
|
verificationCode: string,
|
||||||
|
) {
|
||||||
|
const data = {
|
||||||
|
verificationCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
await sendEmailWithTemplate(email, BrevoTemplateId.BEWCLOUD_VERIFY_EMAIL, data);
|
||||||
|
}
|
||||||
131
lib/types.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
hashed_password: string;
|
||||||
|
subscription: {
|
||||||
|
external: Record<never, never>;
|
||||||
|
expires_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
status: 'trial' | 'active' | 'inactive';
|
||||||
|
extra: {
|
||||||
|
is_email_verified: boolean;
|
||||||
|
is_admin?: boolean;
|
||||||
|
dav_hashed_password?: string;
|
||||||
|
contacts_revision?: string;
|
||||||
|
contacts_updated_at?: string;
|
||||||
|
};
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSession {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
expires_at: Date;
|
||||||
|
last_seen_at: Date;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreshContextState {
|
||||||
|
user?: User;
|
||||||
|
session?: UserSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerificationCode {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
code: string;
|
||||||
|
verification: {
|
||||||
|
type: 'email';
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
expires_at: Date;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardLink {
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Dashboard {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
data: {
|
||||||
|
links: DashboardLink[];
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NewsFeedType = 'rss' | 'atom' | 'json';
|
||||||
|
export type NewsFeedCrawlType = 'direct' | 'googlebot' | 'proxy';
|
||||||
|
|
||||||
|
export interface NewsFeed {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
feed_url: string;
|
||||||
|
last_crawled_at: Date | null;
|
||||||
|
extra: {
|
||||||
|
title?: string;
|
||||||
|
feed_type?: NewsFeedType;
|
||||||
|
crawl_type?: NewsFeedCrawlType;
|
||||||
|
};
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewsFeedArticle {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
feed_id: string;
|
||||||
|
article_url: string;
|
||||||
|
article_title: string;
|
||||||
|
article_summary: string;
|
||||||
|
article_date: Date;
|
||||||
|
is_read: boolean;
|
||||||
|
extra: Record<never, never>;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: I don't really organize contacts by groups or address books, so I don't think I'll need that complexity
|
||||||
|
export interface Contact {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
revision: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
extra: {
|
||||||
|
name_title?: string;
|
||||||
|
middle_names?: string[];
|
||||||
|
organization?: string;
|
||||||
|
role?: string;
|
||||||
|
photo_url?: string;
|
||||||
|
photo_mediatype?: string;
|
||||||
|
addresses?: ContactAddress[];
|
||||||
|
fields?: ContactField[];
|
||||||
|
notes?: string;
|
||||||
|
uid?: string;
|
||||||
|
nickname?: string;
|
||||||
|
birthday?: string;
|
||||||
|
};
|
||||||
|
updated_at: Date;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactAddress {
|
||||||
|
label?: string;
|
||||||
|
line_1?: string;
|
||||||
|
line_2?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
postal_code?: string;
|
||||||
|
country?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContactFieldType = 'email' | 'phone' | 'url' | 'other';
|
||||||
|
|
||||||
|
export interface ContactField {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
type: ContactFieldType;
|
||||||
|
}
|
||||||
616
lib/utils.ts
Normal file
@@ -0,0 +1,616 @@
|
|||||||
|
import { Contact, ContactAddress, ContactField } from './types.ts';
|
||||||
|
|
||||||
|
export const baseUrl = 'https://app.bewcloud.com';
|
||||||
|
export const defaultTitle = 'bewCloud is a modern and simpler alternative to Nextcloud and ownCloud';
|
||||||
|
export const defaultDescription = `Have your calendar, contacts, tasks, and files under your own control.`;
|
||||||
|
export const helpEmail = 'help@bewcloud.com';
|
||||||
|
|
||||||
|
export const CONTACTS_PER_PAGE_COUNT = 20;
|
||||||
|
|
||||||
|
export const DAV_RESPONSE_HEADER = '1, 3, 4, addressbook';
|
||||||
|
// '1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, oc-resource-sharing, addressbook, nextcloud-checksum-update, nc-calendar-search, nc-enable-birthday-calendar'
|
||||||
|
// '1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, calendar-access, calendar-proxy, calendar-auto-schedule, calendar-availability, nc-calendar-trashbin, nc-calendar-webcal-cache, calendarserver-subscribed, oc-resource-sharing, oc-calendar-publishing, calendarserver-sharing, addressbook, nextcloud-checksum-update, nc-calendar-search, nc-enable-birthday-calendar
|
||||||
|
|
||||||
|
export function isRunningLocally(request: Request) {
|
||||||
|
return request.url.includes('localhost');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(unsafe: string) {
|
||||||
|
return unsafe.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeXml(unsafe: string) {
|
||||||
|
return escapeHtml(unsafe).replaceAll('\r', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRandomCode(length = 6) {
|
||||||
|
const getRandomDigit = () => Math.floor(Math.random() * (10)); // 0-9
|
||||||
|
|
||||||
|
const codeDigits = Array.from({ length }).map(getRandomDigit);
|
||||||
|
|
||||||
|
return codeDigits.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateHash(value: string, algorithm: AlgorithmIdentifier) {
|
||||||
|
const hashedValueData = await crypto.subtle.digest(
|
||||||
|
algorithm,
|
||||||
|
new TextEncoder().encode(value),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hashedValue = Array.from(new Uint8Array(hashedValueData)).map(
|
||||||
|
(byte) => byte.toString(16).padStart(2, '0'),
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
return hashedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitArrayInChunks<T = any>(array: T[], chunkLength: number) {
|
||||||
|
const chunks = [];
|
||||||
|
let chunkIndex = 0;
|
||||||
|
const arrayLength = array.length;
|
||||||
|
|
||||||
|
while (chunkIndex < arrayLength) {
|
||||||
|
chunks.push(array.slice(chunkIndex, chunkIndex += chunkLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEmail(email: string) {
|
||||||
|
const trimmedEmail = (email || '').trim().toLocaleLowerCase();
|
||||||
|
if (!trimmedEmail) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredCharsNotInEdges = ['@', '.'];
|
||||||
|
return requiredCharsNotInEdges.every((char) =>
|
||||||
|
trimmedEmail.includes(char) && !trimmedEmail.startsWith(char) && !trimmedEmail.endsWith(char)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateUrl(url: string) {
|
||||||
|
const trimmedUrl = (url || '').trim().toLocaleLowerCase();
|
||||||
|
if (!trimmedUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmedUrl.includes('://')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocolIndex = trimmedUrl.indexOf('://');
|
||||||
|
const urlAfterProtocol = trimmedUrl.substring(protocolIndex + 3);
|
||||||
|
|
||||||
|
if (!urlAfterProtocol) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adapted from https://gist.github.com/fasiha/7f20043a12ce93401d8473aee037d90a
|
||||||
|
export async function concurrentPromises<T>(
|
||||||
|
generators: (() => Promise<T>)[],
|
||||||
|
maxConcurrency: number,
|
||||||
|
): Promise<T[]> {
|
||||||
|
const iterator = generators.entries();
|
||||||
|
|
||||||
|
const results: T[] = [];
|
||||||
|
|
||||||
|
let hasFailed = false;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(Array(maxConcurrency), async () => {
|
||||||
|
for (const [index, promiseToExecute] of iterator) {
|
||||||
|
if (hasFailed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
results[index] = await promiseToExecute();
|
||||||
|
} catch (error) {
|
||||||
|
hasFailed = true;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RESPONSE_TIME_IN_MS = 10 * 1000;
|
||||||
|
|
||||||
|
export async function fetchUrl(url: string) {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const requestCancelTimeout = setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, MAX_RESPONSE_TIME_IN_MS);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requestCancelTimeout) {
|
||||||
|
clearTimeout(requestCancelTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlContents = await response.text();
|
||||||
|
return urlContents;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUrlAsGooglebot(url: string) {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const requestCancelTimeout = setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, MAX_RESPONSE_TIME_IN_MS);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
|
||||||
|
},
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requestCancelTimeout) {
|
||||||
|
clearTimeout(requestCancelTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlContents = await response.text();
|
||||||
|
return urlContents;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUrlWithProxy(url: string) {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const requestCancelTimeout = setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, MAX_RESPONSE_TIME_IN_MS);
|
||||||
|
|
||||||
|
const response = await fetch(`https://api.allorigins.win/raw?url=${url}`, {
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requestCancelTimeout) {
|
||||||
|
clearTimeout(requestCancelTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlContents = await response.text();
|
||||||
|
return urlContents;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUrlWithRetries(url: string) {
|
||||||
|
try {
|
||||||
|
const text = await fetchUrl(url);
|
||||||
|
return text;
|
||||||
|
} catch (_error) {
|
||||||
|
try {
|
||||||
|
const text = await fetchUrlAsGooglebot(url);
|
||||||
|
return text;
|
||||||
|
} catch (_error) {
|
||||||
|
const text = await fetchUrlWithProxy(url);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertFormDataToObject(formData: FormData): Record<string, any> {
|
||||||
|
return JSON.parse(JSON.stringify(Object.fromEntries(formData)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertObjectToFormData(formDataObject: Record<string, any>): FormData {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
for (const key of Object.keys(formDataObject || {})) {
|
||||||
|
if (Array.isArray(formDataObject[key])) {
|
||||||
|
formData.append(key, formDataObject[key].join(','));
|
||||||
|
} else {
|
||||||
|
formData.append(key, formDataObject[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeXmlTag(tagName: string, value: any, attributes?: Record<string, any>) {
|
||||||
|
const attributesXml = attributes
|
||||||
|
? Object.keys(attributes || {}).map((attributeKey) => `${attributeKey}="${escapeHtml(attributes[attributeKey])}"`)
|
||||||
|
.join(' ')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) {
|
||||||
|
return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''} />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const xmlLines: string[] = [];
|
||||||
|
|
||||||
|
for (const valueItem of value) {
|
||||||
|
xmlLines.push(writeXmlTag(tagName, valueItem));
|
||||||
|
}
|
||||||
|
|
||||||
|
return xmlLines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
if (Object.keys(value).length === 0) {
|
||||||
|
return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''} />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''}>${convertObjectToDavXml(value)}</${tagName}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''}>${value}</${tagName}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertObjectToDavXml(davObject: Record<string, any>, isInitial = false): string {
|
||||||
|
const xmlLines: string[] = [];
|
||||||
|
|
||||||
|
if (isInitial) {
|
||||||
|
xmlLines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(davObject)) {
|
||||||
|
if (key.endsWith('_attributes')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
xmlLines.push(writeXmlTag(key, davObject[key], davObject[`${key}_attributes`]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return xmlLines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLeadingZero(number: number) {
|
||||||
|
if (number < 10) {
|
||||||
|
return `0${number}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return number.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRFC822Date(dateString: string) {
|
||||||
|
const dayStrings = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
const monthStrings = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
|
const timeStamp = Date.parse(dateString);
|
||||||
|
const date = new Date(timeStamp);
|
||||||
|
|
||||||
|
const day = dayStrings[date.getDay()];
|
||||||
|
const dayNumber = addLeadingZero(date.getUTCDate());
|
||||||
|
const month = monthStrings[date.getUTCMonth()];
|
||||||
|
const year = date.getUTCFullYear();
|
||||||
|
const time = `${addLeadingZero(date.getUTCHours())}:${addLeadingZero(date.getUTCMinutes())}:00`;
|
||||||
|
|
||||||
|
return `${day}, ${dayNumber} ${month} ${year} ${time} +0000`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatContactToVCard(contacts: Contact[]): string {
|
||||||
|
const vCardText = contacts.map((contact) =>
|
||||||
|
`BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
N:${contact.last_name};${contact.first_name};${
|
||||||
|
contact.extra.middle_names ? contact.extra.middle_names?.map((name) => name.trim()).filter(Boolean).join(' ') : ''
|
||||||
|
};${contact.extra.name_title || ''};
|
||||||
|
FN:${contact.extra.name_title ? `${contact.extra.name_title || ''} ` : ''}${contact.first_name} ${contact.last_name}
|
||||||
|
${contact.extra.organization ? `ORG:${contact.extra.organization.replaceAll(',', '\\,')}` : ''}
|
||||||
|
${contact.extra.role ? `TITLE:${contact.extra.role}` : ''}
|
||||||
|
${contact.extra.birthday ? `BDAY:${contact.extra.birthday}` : ''}
|
||||||
|
${contact.extra.nickname ? `NICKNAME:${contact.extra.nickname}` : ''}
|
||||||
|
${contact.extra.photo_url ? `PHOTO;MEDIATYPE=${contact.extra.photo_mediatype}:${contact.extra.photo_url}` : ''}
|
||||||
|
${
|
||||||
|
contact.extra.fields?.filter((field) => field.type === 'phone').map((phone) =>
|
||||||
|
`TEL;TYPE=${phone.name}:${phone.value}`
|
||||||
|
).join('\n') || ''
|
||||||
|
}
|
||||||
|
${
|
||||||
|
contact.extra.addresses?.map((address) =>
|
||||||
|
`ADR;TYPE=${address.label}:${(address.line_2 || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${
|
||||||
|
(address.line_1 || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')
|
||||||
|
};${(address.city || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${
|
||||||
|
(address.state || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')
|
||||||
|
};${(address.postal_code || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${
|
||||||
|
(address.country || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')
|
||||||
|
}`
|
||||||
|
).join('\n') || ''
|
||||||
|
}
|
||||||
|
${
|
||||||
|
contact.extra.fields?.filter((field) => field.type === 'email').map((email) =>
|
||||||
|
`EMAIL;TYPE=${email.name}:${email.value}`
|
||||||
|
).join('\n') || ''
|
||||||
|
}
|
||||||
|
REV:${new Date(contact.updated_at).toISOString()}
|
||||||
|
${
|
||||||
|
contact.extra.fields?.filter((field) => field.type === 'other').map((other) => `x-${other.name}:${other.value}`)
|
||||||
|
.join('\n') || ''
|
||||||
|
}
|
||||||
|
${
|
||||||
|
contact.extra.notes
|
||||||
|
? `NOTE:${contact.extra.notes.replaceAll('\r', '').replaceAll('\n', '\\n').replaceAll(',', '\\,')}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
${contact.extra.uid ? `UID:${contact.extra.uid}` : ''}
|
||||||
|
END:VCARD`
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
return vCardText.split('\n').map((line) => line.trim()).filter(Boolean).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
type VCardVersion = '2.1' | '3.0' | '4.0';
|
||||||
|
|
||||||
|
export function parseVCardFromTextContents(text: string): Partial<Contact>[] {
|
||||||
|
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
const partialContacts: Partial<Contact>[] = [];
|
||||||
|
|
||||||
|
let partialContact: Partial<Contact> = {};
|
||||||
|
let vCardVersion: VCardVersion = '2.1';
|
||||||
|
|
||||||
|
// Loop through every line
|
||||||
|
for (const line of lines) {
|
||||||
|
// Start new contact and vCard version
|
||||||
|
if (line.startsWith('BEGIN:VCARD')) {
|
||||||
|
partialContact = {};
|
||||||
|
vCardVersion = '2.1';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish contact
|
||||||
|
if (line.startsWith('END:VCARD')) {
|
||||||
|
partialContacts.push(partialContact);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select proper vCard version
|
||||||
|
if (line.startsWith('VERSION:')) {
|
||||||
|
if (line.startsWith('VERSION:2.1')) {
|
||||||
|
vCardVersion = '2.1';
|
||||||
|
} else if (line.startsWith('VERSION:3.0')) {
|
||||||
|
vCardVersion = '3.0';
|
||||||
|
} else if (line.startsWith('VERSION:4.0')) {
|
||||||
|
vCardVersion = '4.0';
|
||||||
|
} else {
|
||||||
|
// Default to 2.1, log warning
|
||||||
|
vCardVersion = '2.1';
|
||||||
|
console.warn(`Invalid vCard version found: "${line}". Defaulting to 2.1 parser.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vCardVersion !== '2.1' && vCardVersion !== '3.0' && vCardVersion !== '4.0') {
|
||||||
|
vCardVersion = '2.1';
|
||||||
|
console.warn(`Invalid vCard version found: "${vCardVersion}". Defaulting to 2.1 parser.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('UID:')) {
|
||||||
|
const uid = line.replace('UID:', '');
|
||||||
|
|
||||||
|
if (!uid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
partialContact.extra = {
|
||||||
|
...(partialContact.extra || {}),
|
||||||
|
uid,
|
||||||
|
};
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('N:')) {
|
||||||
|
const names = line.split('N:')[1].split(';');
|
||||||
|
|
||||||
|
const lastName = names[0] || '';
|
||||||
|
const firstName = names[1] || '';
|
||||||
|
const middleNames = names.slice(2, -1).filter(Boolean);
|
||||||
|
const title = names.slice(-1).join(' ') || '';
|
||||||
|
|
||||||
|
if (!firstName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
partialContact.first_name = firstName;
|
||||||
|
partialContact.last_name = lastName;
|
||||||
|
partialContact.extra = {
|
||||||
|
...(partialContact.extra || {}),
|
||||||
|
middle_names: middleNames,
|
||||||
|
name_title: title,
|
||||||
|
};
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('ORG:')) {
|
||||||
|
const organization = ((line.split('ORG:')[1] || '').split(';').join(' ') || '').replaceAll('\\,', ',');
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
partialContact.extra = {
|
||||||
|
...(partialContact.extra || {}),
|
||||||
|
organization,
|
||||||
|
};
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('BDAY:')) {
|
||||||
|
const birthday = line.split('BDAY:')[1] || '';
|
||||||
|
|
||||||
|
partialContact.extra = {
|
||||||
|
...(partialContact.extra || {}),
|
||||||
|
birthday,
|
||||||
|
};
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('NICKNAME:')) {
|
||||||
|
const nickname = (line.split('NICKNAME:')[1] || '').split(';').join(' ') || '';
|
||||||
|
|
||||||
|
if (!nickname) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
partialContact.extra = {
|
||||||
|
...(partialContact.extra || {}),
|
||||||
|
nickname,
|
||||||
|
};
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('TITLE:')) {
|
||||||
|
const role = line.split('TITLE:')[1] || '';
|
||||||
|
|
||||||
|
partialContact.extra = {
|
||||||
|
...(partialContact.extra || {}),
|
||||||
|
role,
|
||||||
|
};
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('NOTE:')) {
|
||||||
|
const notes = (line.split('NOTE:')[1] || '').replaceAll('\\n', '\n').replaceAll('\\,', ',');
|
||||||
|
|
||||||
|
partialContact.extra = {
|
||||||
|
...(partialContact.extra || {}),
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.includes('ADR;')) {
|
||||||
|
const addressInfo = line.split('ADR;')[1] || '';
|
||||||
|
const addressParts = (addressInfo.split(':')[1] || '').split(';');
|
||||||
|
const country = addressParts.slice(-1, addressParts.length).join(' ').replaceAll('\\n', '\n').replaceAll(
|
||||||
|
'\\,',
|
||||||
|
',',
|
||||||
|
);
|
||||||
|
const postalCode = addressParts.slice(-2, addressParts.length - 1).join(' ').replaceAll('\\n', '\n').replaceAll(
|
||||||
|
'\\,',
|
||||||
|
',',
|
||||||
|
);
|
||||||
|
const state = addressParts.slice(-3, addressParts.length - 2).join(' ').replaceAll('\\n', '\n').replaceAll(
|
||||||
|
'\\,',
|
||||||
|
',',
|
||||||
|
);
|
||||||
|
const city = addressParts.slice(-4, addressParts.length - 3).join(' ').replaceAll('\\n', '\n').replaceAll(
|
||||||
|
'\\,',
|
||||||
|
',',
|
||||||
|
);
|
||||||
|
const line1 = addressParts.slice(-5, addressParts.length - 4).join(' ').replaceAll('\\n', '\n').replaceAll(
|
||||||
|
'\\,',
|
||||||
|
',',
|
||||||
|
);
|
||||||
|
const line2 = addressParts.slice(-6, addressParts.length - 5).join(' ').replaceAll('\\n', '\n').replaceAll(
|
||||||
|
'\\,',
|
||||||
|
',',
|
||||||
|
);
|
||||||
|
|
||||||
|
const label = ((addressInfo.split(':')[0] || '').split('TYPE=')[1] || 'home').replaceAll(';', '').replaceAll(
|
||||||
|
'\\n',
|
||||||
|
'\n',
|
||||||
|
).replaceAll('\\,', ',');
|
||||||
|
|
||||||
|
if (!country && !postalCode && !state && !city && !line2 && !line1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const address: ContactAddress = {
|
||||||
|
label,
|
||||||
|
line_1: line1,
|
||||||
|
line_2: line2,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
postal_code: postalCode,
|
||||||
|
country,
|
||||||
|
};
|
||||||
|
|
||||||
|
partialContact.extra = {
|
||||||
|
...(partialContact.extra || {}),
|
||||||
|
addresses: [...(partialContact.extra?.addresses || []), address],
|
||||||
|
};
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.includes('PHOTO;')) {
|
||||||
|
const photoInfo = line.split('PHOTO;')[1] || '';
|
||||||
|
const photoUrl = photoInfo.split(':')[1];
|
||||||
|
const photoMediaTypeInfo = photoInfo.split(':')[0];
|
||||||
|
let photoMediaType = photoMediaTypeInfo.split('TYPE=')[1] || '';
|
||||||
|
|
||||||
|
if (!photoMediaType) {
|
||||||
|
photoMediaType = 'image/jpeg';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!photoMediaType.startsWith('image/')) {
|
||||||
|
photoMediaType = `image/${photoMediaType.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!photoUrl) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
partialContact.extra = {
|
||||||
|
...(partialContact.extra || {}),
|
||||||
|
photo_mediatype: photoMediaType,
|
||||||
|
photo_url: photoUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.includes('TEL;')) {
|
||||||
|
const phoneInfo = line.split('TEL;')[1] || '';
|
||||||
|
const phoneNumber = phoneInfo.split(':')[1] || '';
|
||||||
|
const name = (phoneInfo.split(':')[0].split('TYPE=')[1] || 'home').replaceAll(';', '');
|
||||||
|
|
||||||
|
if (!phoneNumber) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const field: ContactField = {
|
||||||
|
name,
|
||||||
|
value: phoneNumber,
|
||||||
|
type: 'phone',
|
||||||
|
};
|
||||||
|
|
||||||
|
partialContact.extra = {
|
||||||
|
...(partialContact.extra || {}),
|
||||||
|
fields: [...(partialContact.extra?.fields || []), field],
|
||||||
|
};
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.includes('EMAIL;')) {
|
||||||
|
const emailInfo = line.split('EMAIL;')[1] || '';
|
||||||
|
const emailAddress = emailInfo.split(':')[1] || '';
|
||||||
|
const name = (emailInfo.split(':')[0].split('TYPE=')[1] || 'home').replaceAll(';', '');
|
||||||
|
|
||||||
|
if (!emailAddress) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const field: ContactField = {
|
||||||
|
name,
|
||||||
|
value: emailAddress,
|
||||||
|
type: 'email',
|
||||||
|
};
|
||||||
|
|
||||||
|
partialContact.extra = {
|
||||||
|
...(partialContact.extra || {}),
|
||||||
|
fields: [...(partialContact.extra?.fields || []), field],
|
||||||
|
};
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return partialContacts;
|
||||||
|
}
|
||||||
294
lib/utils_test.ts
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import { assertEquals } from 'std/assert/assert_equals.ts';
|
||||||
|
import {
|
||||||
|
convertFormDataToObject,
|
||||||
|
convertObjectToDavXml,
|
||||||
|
convertObjectToFormData,
|
||||||
|
escapeHtml,
|
||||||
|
generateHash,
|
||||||
|
generateRandomCode,
|
||||||
|
splitArrayInChunks,
|
||||||
|
validateEmail,
|
||||||
|
validateUrl,
|
||||||
|
} from './utils.ts';
|
||||||
|
|
||||||
|
Deno.test('that escapeHtml works', () => {
|
||||||
|
const tests: { input: string; expected: string }[] = [
|
||||||
|
{
|
||||||
|
input: '<a href="https://brunobernardino.com">URL</a>',
|
||||||
|
expected: '<a href="https://brunobernardino.com">URL</a>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "\"><img onerror='alert(1)' />",
|
||||||
|
expected: '"><img onerror='alert(1)' />',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
const output = escapeHtml(test.input);
|
||||||
|
assertEquals(output, test.expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('that generateRandomCode works', () => {
|
||||||
|
const tests: { length: number }[] = [
|
||||||
|
{
|
||||||
|
length: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
length: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
length: 8,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
const output = generateRandomCode(test.length);
|
||||||
|
assertEquals(output.length, test.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('that splitArrayInChunks works', () => {
|
||||||
|
const tests: { input: { array: { number: number }[]; chunkLength: number }; expected: { number: number }[][] }[] = [
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
array: [
|
||||||
|
{ number: 1 },
|
||||||
|
{ number: 2 },
|
||||||
|
{ number: 3 },
|
||||||
|
{ number: 4 },
|
||||||
|
{ number: 5 },
|
||||||
|
{ number: 6 },
|
||||||
|
],
|
||||||
|
chunkLength: 2,
|
||||||
|
},
|
||||||
|
expected: [
|
||||||
|
[{ number: 1 }, { number: 2 }],
|
||||||
|
[{ number: 3 }, { number: 4 }],
|
||||||
|
[{ number: 5 }, { number: 6 }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
array: [
|
||||||
|
{ number: 1 },
|
||||||
|
{ number: 2 },
|
||||||
|
{ number: 3 },
|
||||||
|
{ number: 4 },
|
||||||
|
{ number: 5 },
|
||||||
|
],
|
||||||
|
chunkLength: 2,
|
||||||
|
},
|
||||||
|
expected: [
|
||||||
|
[{ number: 1 }, { number: 2 }],
|
||||||
|
[{ number: 3 }, { number: 4 }],
|
||||||
|
[{ number: 5 }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
array: [
|
||||||
|
{ number: 1 },
|
||||||
|
{ number: 2 },
|
||||||
|
{ number: 3 },
|
||||||
|
{ number: 4 },
|
||||||
|
{ number: 5 },
|
||||||
|
{ number: 6 },
|
||||||
|
],
|
||||||
|
chunkLength: 3,
|
||||||
|
},
|
||||||
|
expected: [
|
||||||
|
[{ number: 1 }, { number: 2 }, { number: 3 }],
|
||||||
|
[{ number: 4 }, { number: 5 }, { number: 6 }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
const output = splitArrayInChunks(
|
||||||
|
test.input.array,
|
||||||
|
test.input.chunkLength,
|
||||||
|
);
|
||||||
|
assertEquals(output, test.expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('that generateHash works', async () => {
|
||||||
|
const tests: { input: { value: string; algorithm: string }; expected: string }[] = [
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
value: 'password',
|
||||||
|
algorithm: 'SHA-256',
|
||||||
|
},
|
||||||
|
expected: '5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
value: '123456',
|
||||||
|
algorithm: 'SHA-256',
|
||||||
|
},
|
||||||
|
expected: '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
const output = await generateHash(test.input.value, test.input.algorithm);
|
||||||
|
assertEquals(output, test.expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('that validateEmail works', () => {
|
||||||
|
const tests: { email: string; expected: boolean }[] = [
|
||||||
|
{ email: 'user@example.com', expected: true },
|
||||||
|
{ email: 'u@e.c', expected: true },
|
||||||
|
{ email: 'user@example.', expected: false },
|
||||||
|
{ email: '@example.com', expected: false },
|
||||||
|
{ email: 'user@example.', expected: false },
|
||||||
|
{ email: 'ABC', expected: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
const result = validateEmail(test.email);
|
||||||
|
assertEquals(result, test.expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('that validateUrl works', () => {
|
||||||
|
const tests: { url: string; expected: boolean }[] = [
|
||||||
|
{ url: 'https://bewcloud.com', expected: true },
|
||||||
|
{ url: 'ftp://something', expected: true },
|
||||||
|
{ url: 'http', expected: false },
|
||||||
|
{ url: 'https://', expected: false },
|
||||||
|
{ url: 'http://a', expected: true },
|
||||||
|
{ url: 'ABC', expected: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
const result = validateUrl(test.url);
|
||||||
|
assertEquals(result, test.expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('that convertFormDataToObject works', () => {
|
||||||
|
const formData1 = new FormData();
|
||||||
|
formData1.append('user', '1');
|
||||||
|
formData1.append('is_real', 'false');
|
||||||
|
formData1.append('tags', 'one');
|
||||||
|
formData1.append('tags', 'two');
|
||||||
|
|
||||||
|
const formData2 = new FormData();
|
||||||
|
formData2.append('user', '2');
|
||||||
|
formData2.append('is_real', 'true');
|
||||||
|
formData2.append('tags', 'one');
|
||||||
|
formData2.append('empty', '');
|
||||||
|
|
||||||
|
const tests: { input: FormData; expected: Record<string, any> }[] = [
|
||||||
|
{
|
||||||
|
input: formData1,
|
||||||
|
expected: {
|
||||||
|
user: '1',
|
||||||
|
is_real: 'false',
|
||||||
|
// tags: ['one', 'two'],
|
||||||
|
tags: 'two', // NOTE: This is a limitation of the simple logic, but it should ideally be the array above
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: formData2,
|
||||||
|
expected: {
|
||||||
|
user: '2',
|
||||||
|
is_real: 'true',
|
||||||
|
tags: 'one',
|
||||||
|
empty: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
const output = convertFormDataToObject(test.input);
|
||||||
|
assertEquals(output, test.expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('that convertObjectToFormData works', () => {
|
||||||
|
const formData1 = new FormData();
|
||||||
|
formData1.append('user', '1');
|
||||||
|
formData1.append('is_real', 'false');
|
||||||
|
formData1.append('tags', 'one');
|
||||||
|
// formData1.append('tags', 'two');// NOTE: This is a limitation of the simple logic, but it should ideally be an array below
|
||||||
|
|
||||||
|
const formData2 = new FormData();
|
||||||
|
formData2.append('user', '2');
|
||||||
|
formData2.append('is_real', 'true');
|
||||||
|
formData2.append('tags', 'one');
|
||||||
|
formData2.append('empty', '');
|
||||||
|
|
||||||
|
const tests: { input: Record<string, any>; expected: FormData }[] = [
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
user: '1',
|
||||||
|
is_real: 'false',
|
||||||
|
tags: 'one',
|
||||||
|
},
|
||||||
|
expected: formData1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
user: '2',
|
||||||
|
is_real: 'true',
|
||||||
|
tags: 'one',
|
||||||
|
empty: '',
|
||||||
|
},
|
||||||
|
expected: formData2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
const output = convertObjectToFormData(test.input);
|
||||||
|
assertEquals(convertFormDataToObject(output), convertFormDataToObject(test.expected));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('that convertObjectToDavXml works', () => {
|
||||||
|
const tests: { input: Record<string, any>; expected: string }[] = [
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
url: 'https://bewcloud.com',
|
||||||
|
},
|
||||||
|
expected: `<url>https://bewcloud.com</url>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
a: 'Website',
|
||||||
|
a_attributes: {
|
||||||
|
href: 'https://bewcloud.com',
|
||||||
|
target: '_blank',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: `<a href="https://bewcloud.com" target="_blank">Website</a>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: {
|
||||||
|
article: {
|
||||||
|
p: [
|
||||||
|
{
|
||||||
|
strong: 'Indeed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
i: {},
|
||||||
|
},
|
||||||
|
'Mighty!',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
article_attributes: {
|
||||||
|
class: 'center',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: `<article class="center"><p><strong>Indeed</strong></p>\n<p><i /></p>\n<p>Mighty!</p></article>`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
const result = convertObjectToDavXml(test.input);
|
||||||
|
assertEquals(result, test.expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
13
main.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/// <reference no-default-lib="true" />
|
||||||
|
/// <reference lib="dom" />
|
||||||
|
/// <reference lib="dom.iterable" />
|
||||||
|
/// <reference lib="dom.asynciterable" />
|
||||||
|
/// <reference lib="deno.ns" />
|
||||||
|
|
||||||
|
import 'std/dotenv/load.ts';
|
||||||
|
|
||||||
|
import { start } from 'fresh/server.ts';
|
||||||
|
import manifest from './fresh.gen.ts';
|
||||||
|
import config from './fresh.config.ts';
|
||||||
|
|
||||||
|
await start(manifest, config);
|
||||||
44
main_test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { assert } from 'std/assert/assert.ts';
|
||||||
|
import { assertEquals } from 'std/assert/assert_equals.ts';
|
||||||
|
import { createHandler, ServeHandlerInfo } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import manifest from './fresh.gen.ts';
|
||||||
|
import config from './fresh.config.ts';
|
||||||
|
|
||||||
|
const CONN_INFO: ServeHandlerInfo = {
|
||||||
|
remoteAddr: { hostname: '127.0.0.1', port: 53496, transport: 'tcp' },
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test('Basic routes', async (testContext) => {
|
||||||
|
const handler = await createHandler(manifest, config);
|
||||||
|
|
||||||
|
await testContext.step('#1 GET /', async () => {
|
||||||
|
const response = await handler(new Request('http://127.0.0.1/'), CONN_INFO);
|
||||||
|
assertEquals(response.status, 303);
|
||||||
|
});
|
||||||
|
|
||||||
|
await testContext.step('#2 GET /login', async () => {
|
||||||
|
const response = await handler(new Request('http://127.0.0.1/login'), CONN_INFO);
|
||||||
|
const text = await response.text();
|
||||||
|
assert(text.includes('bewCloud'));
|
||||||
|
assertEquals(response.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await testContext.step('#3 GET /blah', async () => {
|
||||||
|
const response = await handler(new Request('http://127.0.0.1/blah'), CONN_INFO);
|
||||||
|
const text = await response.text();
|
||||||
|
assert(text.includes('404 - Page not found'));
|
||||||
|
assertEquals(response.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
// await testContext.step('#4 POST /', async () => {
|
||||||
|
// const formData = new FormData();
|
||||||
|
// formData.append('text', 'Deno!');
|
||||||
|
// const request = new Request('http://127.0.0.1/', {
|
||||||
|
// method: 'POST',
|
||||||
|
// body: formData,
|
||||||
|
// });
|
||||||
|
// const response = await handler(request, CONN_INFO);
|
||||||
|
// assertEquals(response.status, 303);
|
||||||
|
// });
|
||||||
|
});
|
||||||
90
migrate-db.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import 'std/dotenv/load.ts';
|
||||||
|
|
||||||
|
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||||
|
|
||||||
|
const migrationsDirectoryPath = `${Deno.cwd()}/db-migrations`;
|
||||||
|
|
||||||
|
const migrationsDirectory = Deno.readDir(migrationsDirectoryPath);
|
||||||
|
|
||||||
|
const db = new Database();
|
||||||
|
|
||||||
|
interface Migration {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
executed_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getExecutedMigrations() {
|
||||||
|
const executedMigrations = new Set(
|
||||||
|
Array.from(
|
||||||
|
(await db.query<Migration>(sql`SELECT * FROM "bewcloud_migrations" ORDER BY "name" ASC`)).map((migration) =>
|
||||||
|
migration.name
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return executedMigrations;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMissingMigrations() {
|
||||||
|
const existingMigrations: Set<string> = new Set();
|
||||||
|
|
||||||
|
for await (const migrationFile of migrationsDirectory) {
|
||||||
|
// Skip non-files
|
||||||
|
if (!migrationFile.isFile) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip files not in the "001-blah.pgsql" format
|
||||||
|
if (!migrationFile.name.match(/^\d+-.*(\.pgsql)$/)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
existingMigrations.add(migrationFile.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add everything to run, by default
|
||||||
|
const migrationsToExecute = new Set([...existingMigrations]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const executedMigrations = await getExecutedMigrations();
|
||||||
|
|
||||||
|
// Remove any existing migrations that were executed, from the list of migrations to execute
|
||||||
|
for (const executedMigration of executedMigrations) {
|
||||||
|
migrationsToExecute.delete(executedMigration);
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// The table likely doesn't exist, so run everything.
|
||||||
|
}
|
||||||
|
|
||||||
|
return migrationsToExecute;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMigrations(missingMigrations: Set<string>) {
|
||||||
|
for (const missingMigration of missingMigrations) {
|
||||||
|
console.log(`Running "${missingMigration}"...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const migrationSql = await Deno.readTextFile(`${migrationsDirectoryPath}/${missingMigration}`);
|
||||||
|
|
||||||
|
await db.query(migrationSql);
|
||||||
|
|
||||||
|
await db.query(sql`INSERT INTO "public"."bewcloud_migrations" ("name", "executed_at") VALUES ($1, NOW())`, [
|
||||||
|
missingMigration,
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('Success!');
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed!');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingMigrations = await getMissingMigrations();
|
||||||
|
|
||||||
|
await runMigrations(missingMigrations);
|
||||||
|
|
||||||
|
if (missingMigrations.size === 0) {
|
||||||
|
console.log('No migrations to run!');
|
||||||
|
}
|
||||||
7
routes/.well-known/carddav.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Nextcloud/ownCloud mimicry
|
||||||
|
export function handler(): Response {
|
||||||
|
return new Response('Redirecting...', {
|
||||||
|
status: 307,
|
||||||
|
headers: { Location: '/dav' },
|
||||||
|
});
|
||||||
|
}
|
||||||
38
routes/_404.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Head } from 'fresh/runtime.ts';
|
||||||
|
import { PageProps } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export default function Error404({ state }: PageProps<Data, FreshContextState>) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>404 - Page not found</title>
|
||||||
|
</Head>
|
||||||
|
<main>
|
||||||
|
<section class='max-w-screen-md mx-auto flex flex-col items-center justify-center'>
|
||||||
|
{!state.user
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
class='my-6'
|
||||||
|
src='/images/logo-white.svg'
|
||||||
|
width='250'
|
||||||
|
height='50'
|
||||||
|
alt='the bewCloud logo: a stylized logo'
|
||||||
|
/>
|
||||||
|
<h1>404 - Page not found</h1>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
<p class='my-4'>
|
||||||
|
The page you were looking for doesn"t exist.
|
||||||
|
</p>
|
||||||
|
<a href='/'>Go back home</a>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
routes/_app.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { PageProps } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import { defaultDescription, defaultTitle } from '/lib/utils.ts';
|
||||||
|
import Header from '/components/Header.tsx';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export default function App({ route, Component, state }: PageProps<Data, FreshContextState>) {
|
||||||
|
return (
|
||||||
|
<html class='h-full bg-slate-800'>
|
||||||
|
<head>
|
||||||
|
<meta charset='utf-8' />
|
||||||
|
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
|
||||||
|
<title>{defaultTitle}</title>
|
||||||
|
<meta name='description' content={defaultDescription} />
|
||||||
|
<meta name='author' content='Bruno Bernardino' />
|
||||||
|
<meta property='og:title' content={defaultTitle} />
|
||||||
|
<link rel='icon' href='/images/favicon.png' type='image/png' />
|
||||||
|
<link rel='apple-touch-icon' href='/images/favicon.png' />
|
||||||
|
<link rel='stylesheet' href='/styles.css' />
|
||||||
|
</head>
|
||||||
|
<body class='h-full'>
|
||||||
|
<Header route={route} user={state.user} />
|
||||||
|
<Component />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
routes/_middleware.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { FreshContext } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import { getDataFromRequest } from '/lib/auth.ts';
|
||||||
|
|
||||||
|
export const handler = [
|
||||||
|
async function handleCors(request: Request, context: FreshContext<FreshContextState>) {
|
||||||
|
if (request.method == 'OPTIONS') {
|
||||||
|
const response = new Response(null, {
|
||||||
|
status: 204,
|
||||||
|
});
|
||||||
|
const origin = request.headers.get('Origin') || '*';
|
||||||
|
const headers = response.headers;
|
||||||
|
headers.set('Access-Control-Allow-Origin', origin);
|
||||||
|
headers.set('Access-Control-Allow-Methods', 'DELETE');
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = request.headers.get('Origin') || '*';
|
||||||
|
const response = await context.next();
|
||||||
|
const headers = response.headers;
|
||||||
|
|
||||||
|
headers.set('Access-Control-Allow-Origin', origin);
|
||||||
|
headers.set('Access-Control-Allow-Credentials', 'true');
|
||||||
|
headers.set(
|
||||||
|
'Access-Control-Allow-Headers',
|
||||||
|
'Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With',
|
||||||
|
);
|
||||||
|
headers.set(
|
||||||
|
'Access-Control-Allow-Methods',
|
||||||
|
'POST, OPTIONS, GET, PUT, DELETE',
|
||||||
|
);
|
||||||
|
headers.set(
|
||||||
|
'Content-Security-Policy',
|
||||||
|
"default-src 'self'; child-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'",
|
||||||
|
);
|
||||||
|
headers.set('X-Frame-Options', 'DENY');
|
||||||
|
headers.set('X-Content-Type-Options', 'nosniff');
|
||||||
|
headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
async function handleContextState(request: Request, context: FreshContext<FreshContextState>) {
|
||||||
|
const { user, session } = (await getDataFromRequest(request)) || {};
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
context.state.user = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
context.state.session = session;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await context.next();
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
async function handleLogging(request: Request, context: FreshContext<FreshContextState>) {
|
||||||
|
const response = await context.next();
|
||||||
|
|
||||||
|
console.info(`${new Date().toISOString()} - ${request.method} ${request.url} [${response.status}]`);
|
||||||
|
if (request.url.includes('/dav/')) {
|
||||||
|
console.info(`Request`, request.headers);
|
||||||
|
console.info((await request.clone().text()) || '<No Body>');
|
||||||
|
console.info(`Response`, response.headers);
|
||||||
|
console.info(`Status`, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
];
|
||||||
41
routes/api/contacts/add.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { Contact, FreshContextState } from '/lib/types.ts';
|
||||||
|
import { createContact, getContacts } from '/lib/data/contacts.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
contacts: Pick<Contact, 'id' | 'first_name' | 'last_name'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (requestBody.firstName) {
|
||||||
|
const contact = await createContact(context.state.user.id, requestBody.firstName, requestBody.lastName);
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contacts = await getContacts(context.state.user.id, requestBody.page - 1);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, contacts };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
42
routes/api/contacts/delete.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { Contact, FreshContextState } from '/lib/types.ts';
|
||||||
|
import { deleteContact, getContact, getContacts } from '/lib/data/contacts.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
contactId: string;
|
||||||
|
page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
contacts: Pick<Contact, 'id' | 'first_name' | 'last_name'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (requestBody.contactId) {
|
||||||
|
const contact = await getContact(requestBody.contactId, context.state.user.id);
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteContact(requestBody.contactId, context.state.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contacts = await getContacts(context.state.user.id, requestBody.page - 1);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, contacts };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
27
routes/api/contacts/get.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { Contact, FreshContextState } from '/lib/types.ts';
|
||||||
|
import { getAllContacts } from '/lib/data/contacts.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
contacts: Contact[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contacts = await getAllContacts(context.state.user.id);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, contacts };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
60
routes/api/contacts/import.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { Contact, FreshContextState } from '/lib/types.ts';
|
||||||
|
import { concurrentPromises } from '/lib/utils.ts';
|
||||||
|
import { createContact, getContacts, updateContact } from '/lib/data/contacts.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
partialContacts: Partial<Contact>[];
|
||||||
|
page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
contacts: Pick<Contact, 'id' | 'first_name' | 'last_name'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (requestBody.partialContacts) {
|
||||||
|
if (requestBody.partialContacts.length === 0) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await concurrentPromises(
|
||||||
|
requestBody.partialContacts.map((partialContact) => async () => {
|
||||||
|
if (partialContact.first_name) {
|
||||||
|
const contact = await createContact(
|
||||||
|
context.state.user!.id,
|
||||||
|
partialContact.first_name,
|
||||||
|
partialContact.last_name || '',
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsedExtra = JSON.stringify(partialContact.extra || {});
|
||||||
|
|
||||||
|
if (parsedExtra !== '{}') {
|
||||||
|
contact.extra = partialContact.extra!;
|
||||||
|
|
||||||
|
await updateContact(contact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contacts = await getContacts(context.state.user.id, requestBody.page - 1);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, contacts };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
40
routes/api/dashboard/save-links.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { DashboardLink, FreshContextState } from '/lib/types.ts';
|
||||||
|
import { getDashboardByUserId, updateDashboard } from '/lib/data/dashboard.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
links: DashboardLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDashboard = await getDashboardByUserId(context.state.user.id);
|
||||||
|
|
||||||
|
if (!userDashboard) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (typeof requestBody.links !== 'undefined') {
|
||||||
|
userDashboard.data.links = requestBody.links;
|
||||||
|
|
||||||
|
await updateDashboard(userDashboard);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
40
routes/api/dashboard/save-notes.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import { getDashboardByUserId, updateDashboard } from '/lib/data/dashboard.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDashboard = await getDashboardByUserId(context.state.user.id);
|
||||||
|
|
||||||
|
if (!userDashboard) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (typeof requestBody.notes !== 'undefined' && userDashboard.data.notes !== requestBody.notes) {
|
||||||
|
userDashboard.data.notes = requestBody.notes;
|
||||||
|
|
||||||
|
await updateDashboard(userDashboard);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
42
routes/api/news/add-feed.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState, NewsFeed } from '/lib/types.ts';
|
||||||
|
import { createNewsFeed, getNewsFeeds } from '/lib/data/news.ts';
|
||||||
|
import { fetchNewArticles } from '/crons/news.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
feedUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
newFeeds: NewsFeed[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (requestBody.feedUrl) {
|
||||||
|
const newFeed = await createNewsFeed(context.state.user.id, requestBody.feedUrl);
|
||||||
|
|
||||||
|
if (!newFeed) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchNewArticles();
|
||||||
|
|
||||||
|
const newFeeds = await getNewsFeeds(context.state.user.id);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, newFeeds };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
41
routes/api/news/delete-feed.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState, NewsFeed } from '/lib/types.ts';
|
||||||
|
import { deleteNewsFeed, getNewsFeed, getNewsFeeds } from '/lib/data/news.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
feedId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
newFeeds: NewsFeed[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (requestBody.feedId) {
|
||||||
|
const newsFeed = await getNewsFeed(requestBody.feedId, context.state.user.id);
|
||||||
|
|
||||||
|
if (!newsFeed) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteNewsFeed(requestBody.feedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFeeds = await getNewsFeeds(context.state.user.id);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, newFeeds };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
46
routes/api/news/import-feeds.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState, NewsFeed } from '/lib/types.ts';
|
||||||
|
import { concurrentPromises } from '/lib/utils.ts';
|
||||||
|
import { createNewsFeed, getNewsFeeds } from '/lib/data/news.ts';
|
||||||
|
import { fetchNewArticles } from '/crons/news.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
feedUrls: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
newFeeds: NewsFeed[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (requestBody.feedUrls) {
|
||||||
|
if (requestBody.feedUrls.length === 0) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await concurrentPromises(
|
||||||
|
requestBody.feedUrls.map((feedUrl) => () => createNewsFeed(context.state.user!.id, feedUrl)),
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchNewArticles();
|
||||||
|
|
||||||
|
const newFeeds = await getNewsFeeds(context.state.user.id);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, newFeeds };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
44
routes/api/news/mark-read.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import { getNewsArticle, markAllArticlesRead, updateNewsArticle } from '/lib/data/news.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
articleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (requestBody.articleId) {
|
||||||
|
if (requestBody.articleId === 'all') {
|
||||||
|
await markAllArticlesRead(context.state.user.id);
|
||||||
|
} else {
|
||||||
|
const article = await getNewsArticle(requestBody.articleId, context.state.user.id);
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
article.is_read = true;
|
||||||
|
|
||||||
|
await updateNewsArticle(article);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
36
routes/api/news/refresh-articles.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState, NewsFeedArticle } from '/lib/types.ts';
|
||||||
|
import { getNewsArticles, getNewsFeeds } from '/lib/data/news.ts';
|
||||||
|
import { fetchNewArticles } from '/crons/news.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
newArticles: NewsFeedArticle[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newsFeeds = await getNewsFeeds(context.state.user.id);
|
||||||
|
|
||||||
|
if (!newsFeeds.length) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchNewArticles(true);
|
||||||
|
|
||||||
|
const newArticles = await getNewsArticles(context.state.user.id);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, newArticles };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
48
routes/contacts.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { Contact, FreshContextState } from '/lib/types.ts';
|
||||||
|
import { getContacts, getContactsCount, searchContacts, searchContactsCount } from '/lib/data/contacts.ts';
|
||||||
|
import Contacts from '/islands/contacts/Contacts.tsx';
|
||||||
|
|
||||||
|
interface Data {
|
||||||
|
userContacts: Pick<Contact, 'id' | 'first_name' | 'last_name'>[];
|
||||||
|
page: number;
|
||||||
|
contactsCount: number;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async GET(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = new URL(request.url).searchParams;
|
||||||
|
|
||||||
|
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||||
|
const search = searchParams.get('search') || undefined;
|
||||||
|
|
||||||
|
const userContacts = search
|
||||||
|
? await searchContacts(search, context.state.user.id, page - 1)
|
||||||
|
: await getContacts(context.state.user.id, page - 1);
|
||||||
|
|
||||||
|
const contactsCount = search
|
||||||
|
? await searchContactsCount(search, context.state.user.id)
|
||||||
|
: await getContactsCount(context.state.user.id);
|
||||||
|
|
||||||
|
return await context.render({ userContacts, page, contactsCount, search });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ContactsPage({ data }: PageProps<Data, FreshContextState>) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<Contacts
|
||||||
|
initialContacts={data?.userContacts || []}
|
||||||
|
page={data?.page || 1}
|
||||||
|
contactsCount={data?.contactsCount || 0}
|
||||||
|
search={data?.search || ''}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
routes/contacts/[contactId].tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { Contact, ContactAddress, ContactField, FreshContextState } from '/lib/types.ts';
|
||||||
|
import { convertFormDataToObject } from '/lib/utils.ts';
|
||||||
|
import { getContact, updateContact } from '/lib/data/contacts.ts';
|
||||||
|
import { getFormDataField, getFormDataFieldArray } from '/lib/form-utils.tsx';
|
||||||
|
import ViewContact, { formFields } from '/islands/contacts/ViewContact.tsx';
|
||||||
|
|
||||||
|
interface Data {
|
||||||
|
contact: Contact;
|
||||||
|
error?: string;
|
||||||
|
notice?: string;
|
||||||
|
formData: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async GET(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { contactId } = context.params;
|
||||||
|
|
||||||
|
const contact = await getContact(contactId, context.state.user.id);
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return await context.render({ contact, formData: {} });
|
||||||
|
},
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { contactId } = context.params;
|
||||||
|
|
||||||
|
const contact = await getContact(contactId, context.state.user.id);
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
contact.extra.name_title = getFormDataField(formData, 'name_title') || undefined;
|
||||||
|
contact.first_name = getFormDataField(formData, 'first_name');
|
||||||
|
contact.last_name = getFormDataField(formData, 'last_name');
|
||||||
|
contact.extra.middle_names = getFormDataField(formData, 'middle_names').split(' ').map((name) =>
|
||||||
|
(name || '').trim()
|
||||||
|
).filter(Boolean);
|
||||||
|
if (contact.extra.middle_names.length === 0) {
|
||||||
|
contact.extra.middle_names = undefined;
|
||||||
|
}
|
||||||
|
contact.extra.birthday = getFormDataField(formData, 'birthday') || undefined;
|
||||||
|
contact.extra.nickname = getFormDataField(formData, 'nickname') || undefined;
|
||||||
|
contact.extra.organization = getFormDataField(formData, 'organization') || undefined;
|
||||||
|
contact.extra.role = getFormDataField(formData, 'role') || undefined;
|
||||||
|
contact.extra.photo_url = getFormDataField(formData, 'photo_url') || undefined;
|
||||||
|
contact.extra.photo_mediatype = contact.extra.photo_url
|
||||||
|
? `image/${contact.extra.photo_url.split('.').slice(-1, 1).join('').toLowerCase()}`
|
||||||
|
: undefined;
|
||||||
|
contact.extra.notes = getFormDataField(formData, 'notes') || undefined;
|
||||||
|
|
||||||
|
contact.extra.fields = [];
|
||||||
|
|
||||||
|
// Phones
|
||||||
|
const phoneNumbers = getFormDataFieldArray(formData, 'phone_numbers');
|
||||||
|
const phoneLabels = getFormDataFieldArray(formData, 'phone_labels');
|
||||||
|
|
||||||
|
for (const [index, phoneNumber] of phoneNumbers.entries()) {
|
||||||
|
if (phoneNumber.trim().length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const field: ContactField = {
|
||||||
|
name: phoneLabels[index] || 'Home',
|
||||||
|
value: phoneNumber.trim(),
|
||||||
|
type: 'phone',
|
||||||
|
};
|
||||||
|
|
||||||
|
contact.extra.fields.push(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emails
|
||||||
|
const emailAddresses = getFormDataFieldArray(formData, 'email_addresses');
|
||||||
|
const emailLabels = getFormDataFieldArray(formData, 'email_labels');
|
||||||
|
|
||||||
|
for (const [index, emailAddress] of emailAddresses.entries()) {
|
||||||
|
if (emailAddress.trim().length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const field: ContactField = {
|
||||||
|
name: emailLabels[index] || 'Home',
|
||||||
|
value: emailAddress.trim(),
|
||||||
|
type: 'email',
|
||||||
|
};
|
||||||
|
|
||||||
|
contact.extra.fields.push(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
// URLs
|
||||||
|
const urlAddresses = getFormDataFieldArray(formData, 'url_addresses');
|
||||||
|
const urlLabels = getFormDataFieldArray(formData, 'url_labels');
|
||||||
|
|
||||||
|
for (const [index, urlAddress] of urlAddresses.entries()) {
|
||||||
|
if (urlAddress.trim().length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const field: ContactField = {
|
||||||
|
name: urlLabels[index] || 'Home',
|
||||||
|
value: urlAddress.trim(),
|
||||||
|
type: 'url',
|
||||||
|
};
|
||||||
|
|
||||||
|
contact.extra.fields.push(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Others
|
||||||
|
const otherValues = getFormDataFieldArray(formData, 'other_values');
|
||||||
|
const otherLabels = getFormDataFieldArray(formData, 'other_labels');
|
||||||
|
|
||||||
|
for (const [index, otherValue] of otherValues.entries()) {
|
||||||
|
if (otherValue.trim().length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const field: ContactField = {
|
||||||
|
name: otherLabels[index] || 'Home',
|
||||||
|
value: otherValue.trim(),
|
||||||
|
type: 'other',
|
||||||
|
};
|
||||||
|
|
||||||
|
contact.extra.fields.push(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
contact.extra.addresses = [];
|
||||||
|
|
||||||
|
// Addresses
|
||||||
|
const addressLine1s = getFormDataFieldArray(formData, 'address_line_1s');
|
||||||
|
const addressLine2s = getFormDataFieldArray(formData, 'address_line_2s');
|
||||||
|
const addressCities = getFormDataFieldArray(formData, 'address_cities');
|
||||||
|
const addressPostalCodes = getFormDataFieldArray(formData, 'address_postal_codes');
|
||||||
|
const addressStates = getFormDataFieldArray(formData, 'address_states');
|
||||||
|
const addressCountries = getFormDataFieldArray(formData, 'address_countries');
|
||||||
|
const addressLabels = getFormDataFieldArray(formData, 'address_labels');
|
||||||
|
|
||||||
|
for (const [index, addressLine1] of addressLine1s.entries()) {
|
||||||
|
if (addressLine1.trim().length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const address: ContactAddress = {
|
||||||
|
label: addressLabels[index] || 'Home',
|
||||||
|
line_1: addressLine1.trim(),
|
||||||
|
line_2: addressLine2s[index] || undefined,
|
||||||
|
city: addressCities[index] || undefined,
|
||||||
|
postal_code: addressPostalCodes[index] || undefined,
|
||||||
|
state: addressStates[index] || undefined,
|
||||||
|
country: addressCountries[index] || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
contact.extra.addresses.push(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!contact.first_name) {
|
||||||
|
throw new Error(`First name is required.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
formFields(contact).forEach((field) => {
|
||||||
|
if (field.required) {
|
||||||
|
const value = formData.get(field.name);
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${field.label} is required`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateContact(contact);
|
||||||
|
|
||||||
|
return await context.render({
|
||||||
|
contact,
|
||||||
|
notice: 'Contact updated successfully!',
|
||||||
|
formData: convertFormDataToObject(formData),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return await context.render({ contact, error: error.toString(), formData: convertFormDataToObject(formData) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ContactsPage({ data }: PageProps<Data, FreshContextState>) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<ViewContact initialContact={data.contact} formData={data.formData} error={data.error} notice={data.notice} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
routes/dashboard.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { Dashboard, FreshContextState } from '/lib/types.ts';
|
||||||
|
import { createDashboard, getDashboardByUserId } from '/lib/data/dashboard.ts';
|
||||||
|
import Notes from '/islands/dashboard/Notes.tsx';
|
||||||
|
import Links from '/islands/dashboard/Links.tsx';
|
||||||
|
|
||||||
|
interface Data {
|
||||||
|
userDashboard: Dashboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async GET(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
let userDashboard = await getDashboardByUserId(context.state.user.id);
|
||||||
|
|
||||||
|
if (!userDashboard) {
|
||||||
|
userDashboard = await createDashboard(context.state.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await context.render({ userDashboard });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Dashboard({ data }: PageProps<Data, FreshContextState>) {
|
||||||
|
const initialNotes = data?.userDashboard?.data?.notes || 'Jot down some notes here.';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<Links initialLinks={data?.userDashboard?.data?.links || []} />
|
||||||
|
|
||||||
|
<Notes initialNotes={initialNotes} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
routes/dav/addressbooks.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { Handler } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import { convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils.ts';
|
||||||
|
import { createSessionCookie } from '/lib/auth.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
interface DavResponse {
|
||||||
|
'd:href': string;
|
||||||
|
'd:propstat': {
|
||||||
|
'd:prop': {
|
||||||
|
'd:resourcetype'?: {
|
||||||
|
'd:collection': {};
|
||||||
|
'card:addressbook'?: {};
|
||||||
|
};
|
||||||
|
'd:displayname'?: string;
|
||||||
|
'd:getetag'?: string;
|
||||||
|
'd:current-user-principal'?: {
|
||||||
|
'd:href': string;
|
||||||
|
};
|
||||||
|
'd:principal-URL'?: {};
|
||||||
|
'card:addressbook-home-set'?: {};
|
||||||
|
};
|
||||||
|
'd:status': string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DavMultiStatusResponse {
|
||||||
|
'd:multistatus': {
|
||||||
|
'd:response': DavResponse[];
|
||||||
|
};
|
||||||
|
'd:multistatus_attributes': {
|
||||||
|
'xmlns:d': string;
|
||||||
|
'xmlns:s': string;
|
||||||
|
'xmlns:card': string;
|
||||||
|
'xmlns:oc': string;
|
||||||
|
'xmlns:nc': string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResponseBody extends DavMultiStatusResponse {}
|
||||||
|
|
||||||
|
export const handler: Handler<Data, FreshContextState> = (request, context) => {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'GET') {
|
||||||
|
return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method !== 'PROPFIND' && request.method !== 'REPORT') {
|
||||||
|
return new Response('Bad Request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = {
|
||||||
|
'd:multistatus': {
|
||||||
|
'd:response': [
|
||||||
|
{
|
||||||
|
'd:href': '/dav/addressbooks/',
|
||||||
|
'd:propstat': [{
|
||||||
|
'd:prop': {
|
||||||
|
'd:resourcetype': {
|
||||||
|
'd:collection': {},
|
||||||
|
},
|
||||||
|
'd:current-user-principal': {
|
||||||
|
'd:href': '/dav/principals/',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'd:status': 'HTTP/1.1 200 OK',
|
||||||
|
}, {
|
||||||
|
'd:prop': { 'd:principal-URL': {}, 'card:addressbook-home-set': {} },
|
||||||
|
'd:status': 'HTTP/1.1 404 Not Found',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'd:href': '/dav/addressbooks/contacts/',
|
||||||
|
'd:propstat': [{
|
||||||
|
'd:prop': {
|
||||||
|
'd:resourcetype': {
|
||||||
|
'd:collection': {},
|
||||||
|
'card:addressbook': {},
|
||||||
|
},
|
||||||
|
'd:displayname': 'Contacts',
|
||||||
|
'd:getetag': escapeHtml(`"${context.state.user.extra.contacts_revision || 'new'}"`),
|
||||||
|
'd:current-user-principal': {
|
||||||
|
'd:href': '/dav/principals/',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'd:status': 'HTTP/1.1 200 OK',
|
||||||
|
}, {
|
||||||
|
'd:prop': { 'd:principal-URL': {}, 'card:addressbook-home-set': {} },
|
||||||
|
'd:status': 'HTTP/1.1 404 Not Found',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'd:multistatus_attributes': {
|
||||||
|
'xmlns:d': 'DAV:',
|
||||||
|
'xmlns:s': 'http://sabredav.org/ns',
|
||||||
|
'xmlns:card': 'urn:ietf:params:xml:ns:carddav',
|
||||||
|
'xmlns:oc': 'http://owncloud.org/ns',
|
||||||
|
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/xml; charset=utf-8',
|
||||||
|
'dav': DAV_RESPONSE_HEADER,
|
||||||
|
},
|
||||||
|
status: 207,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (context.state.session) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSessionCookie(request, context.state.user, response, true);
|
||||||
|
};
|
||||||
249
routes/dav/addressbooks/contacts.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { Handler } from 'fresh/server.ts';
|
||||||
|
import { parse } from 'xml/mod.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import {
|
||||||
|
buildRFC822Date,
|
||||||
|
convertObjectToDavXml,
|
||||||
|
DAV_RESPONSE_HEADER,
|
||||||
|
escapeHtml,
|
||||||
|
escapeXml,
|
||||||
|
formatContactToVCard,
|
||||||
|
} from '/lib/utils.ts';
|
||||||
|
import { getAllContacts } from '/lib/data/contacts.ts';
|
||||||
|
import { createSessionCookie } from '/lib/auth.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
interface DavResponse {
|
||||||
|
'd:href': string;
|
||||||
|
'd:propstat': {
|
||||||
|
'd:prop': {
|
||||||
|
'd:resourcetype'?: {
|
||||||
|
'd:collection'?: {};
|
||||||
|
'card:addressbook'?: {};
|
||||||
|
};
|
||||||
|
'd:displayname'?: string | {};
|
||||||
|
'card:address-data'?: string;
|
||||||
|
'd:getlastmodified'?: string | {};
|
||||||
|
'd:getetag'?: string | {};
|
||||||
|
'd:getcontenttype'?: string | {};
|
||||||
|
'd:getcontentlength'?: number | {};
|
||||||
|
'd:creationdate'?: string | {};
|
||||||
|
'card:addressbook-description'?: string | {};
|
||||||
|
'cs:getctag'?: {};
|
||||||
|
'd:current-user-privilege-set'?: {
|
||||||
|
'd:privilege': {
|
||||||
|
'd:write-properties'?: {};
|
||||||
|
'd:write'?: {};
|
||||||
|
'd:write-content'?: {};
|
||||||
|
'd:unlock'?: {};
|
||||||
|
'd:bind'?: {};
|
||||||
|
'd:unbind'?: {};
|
||||||
|
'd:write-acl'?: {};
|
||||||
|
'd:read'?: {};
|
||||||
|
'd:read-acl'?: {};
|
||||||
|
'd:read-current-user-privilege-set'?: {};
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'd:status': string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DavMultiStatusResponse {
|
||||||
|
'd:multistatus': {
|
||||||
|
'd:response': DavResponse[];
|
||||||
|
};
|
||||||
|
'd:multistatus_attributes': {
|
||||||
|
'xmlns:d': string;
|
||||||
|
'xmlns:s': string;
|
||||||
|
'xmlns:card': string;
|
||||||
|
'xmlns:oc': string;
|
||||||
|
'xmlns:nc': string;
|
||||||
|
'xmlns:cs': string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResponseBody extends DavMultiStatusResponse {}
|
||||||
|
|
||||||
|
export const handler: Handler<Data, FreshContextState> = async (request, context) => {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'GET') {
|
||||||
|
return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method !== 'PROPFIND' && request.method !== 'REPORT') {
|
||||||
|
return new Response('Bad Request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contacts = await getAllContacts(context.state.user.id);
|
||||||
|
|
||||||
|
const requestBody = (await request.clone().text()).toLowerCase();
|
||||||
|
|
||||||
|
let includeVCard = false;
|
||||||
|
let includeCollection = true;
|
||||||
|
const includePrivileges = requestBody.includes('current-user-privilege-set');
|
||||||
|
|
||||||
|
const filterContactIds = new Set<string>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedDocument = parse(requestBody);
|
||||||
|
|
||||||
|
const multiGetRequest = (parsedDocument['addressbook-multiget'] || parsedDocument['r:addressbook-multiget'] ||
|
||||||
|
parsedDocument['f:addressbook-multiget'] || parsedDocument['d:addressbook-multiget'] ||
|
||||||
|
parsedDocument['r:addressbook-query'] ||
|
||||||
|
parsedDocument['card:addressbook-multiget'] || parsedDocument['card:addressbook-query']) as
|
||||||
|
| Record<string, any>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
includeVCard = Boolean(multiGetRequest);
|
||||||
|
|
||||||
|
const requestedHrefs: string[] = (multiGetRequest && (multiGetRequest['href'] || multiGetRequest['d:href'])) || [];
|
||||||
|
|
||||||
|
includeCollection = requestedHrefs.length === 0;
|
||||||
|
|
||||||
|
for (const requestedHref of requestedHrefs) {
|
||||||
|
const userVCard = requestedHref.split('/').slice(-1).join('');
|
||||||
|
const [userId] = userVCard.split('.vcf');
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
filterContactIds.add(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to parse XML`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredContacts = filterContactIds.size > 0
|
||||||
|
? contacts.filter((contact) => filterContactIds.has(contact.id))
|
||||||
|
: contacts;
|
||||||
|
|
||||||
|
const parsedContacts = filteredContacts.map((contact) => {
|
||||||
|
const parsedContact: DavResponse = {
|
||||||
|
'd:href': `/dav/addressbooks/contacts/${contact.id}.vcf`,
|
||||||
|
'd:propstat': [{
|
||||||
|
'd:prop': {
|
||||||
|
'd:getlastmodified': buildRFC822Date(contact.updated_at.toISOString()),
|
||||||
|
'd:getetag': escapeHtml(`"${contact.revision}"`),
|
||||||
|
'd:getcontenttype': 'text/vcard; charset=utf-8',
|
||||||
|
'd:resourcetype': {},
|
||||||
|
},
|
||||||
|
'd:status': 'HTTP/1.1 200 OK',
|
||||||
|
}, {
|
||||||
|
'd:prop': {
|
||||||
|
'd:displayname': {},
|
||||||
|
'd:getcontentlength': {},
|
||||||
|
'd:creationdate': {},
|
||||||
|
'card:addressbook-description': {},
|
||||||
|
'cs:getctag': {},
|
||||||
|
},
|
||||||
|
'd:status': 'HTTP/1.1 404 Not Found',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (includeVCard) {
|
||||||
|
parsedContact['d:propstat'][0]['d:prop']['card:address-data'] = escapeXml(formatContactToVCard([contact]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includePrivileges) {
|
||||||
|
parsedContact['d:propstat'][0]['d:prop']['d:current-user-privilege-set'] = {
|
||||||
|
'd:privilege': [
|
||||||
|
{ 'd:write-properties': {} },
|
||||||
|
{ 'd:write': {} },
|
||||||
|
{ 'd:write-content': {} },
|
||||||
|
{ 'd:unlock': {} },
|
||||||
|
{ 'd:bind': {} },
|
||||||
|
{ 'd:unbind': {} },
|
||||||
|
{ 'd:write-acl': {} },
|
||||||
|
{ 'd:read': {} },
|
||||||
|
{ 'd:read-acl': {} },
|
||||||
|
{ 'd:read-current-user-privilege-set': {} },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedContact;
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = {
|
||||||
|
'd:multistatus': {
|
||||||
|
'd:response': [
|
||||||
|
...parsedContacts,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'd:multistatus_attributes': {
|
||||||
|
'xmlns:d': 'DAV:',
|
||||||
|
'xmlns:s': 'http://sabredav.org/ns',
|
||||||
|
'xmlns:card': 'urn:ietf:params:xml:ns:carddav',
|
||||||
|
'xmlns:oc': 'http://owncloud.org/ns',
|
||||||
|
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||||
|
'xmlns:cs': 'http://calendarserver.org/ns/',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (includeCollection) {
|
||||||
|
const collectionResponse: DavResponse = {
|
||||||
|
'd:href': '/dav/addressbooks/contacts/',
|
||||||
|
'd:propstat': [{
|
||||||
|
'd:prop': {
|
||||||
|
'd:resourcetype': {
|
||||||
|
'd:collection': {},
|
||||||
|
'card:addressbook': {},
|
||||||
|
},
|
||||||
|
'd:displayname': 'Contacts',
|
||||||
|
'd:getetag': escapeHtml(`"${context.state.user.extra.contacts_revision || 'new'}"`),
|
||||||
|
},
|
||||||
|
'd:status': 'HTTP/1.1 200 OK',
|
||||||
|
}, {
|
||||||
|
'd:prop': {
|
||||||
|
'd:getlastmodified': {},
|
||||||
|
'd:getcontenttype': {},
|
||||||
|
'd:getcontentlength': {},
|
||||||
|
'd:creationdate': {},
|
||||||
|
'card:addressbook-description': {},
|
||||||
|
'cs:getctag': {},
|
||||||
|
},
|
||||||
|
'd:status': 'HTTP/1.1 404 Not Found',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (includePrivileges) {
|
||||||
|
collectionResponse['d:propstat'][0]['d:prop']['d:current-user-privilege-set'] = {
|
||||||
|
'd:privilege': [
|
||||||
|
{ 'd:write-properties': {} },
|
||||||
|
{ 'd:write': {} },
|
||||||
|
{ 'd:write-content': {} },
|
||||||
|
{ 'd:unlock': {} },
|
||||||
|
{ 'd:bind': {} },
|
||||||
|
{ 'd:unbind': {} },
|
||||||
|
{ 'd:write-acl': {} },
|
||||||
|
{ 'd:read': {} },
|
||||||
|
{ 'd:read-acl': {} },
|
||||||
|
{ 'd:read-current-user-privilege-set': {} },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
responseBody['d:multistatus']['d:response'].unshift(collectionResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/xml; charset=utf-8',
|
||||||
|
'dav': DAV_RESPONSE_HEADER,
|
||||||
|
},
|
||||||
|
status: 207,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (context.state.session) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSessionCookie(request, context.state.user, response, true);
|
||||||
|
};
|
||||||
228
routes/dav/addressbooks/contacts/[contactId].vcf.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { Handler } from 'fresh/server.ts';
|
||||||
|
import { parse } from 'xml/mod.ts';
|
||||||
|
|
||||||
|
import { Contact, FreshContextState } from '/lib/types.ts';
|
||||||
|
import {
|
||||||
|
buildRFC822Date,
|
||||||
|
convertObjectToDavXml,
|
||||||
|
DAV_RESPONSE_HEADER,
|
||||||
|
escapeHtml,
|
||||||
|
escapeXml,
|
||||||
|
formatContactToVCard,
|
||||||
|
parseVCardFromTextContents,
|
||||||
|
} from '/lib/utils.ts';
|
||||||
|
import { createContact, deleteContact, getContact, updateContact } from '/lib/data/contacts.ts';
|
||||||
|
import { createSessionCookie } from '/lib/auth.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
interface DavResponse {
|
||||||
|
'd:href': string;
|
||||||
|
'd:propstat': {
|
||||||
|
'd:prop': {
|
||||||
|
'd:resourcetype': {
|
||||||
|
'd:collection'?: {};
|
||||||
|
'card:addressbook'?: {};
|
||||||
|
};
|
||||||
|
'card:address-data'?: string;
|
||||||
|
'd:getlastmodified'?: string;
|
||||||
|
'd:getetag'?: string;
|
||||||
|
'd:getcontenttype'?: string;
|
||||||
|
};
|
||||||
|
'd:status': string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DavMultiStatusResponse {
|
||||||
|
'd:multistatus': {
|
||||||
|
'd:response': DavResponse[];
|
||||||
|
};
|
||||||
|
'd:multistatus_attributes': {
|
||||||
|
'xmlns:d': string;
|
||||||
|
'xmlns:s': string;
|
||||||
|
'xmlns:card': string;
|
||||||
|
'xmlns:oc': string;
|
||||||
|
'xmlns:nc': string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResponseBody extends DavMultiStatusResponse {}
|
||||||
|
|
||||||
|
export const handler: Handler<Data, FreshContextState> = async (request, context) => {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
request.method !== 'PROPFIND' && request.method !== 'REPORT' && request.method !== 'GET' &&
|
||||||
|
request.method !== 'PUT' && request.method !== 'DELETE'
|
||||||
|
) {
|
||||||
|
return new Response('Bad Request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { contactId } = context.params;
|
||||||
|
|
||||||
|
let contact: Contact | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
contact = await getContact(contactId, context.state.user.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
if (request.method === 'PUT') {
|
||||||
|
const requestBody = await request.clone().text();
|
||||||
|
|
||||||
|
const [partialContact] = parseVCardFromTextContents(requestBody);
|
||||||
|
|
||||||
|
if (partialContact.first_name) {
|
||||||
|
const newContact = await createContact(
|
||||||
|
context.state.user.id,
|
||||||
|
partialContact.first_name,
|
||||||
|
partialContact.last_name || '',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use the sent id for the UID
|
||||||
|
if (!partialContact.extra?.uid) {
|
||||||
|
partialContact.extra = {
|
||||||
|
...(partialContact.extra || {}),
|
||||||
|
uid: contactId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
newContact.extra = partialContact.extra!;
|
||||||
|
|
||||||
|
await updateContact(newContact);
|
||||||
|
|
||||||
|
contact = await getContact(newContact.id, context.state.user.id);
|
||||||
|
|
||||||
|
return new Response('Created', { status: 201, headers: { 'etag': `"${contact.revision}"` } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'DELETE') {
|
||||||
|
const clientRevision = request.headers.get('if-match') || request.headers.get('etag');
|
||||||
|
|
||||||
|
// Don't update outdated data
|
||||||
|
if (clientRevision && clientRevision !== `"${contact.revision}"`) {
|
||||||
|
return new Response(null, { status: 204, headers: { 'etag': `"${contact.revision}"` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteContact(contactId, context.state.user.id);
|
||||||
|
|
||||||
|
return new Response(null, { status: 202 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'PUT') {
|
||||||
|
const clientRevision = request.headers.get('if-match') || request.headers.get('etag');
|
||||||
|
|
||||||
|
// Don't update outdated data
|
||||||
|
if (clientRevision && clientRevision !== `"${contact.revision}"`) {
|
||||||
|
return new Response(null, { status: 204, headers: { 'etag': `"${contact.revision}"` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().text();
|
||||||
|
|
||||||
|
const [partialContact] = parseVCardFromTextContents(requestBody);
|
||||||
|
|
||||||
|
contact = {
|
||||||
|
...contact,
|
||||||
|
...partialContact,
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateContact(contact);
|
||||||
|
|
||||||
|
contact = await getContact(contactId, context.state.user.id);
|
||||||
|
|
||||||
|
return new Response(null, { status: 204, headers: { 'etag': `"${contact.revision}"` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'GET') {
|
||||||
|
// Set a UID if there isn't one
|
||||||
|
if (!contact.extra.uid) {
|
||||||
|
contact.extra.uid = crypto.randomUUID();
|
||||||
|
await updateContact(contact);
|
||||||
|
|
||||||
|
contact = await getContact(contactId, context.state.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = new Response(formatContactToVCard([contact]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'etag': `"${contact.revision}"` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (context.state.session) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSessionCookie(request, context.state.user, response, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = (await request.clone().text()).toLowerCase();
|
||||||
|
|
||||||
|
let includeVCard = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedDocument = parse(requestBody);
|
||||||
|
|
||||||
|
const multiGetRequest = (parsedDocument['r:addressbook-multiget'] || parsedDocument['r:addressbook-query'] ||
|
||||||
|
parsedDocument['card:addressbook-multiget'] || parsedDocument['card:addressbook-query']) as
|
||||||
|
| Record<string, any>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
includeVCard = Boolean(multiGetRequest);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to parse XML`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedContact: DavResponse = {
|
||||||
|
'd:href': `/dav/addressbooks/contacts/${contact.id}.vcf`,
|
||||||
|
'd:propstat': {
|
||||||
|
'd:prop': {
|
||||||
|
'd:getlastmodified': buildRFC822Date(contact.updated_at.toISOString()),
|
||||||
|
'd:getetag': escapeHtml(`"${contact.revision}"`),
|
||||||
|
'd:getcontenttype': 'text/vcard; charset=utf-8',
|
||||||
|
'd:resourcetype': {},
|
||||||
|
},
|
||||||
|
'd:status': 'HTTP/1.1 200 OK',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (includeVCard) {
|
||||||
|
parsedContact['d:propstat']['d:prop']['card:address-data'] = escapeXml(formatContactToVCard([contact]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = {
|
||||||
|
'd:multistatus': {
|
||||||
|
'd:response': [parsedContact],
|
||||||
|
},
|
||||||
|
'd:multistatus_attributes': {
|
||||||
|
'xmlns:d': 'DAV:',
|
||||||
|
'xmlns:s': 'http://sabredav.org/ns',
|
||||||
|
'xmlns:card': 'urn:ietf:params:xml:ns:carddav',
|
||||||
|
'xmlns:oc': 'http://owncloud.org/ns',
|
||||||
|
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/xml; charset=utf-8',
|
||||||
|
'dav': DAV_RESPONSE_HEADER,
|
||||||
|
},
|
||||||
|
status: 207,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (context.state.session) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSessionCookie(request, context.state.user, response, true);
|
||||||
|
};
|
||||||
91
routes/dav/files.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Handler } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils.ts';
|
||||||
|
import { createSessionCookie } from '/lib/auth.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
interface DavResponse {
|
||||||
|
'd:href': string;
|
||||||
|
'd:propstat': {
|
||||||
|
'd:prop': {
|
||||||
|
'd:resourcetype': {
|
||||||
|
'd:collection'?: {};
|
||||||
|
};
|
||||||
|
'd:getlastmodified': string;
|
||||||
|
'd:getetag': string;
|
||||||
|
'd:getcontentlength'?: number;
|
||||||
|
'd:getcontenttype'?: string;
|
||||||
|
};
|
||||||
|
'd:status': string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DavMultiStatusResponse {
|
||||||
|
'd:multistatus': {
|
||||||
|
'd:response': DavResponse[];
|
||||||
|
};
|
||||||
|
'd:multistatus_attributes': { 'xmlns:d': string; 'xmlns:s': string; 'xmlns:oc': string; 'xmlns:nc': string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResponseBody extends DavMultiStatusResponse {}
|
||||||
|
|
||||||
|
export const handler: Handler<Data, FreshContextState> = (request, context) => {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'GET') {
|
||||||
|
return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method !== 'PROPFIND') {
|
||||||
|
return new Response('Bad Request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: List directories and files in root
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = {
|
||||||
|
'd:multistatus': {
|
||||||
|
'd:response': [
|
||||||
|
{
|
||||||
|
'd:href': '/dav/files/',
|
||||||
|
'd:propstat': {
|
||||||
|
'd:prop': {
|
||||||
|
'd:resourcetype': {
|
||||||
|
'd:collection': {},
|
||||||
|
},
|
||||||
|
'd:getlastmodified': buildRFC822Date('2020-01-01'),
|
||||||
|
'd:getetag': escapeHtml(`"fake"`),
|
||||||
|
},
|
||||||
|
'd:status': 'HTTP/1.1 200 OK',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'd:multistatus_attributes': {
|
||||||
|
'xmlns:d': 'DAV:',
|
||||||
|
'xmlns:s': 'http://sabredav.org/ns',
|
||||||
|
'xmlns:oc': 'http://owncloud.org/ns',
|
||||||
|
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/xml; charset=utf-8',
|
||||||
|
'dav': DAV_RESPONSE_HEADER,
|
||||||
|
},
|
||||||
|
status: 207,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (context.state.session) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSessionCookie(request, context.state.user, response, true);
|
||||||
|
};
|
||||||
118
routes/dav/index.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { Handler } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils.ts';
|
||||||
|
import { createSessionCookie } from '/lib/auth.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
interface DavResponse {
|
||||||
|
'd:href': string;
|
||||||
|
'd:propstat': {
|
||||||
|
'd:prop': {
|
||||||
|
'd:resourcetype': {
|
||||||
|
'd:collection': {};
|
||||||
|
};
|
||||||
|
'd:current-user-principal'?: {
|
||||||
|
'd:href': string;
|
||||||
|
};
|
||||||
|
'd:getlastmodified'?: string;
|
||||||
|
'd:getetag'?: string;
|
||||||
|
};
|
||||||
|
'd:status': string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DavMultiStatusResponse {
|
||||||
|
'd:multistatus': {
|
||||||
|
'd:response': DavResponse[];
|
||||||
|
};
|
||||||
|
'd:multistatus_attributes': { 'xmlns:d': string; 'xmlns:s': string; 'xmlns:oc': string; 'xmlns:nc': string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResponseBody extends DavMultiStatusResponse {}
|
||||||
|
|
||||||
|
export const handler: Handler<Data, FreshContextState> = (request, context) => {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'GET') {
|
||||||
|
return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method !== 'PROPFIND' && request.method !== 'REPORT') {
|
||||||
|
return new Response('Bad Request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = {
|
||||||
|
'd:multistatus': {
|
||||||
|
'd:response': [
|
||||||
|
{
|
||||||
|
'd:href': '/dav/',
|
||||||
|
'd:propstat': {
|
||||||
|
'd:prop': {
|
||||||
|
'd:resourcetype': {
|
||||||
|
'd:collection': {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'd:status': 'HTTP/1.1 200 OK',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'd:href': '/dav/addressbooks/',
|
||||||
|
'd:propstat': {
|
||||||
|
'd:prop': {
|
||||||
|
'd:resourcetype': {
|
||||||
|
'd:collection': {},
|
||||||
|
},
|
||||||
|
'd:current-user-principal': {
|
||||||
|
'd:href': '/dav/principals/',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'd:status': 'HTTP/1.1 200 OK',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'd:href': '/dav/files/',
|
||||||
|
'd:propstat': {
|
||||||
|
'd:prop': {
|
||||||
|
'd:resourcetype': {
|
||||||
|
'd:collection': {},
|
||||||
|
},
|
||||||
|
'd:current-user-principal': {
|
||||||
|
'd:href': '/dav/principals/',
|
||||||
|
},
|
||||||
|
'd:getlastmodified': buildRFC822Date('2020-01-01'),
|
||||||
|
'd:getetag': escapeHtml(`"fake"`),
|
||||||
|
},
|
||||||
|
'd:status': 'HTTP/1.1 200 OK',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'd:multistatus_attributes': {
|
||||||
|
'xmlns:d': 'DAV:',
|
||||||
|
'xmlns:s': 'http://sabredav.org/ns',
|
||||||
|
'xmlns:oc': 'http://owncloud.org/ns',
|
||||||
|
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/xml; charset=utf-8',
|
||||||
|
'dav': DAV_RESPONSE_HEADER,
|
||||||
|
},
|
||||||
|
status: 207,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (context.state.session) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSessionCookie(request, context.state.user, response, true);
|
||||||
|
};
|
||||||
128
routes/dav/principals.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { Handler } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import { convertObjectToDavXml, DAV_RESPONSE_HEADER } from '/lib/utils.ts';
|
||||||
|
import { createSessionCookie } from '/lib/auth.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
interface DavResponse {
|
||||||
|
'd:href': string;
|
||||||
|
'd:propstat': {
|
||||||
|
'd:prop': {
|
||||||
|
'd:resourcetype': {
|
||||||
|
'd:collection'?: {};
|
||||||
|
'd:principal': {};
|
||||||
|
};
|
||||||
|
'd:displayname'?: string;
|
||||||
|
'card:addressbook-home-set'?: {
|
||||||
|
'd:href': string;
|
||||||
|
};
|
||||||
|
'd:current-user-principal'?: {
|
||||||
|
'd:href': string;
|
||||||
|
};
|
||||||
|
'd:principal-URL'?: {
|
||||||
|
'd:href': string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'd:status': string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DavMultiStatusResponse {
|
||||||
|
'd:multistatus': {
|
||||||
|
'd:response': DavResponse[];
|
||||||
|
};
|
||||||
|
'd:multistatus_attributes': {
|
||||||
|
'xmlns:d': string;
|
||||||
|
'xmlns:s': string;
|
||||||
|
'xmlns:cal': string;
|
||||||
|
'xmlns:cs': string;
|
||||||
|
'xmlns:card': string;
|
||||||
|
'xmlns:oc': string;
|
||||||
|
'xmlns:nc': string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResponseBody extends DavMultiStatusResponse {}
|
||||||
|
|
||||||
|
export const handler: Handler<Data, FreshContextState> = async (request, context) => {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'GET') {
|
||||||
|
return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method !== 'PROPFIND' && request.method !== 'REPORT') {
|
||||||
|
return new Response('Bad Request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = {
|
||||||
|
'd:multistatus': {
|
||||||
|
'd:response': [],
|
||||||
|
},
|
||||||
|
'd:multistatus_attributes': {
|
||||||
|
'xmlns:d': 'DAV:',
|
||||||
|
'xmlns:s': 'http://sabredav.org/ns',
|
||||||
|
'xmlns:cal': 'urn:ietf:params:xml:ns:caldav',
|
||||||
|
'xmlns:cs': 'http://calendarserver.org/ns/',
|
||||||
|
'xmlns:card': 'urn:ietf:params:xml:ns:carddav',
|
||||||
|
'xmlns:oc': 'http://owncloud.org/ns',
|
||||||
|
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (request.method === 'PROPFIND') {
|
||||||
|
const propResponse: DavResponse = {
|
||||||
|
'd:href': '/dav/principals/',
|
||||||
|
'd:propstat': {
|
||||||
|
'd:prop': {
|
||||||
|
'd:resourcetype': {
|
||||||
|
'd:collection': {},
|
||||||
|
'd:principal': {},
|
||||||
|
},
|
||||||
|
'd:current-user-principal': {
|
||||||
|
'd:href': '/dav/principals/',
|
||||||
|
},
|
||||||
|
'd:principal-URL': {
|
||||||
|
'd:href': '/dav/principals/',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'd:status': 'HTTP/1.1 200 OK',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestBody = (await request.clone().text()).toLowerCase();
|
||||||
|
|
||||||
|
if (requestBody.includes('displayname')) {
|
||||||
|
propResponse['d:propstat']['d:prop']['d:displayname'] = `${context.state.user.email}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestBody.includes('addressbook-home-set')) {
|
||||||
|
propResponse['d:propstat']['d:prop']['card:addressbook-home-set'] = {
|
||||||
|
'd:href': `/dav/addressbooks/`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
responseBody['d:multistatus']['d:response'].push(propResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/xml; charset=utf-8',
|
||||||
|
'dav': DAV_RESPONSE_HEADER,
|
||||||
|
},
|
||||||
|
status: 207,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (context.state.session) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSessionCookie(request, context.state.user, response, true);
|
||||||
|
};
|
||||||
15
routes/index.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
GET(request, context) {
|
||||||
|
if (context.state.user) {
|
||||||
|
return new Response('Redirect', { status: 303, headers: { 'Location': `/dashboard` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Redirect', { status: 303, headers: { 'Location': '/login' } });
|
||||||
|
},
|
||||||
|
};
|
||||||
175
routes/login.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { generateHash, helpEmail, validateEmail } from '/lib/utils.ts';
|
||||||
|
import { createSessionResponse, PASSWORD_SALT } from '/lib/auth.ts';
|
||||||
|
import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx';
|
||||||
|
import { createVerificationCode, getUserByEmail, updateUser, validateVerificationCode } from '/lib/data/user.ts';
|
||||||
|
import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts';
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
|
||||||
|
interface Data {
|
||||||
|
error?: string;
|
||||||
|
notice?: string;
|
||||||
|
email?: string;
|
||||||
|
formData?: FormData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async GET(request, context) {
|
||||||
|
if (context.state.user) {
|
||||||
|
return new Response('Redirect', { status: 303, headers: { 'Location': `/` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = new URL(request.url).searchParams;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
let notice = '';
|
||||||
|
let email = '';
|
||||||
|
|
||||||
|
if (searchParams.get('success') === 'signup') {
|
||||||
|
email = searchParams.get('email') || '';
|
||||||
|
formData.set('email', email);
|
||||||
|
|
||||||
|
notice = `You have received a code in your email. Use it to verify your email and login.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await context.render({ notice, email, formData });
|
||||||
|
},
|
||||||
|
async POST(request, context) {
|
||||||
|
if (context.state.user) {
|
||||||
|
return new Response('Redirect', { status: 303, headers: { 'Location': `/` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.clone().formData();
|
||||||
|
const email = getFormDataField(formData, 'email');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!validateEmail(email)) {
|
||||||
|
throw new Error(`Invalid email.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = getFormDataField(formData, 'password');
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
throw new Error(`Password is too short.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await generateHash(`${password}:${PASSWORD_SALT}`, 'SHA-256');
|
||||||
|
|
||||||
|
const user = await getUserByEmail(email);
|
||||||
|
|
||||||
|
if (!user || user.hashed_password !== hashedPassword) {
|
||||||
|
throw new Error('Email not found or invalid password.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.extra.is_email_verified) {
|
||||||
|
const code = getFormDataField(formData, 'verification-code');
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
const verificationCode = await createVerificationCode(user, user.email, 'email');
|
||||||
|
|
||||||
|
await sendVerifyEmailEmail(user.email, verificationCode);
|
||||||
|
|
||||||
|
throw new Error('Email not verified. New code sent to verify your email.');
|
||||||
|
} else {
|
||||||
|
await validateVerificationCode(user, user.email, code, 'email');
|
||||||
|
|
||||||
|
user.extra.is_email_verified = true;
|
||||||
|
|
||||||
|
await updateUser(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSessionResponse(request, user, { urlToRedirectTo: `/` });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return await context.render({ error: error.toString(), email, formData });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function formFields(email?: string, showVerificationCode = false) {
|
||||||
|
const fields: FormField[] = [
|
||||||
|
{
|
||||||
|
name: 'email',
|
||||||
|
label: 'Email',
|
||||||
|
type: 'email',
|
||||||
|
placeholder: 'jane.doe@example.com',
|
||||||
|
value: email || '',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'password',
|
||||||
|
label: 'Password',
|
||||||
|
type: 'password',
|
||||||
|
placeholder: 'super-SECRET-passphrase',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (showVerificationCode) {
|
||||||
|
fields.push({
|
||||||
|
name: 'verification-code',
|
||||||
|
label: 'Verification Code',
|
||||||
|
description: `The verification code to validate your email.`,
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '000000',
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Login({ data }: PageProps<Data, FreshContextState>) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<section class='max-w-screen-md mx-auto flex flex-col items-center justify-center'>
|
||||||
|
<h1 class='text-4xl mb-6'>
|
||||||
|
Login
|
||||||
|
</h1>
|
||||||
|
{data?.error
|
||||||
|
? (
|
||||||
|
<section class='notification-error'>
|
||||||
|
<h3>Failed to login!</h3>
|
||||||
|
<p>{data?.error}</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{data?.notice
|
||||||
|
? (
|
||||||
|
<section class='notification-success'>
|
||||||
|
<h3>Verify your email!</h3>
|
||||||
|
<p>{data?.notice}</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
|
<form method='POST' class='mb-12'>
|
||||||
|
{formFields(data?.email, data?.notice?.includes('verify your email')).map((field) =>
|
||||||
|
generateFieldHtml(field, data?.formData || new FormData())
|
||||||
|
)}
|
||||||
|
<section class='flex justify-center mt-8 mb-4'>
|
||||||
|
<button class='button' type='submit'>Login</button>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2 class='text-2xl mb-4 text-center'>Need an account?</h2>
|
||||||
|
<p class='text-center mt-2 mb-6'>
|
||||||
|
If you still don't have an account,{' '}
|
||||||
|
<strong>
|
||||||
|
<a href='/signup'>signup</a>
|
||||||
|
</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class='text-2xl mb-4 text-center'>Need help?</h2>
|
||||||
|
<p class='text-center mt-2 mb-6'>
|
||||||
|
If you're having any issues or have any questions,{' '}
|
||||||
|
<strong>
|
||||||
|
<a href={`mailto:${helpEmail}`}>please reach out</a>
|
||||||
|
</strong>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
routes/logout.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { logoutUser } from '/lib/auth.ts';
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async GET(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return await logoutUser(request);
|
||||||
|
},
|
||||||
|
};
|
||||||
29
routes/news.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState, NewsFeedArticle } from '/lib/types.ts';
|
||||||
|
import { getNewsArticles } from '/lib/data/news.ts';
|
||||||
|
import Articles from '/islands/news/Articles.tsx';
|
||||||
|
|
||||||
|
interface Data {
|
||||||
|
userArticles: NewsFeedArticle[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async GET(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userArticles = await getNewsArticles(context.state.user.id);
|
||||||
|
|
||||||
|
return await context.render({ userArticles });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function News({ data }: PageProps<Data, FreshContextState>) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<Articles initialArticles={data?.userArticles || []} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
routes/news/feeds.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState, NewsFeed } from '/lib/types.ts';
|
||||||
|
import { getNewsFeeds } from '/lib/data/news.ts';
|
||||||
|
import Feeds from '/islands/news/Feeds.tsx';
|
||||||
|
|
||||||
|
interface Data {
|
||||||
|
userFeeds: NewsFeed[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async GET(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userFeeds = await getNewsFeeds(context.state.user.id);
|
||||||
|
|
||||||
|
return await context.render({ userFeeds });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FeedsPage({ data }: PageProps<Data, FreshContextState>) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<Feeds initialFeeds={data?.userFeeds || []} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
routes/remote.php/[davRoute].tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Nextcloud/ownCloud mimicry
|
||||||
|
export function handler(): Response {
|
||||||
|
return new Response('Redirecting...', {
|
||||||
|
status: 307,
|
||||||
|
headers: { Location: '/dav' },
|
||||||
|
});
|
||||||
|
}
|
||||||
186
routes/settings.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { Dashboard, FreshContextState } from '/lib/types.ts';
|
||||||
|
import { PASSWORD_SALT } from '/lib/auth.ts';
|
||||||
|
import {
|
||||||
|
createVerificationCode,
|
||||||
|
deleteUser,
|
||||||
|
getUserByEmail,
|
||||||
|
updateUser,
|
||||||
|
validateVerificationCode,
|
||||||
|
} from '/lib/data/user.ts';
|
||||||
|
import { convertFormDataToObject, generateHash, validateEmail } from '/lib/utils.ts';
|
||||||
|
import { getFormDataField } from '/lib/form-utils.tsx';
|
||||||
|
import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts';
|
||||||
|
import Settings, { Action, actionWords } from '/islands/Settings.tsx';
|
||||||
|
|
||||||
|
interface Data {
|
||||||
|
error?: {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
notice?: {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
formData: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async GET(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return await context.render();
|
||||||
|
},
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
let action: Action = 'change-email';
|
||||||
|
let errorTitle = '';
|
||||||
|
let errorMessage = '';
|
||||||
|
let successTitle = '';
|
||||||
|
let successMessage = '';
|
||||||
|
|
||||||
|
const formData = await request.clone().formData();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { user } = context.state;
|
||||||
|
|
||||||
|
action = getFormDataField(formData, 'action') as Action;
|
||||||
|
|
||||||
|
if (action !== 'change-email' && action !== 'verify-change-email') {
|
||||||
|
formData.set('email', user.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((action === 'change-email' || action === 'verify-change-email')) {
|
||||||
|
const email = getFormDataField(formData, 'email');
|
||||||
|
|
||||||
|
if (!validateEmail(email)) {
|
||||||
|
throw new Error(`Invalid email.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email === user.email) {
|
||||||
|
throw new Error(`New email is the same as the current email.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingUser = await getUserByEmail(email);
|
||||||
|
|
||||||
|
if (matchingUser) {
|
||||||
|
throw new Error('Email is already in use.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'change-email') {
|
||||||
|
const verificationCode = await createVerificationCode(user, email, 'email');
|
||||||
|
|
||||||
|
await sendVerifyEmailEmail(email, verificationCode);
|
||||||
|
|
||||||
|
successTitle = 'Verify your email!';
|
||||||
|
successMessage = 'You have received a code in your new email. Use it to verify it here.';
|
||||||
|
} else {
|
||||||
|
const code = getFormDataField(formData, 'verification-code');
|
||||||
|
|
||||||
|
await validateVerificationCode(user, email, code, 'email');
|
||||||
|
|
||||||
|
user.email = email;
|
||||||
|
|
||||||
|
await updateUser(user);
|
||||||
|
|
||||||
|
successTitle = 'Email updated!';
|
||||||
|
successMessage = 'Email updated successfully.';
|
||||||
|
}
|
||||||
|
} else if (action === 'change-password') {
|
||||||
|
const currentPassword = getFormDataField(formData, 'current-password');
|
||||||
|
const newPassword = getFormDataField(formData, 'new-password');
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
throw new Error(`New password is too short`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedCurrentPassword = await generateHash(`${currentPassword}:${PASSWORD_SALT}`, 'SHA-256');
|
||||||
|
const hashedNewPassword = await generateHash(`${newPassword}:${PASSWORD_SALT}`, 'SHA-256');
|
||||||
|
|
||||||
|
if (user.hashed_password !== hashedCurrentPassword) {
|
||||||
|
throw new Error('Invalid current password.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hashedCurrentPassword === hashedNewPassword) {
|
||||||
|
throw new Error(`New password is the same as the current password.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.hashed_password = hashedNewPassword;
|
||||||
|
|
||||||
|
await updateUser(user);
|
||||||
|
|
||||||
|
successTitle = 'Password changed!';
|
||||||
|
successMessage = 'Password changed successfully.';
|
||||||
|
} else if (action === 'change-dav-password') {
|
||||||
|
const newDavPassword = getFormDataField(formData, 'new-dav-password');
|
||||||
|
|
||||||
|
if (newDavPassword.length < 6) {
|
||||||
|
throw new Error(`New DAV password is too short`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedNewDavPassword = await generateHash(`${newDavPassword}:${PASSWORD_SALT}`, 'SHA-256');
|
||||||
|
|
||||||
|
if (user.extra.dav_hashed_password === hashedNewDavPassword) {
|
||||||
|
throw new Error(`New DAV password is the same as the current password.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.extra.dav_hashed_password = hashedNewDavPassword;
|
||||||
|
|
||||||
|
await updateUser(user);
|
||||||
|
|
||||||
|
successTitle = 'DAV Password changed!';
|
||||||
|
successMessage = 'DAV Password changed successfully.';
|
||||||
|
} else if (action === 'delete-account') {
|
||||||
|
const currentPassword = getFormDataField(formData, 'current-password');
|
||||||
|
|
||||||
|
const hashedCurrentPassword = await generateHash(`${currentPassword}:${PASSWORD_SALT}`, 'SHA-256');
|
||||||
|
|
||||||
|
if (user.hashed_password !== hashedCurrentPassword) {
|
||||||
|
throw new Error('Invalid current password.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteUser(user.id);
|
||||||
|
|
||||||
|
return new Response('Account deleted successfully', {
|
||||||
|
status: 303,
|
||||||
|
headers: { 'location': `/signup?success=delete` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const notice = successTitle
|
||||||
|
? {
|
||||||
|
title: successTitle,
|
||||||
|
message: successMessage,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return await context.render({
|
||||||
|
notice,
|
||||||
|
formData: convertFormDataToObject(formData),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
errorMessage = error.toString();
|
||||||
|
errorTitle = `Failed to ${actionWords.get(action) || action}!`;
|
||||||
|
|
||||||
|
return await context.render({
|
||||||
|
error: { title: errorTitle, message: errorMessage },
|
||||||
|
formData: convertFormDataToObject(formData),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Dashboard({ data }: PageProps<Data, FreshContextState>) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<Settings formData={data?.formData} error={data?.error} notice={data?.notice} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
routes/signup.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { generateHash, helpEmail, validateEmail } from '/lib/utils.ts';
|
||||||
|
import { PASSWORD_SALT } from '/lib/auth.ts';
|
||||||
|
import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx';
|
||||||
|
import { createUser, createVerificationCode, getUserByEmail } from '/lib/data/user.ts';
|
||||||
|
import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts';
|
||||||
|
import { isSignupAllowed } from '/lib/config.ts';
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
|
||||||
|
interface Data {
|
||||||
|
error?: string;
|
||||||
|
notice?: string;
|
||||||
|
email?: string;
|
||||||
|
formData?: FormData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async GET(request, context) {
|
||||||
|
if (context.state.user) {
|
||||||
|
return new Response('Redirect', { status: 303, headers: { 'Location': `/` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = new URL(request.url).searchParams;
|
||||||
|
|
||||||
|
let notice = '';
|
||||||
|
|
||||||
|
if (searchParams.get('success') === 'delete') {
|
||||||
|
notice = `Your account and all its data has been deleted.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await context.render({ notice });
|
||||||
|
},
|
||||||
|
async POST(request, context) {
|
||||||
|
if (context.state.user) {
|
||||||
|
return new Response('Redirect', { status: 303, headers: { 'Location': `/` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.clone().formData();
|
||||||
|
const email = getFormDataField(formData, 'email');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!(await isSignupAllowed())) {
|
||||||
|
throw new Error(`Signups are not allowed.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateEmail(email)) {
|
||||||
|
throw new Error(`Invalid email.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = getFormDataField(formData, 'password');
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
throw new Error(`Password is too short.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await getUserByEmail(email);
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw new Error('Email is already in use. Perhaps you want to login instead?');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await generateHash(`${password}:${PASSWORD_SALT}`, 'SHA-256');
|
||||||
|
|
||||||
|
const user = await createUser(email, hashedPassword);
|
||||||
|
|
||||||
|
const verificationCode = await createVerificationCode(user, user.email, 'email');
|
||||||
|
|
||||||
|
await sendVerifyEmailEmail(user.email, verificationCode);
|
||||||
|
|
||||||
|
return new Response('Signup successful', {
|
||||||
|
status: 303,
|
||||||
|
headers: { 'location': `/login?success=signup&email=${encodeURIComponent(user.email)}` },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return await context.render({ error: error.toString(), email, formData });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function formFields(email?: string) {
|
||||||
|
const fields: FormField[] = [
|
||||||
|
{
|
||||||
|
name: 'email',
|
||||||
|
label: 'Email',
|
||||||
|
description: `The email that will be used to login. A code will be sent to it.`,
|
||||||
|
type: 'email',
|
||||||
|
placeholder: 'jane.doe@example.com',
|
||||||
|
value: email || '',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'password',
|
||||||
|
label: 'Password',
|
||||||
|
description: `The password that will be used to login.`,
|
||||||
|
type: 'password',
|
||||||
|
placeholder: 'super-SECRET-passphrase',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Signup({ data }: PageProps<Data, FreshContextState>) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<section class='max-w-screen-md mx-auto flex flex-col items-center justify-center'>
|
||||||
|
<h1 class='text-4xl mb-6'>
|
||||||
|
Signup
|
||||||
|
</h1>
|
||||||
|
{data?.error
|
||||||
|
? (
|
||||||
|
<section class='notification-error'>
|
||||||
|
<h3>Failed to signup!</h3>
|
||||||
|
<p>{data?.error}</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
{data?.notice
|
||||||
|
? (
|
||||||
|
<section class='notification-success'>
|
||||||
|
<h3>Success!</h3>
|
||||||
|
<p>{data?.notice}</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
|
<form method='POST' class='mb-12'>
|
||||||
|
{formFields(data?.email).map((field) => generateFieldHtml(field, data?.formData || new FormData()))}
|
||||||
|
<section class='flex justify-center mt-8 mb-4'>
|
||||||
|
<button class='button' type='submit'>Signup</button>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2 class='text-2xl mb-4 text-center'>Already have an account?</h2>
|
||||||
|
<p class='text-center mt-2 mb-6'>
|
||||||
|
If you already have an account,{' '}
|
||||||
|
<strong>
|
||||||
|
<a href='/login'>login</a>
|
||||||
|
</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class='text-2xl mb-4 text-center'>Need help?</h2>
|
||||||
|
<p class='text-center mt-2 mb-6'>
|
||||||
|
If you're having any issues or have any questions,{' '}
|
||||||
|
<strong>
|
||||||
|
<a href={`mailto:${helpEmail}`}>please reach out</a>
|
||||||
|
</strong>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
static/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
1
static/images/add.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><path fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="32" d="M448 256c0-106-86-192-192-192S64 150 64 256s86 192 192 192s192-86 192-192Z"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M256 176v160m80-80H176"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
1
static/images/calendar.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><rect width="416" height="384" x="48" y="80" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32" rx="48"/><circle cx="296" cy="232" r="24" fill="currentColor"/><circle cx="376" cy="232" r="24" fill="currentColor"/><circle cx="296" cy="312" r="24" fill="currentColor"/><circle cx="376" cy="312" r="24" fill="currentColor"/><circle cx="136" cy="312" r="24" fill="currentColor"/><circle cx="216" cy="312" r="24" fill="currentColor"/><circle cx="136" cy="392" r="24" fill="currentColor"/><circle cx="216" cy="392" r="24" fill="currentColor"/><circle cx="296" cy="392" r="24" fill="currentColor"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M128 48v32m256-32v32"/><path fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32" d="M464 160H48"/></svg>
|
||||||
|
After Width: | Height: | Size: 937 B |
1
static/images/check-all.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><path fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="32" d="M448 256c0-106-86-192-192-192S64 150 64 256s86 192 192 192s192-86 192-192Z"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M368 192L256.13 320l-47.95-48m-16.23 48L144 272m161.71-80l-51.55 59"/></svg>
|
||||||
|
After Width: | Height: | Size: 430 B |
1
static/images/check.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><path fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="32" d="M448 256c0-106-86-192-192-192S64 150 64 256s86 192 192 192s192-86 192-192Z"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M352 176L217.6 336L160 272"/></svg>
|
||||||
|
After Width: | Height: | Size: 389 B |
1
static/images/contacts.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M402 168c-2.93 40.67-33.1 72-66 72s-63.12-31.32-66-72c-3-42.31 26.37-72 66-72s69 30.46 66 72Z"/><path fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="32" d="M336 304c-65.17 0-127.84 32.37-143.54 95.41c-2.08 8.34 3.15 16.59 11.72 16.59h263.65c8.57 0 13.77-8.25 11.72-16.59C463.85 335.36 401.18 304 336 304Z"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M200 185.94c-2.34 32.48-26.72 58.06-53 58.06s-50.7-25.57-53-58.06C91.61 152.15 115.34 128 147 128s55.39 24.77 53 57.94Z"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-miterlimit="10" stroke-width="32" d="M206 306c-18.05-8.27-37.93-11.45-59-11.45c-52 0-102.1 25.85-114.65 76.2c-1.65 6.66 2.53 13.25 9.37 13.25H154"/></svg>
|
||||||
|
After Width: | Height: | Size: 978 B |
1
static/images/dashboard.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M80 212v236a16 16 0 0 0 16 16h96V328a24 24 0 0 1 24-24h80a24 24 0 0 1 24 24v136h96a16 16 0 0 0 16-16V212"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M480 256L266.89 52c-5-5.28-16.69-5.34-21.78 0L32 256m368-77V64h-48v69"/></svg>
|
||||||
|
After Width: | Height: | Size: 486 B |
BIN
static/images/default.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
1
static/images/delete.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><path d="M256 90c44.3 0 86 17.3 117.4 48.6C404.7 170 422 211.7 422 256s-17.3 86-48.6 117.4C342 404.7 300.3 422 256 422s-86-17.3-117.4-48.6C107.3 342 90 300.3 90 256s17.3-86 48.6-117.4C170 107.3 211.7 90 256 90m0-42C141.1 48 48 141.1 48 256s93.1 208 208 208 208-93.1 208-208S370.9 48 256 48z" fill="currentColor"/><path d="M360 330.9L330.9 360 256 285.1 181.1 360 152 330.9l74.9-74.9-74.9-74.9 29.1-29.1 74.9 74.9 74.9-74.9 29.1 29.1-74.9 74.9z" fill="currentColor"/></svg>
|
||||||
|
After Width: | Height: | Size: 557 B |
BIN
static/images/favicon.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
121
static/images/favicon.svg
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
viewBox="0 0 80 80"
|
||||||
|
class="iconLeft"
|
||||||
|
version="1.1"
|
||||||
|
id="svg3"
|
||||||
|
sodipodi:docname="favicon.svg"
|
||||||
|
inkscape:export-filename="favicon.png"
|
||||||
|
inkscape:export-xdpi="1228.8"
|
||||||
|
inkscape:export-ydpi="1228.8"
|
||||||
|
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
|
||||||
|
width="80"
|
||||||
|
height="80"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview3"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="1.92"
|
||||||
|
inkscape:cx="127.34375"
|
||||||
|
inkscape:cy="30.208333"
|
||||||
|
inkscape:window-width="1440"
|
||||||
|
inkscape:window-height="831"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="g5" />
|
||||||
|
<!---->
|
||||||
|
<defs
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
id="defs1">
|
||||||
|
<!---->
|
||||||
|
<linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#d27698e6-eb24-4a3f-96af-256d84d5b3ea"
|
||||||
|
id="linearGradient4"
|
||||||
|
gradientTransform="scale(1.3465159,0.74265739)"
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="74.265739"
|
||||||
|
y2="0"
|
||||||
|
gradientUnits="userSpaceOnUse" />
|
||||||
|
<linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#d27698e6-eb24-4a3f-96af-256d84d5b3ea"
|
||||||
|
id="linearGradient5"
|
||||||
|
gradientTransform="scale(1.3465159,0.74265739)"
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="74.265739"
|
||||||
|
y2="0"
|
||||||
|
gradientUnits="userSpaceOnUse" />
|
||||||
|
</defs>
|
||||||
|
<defs
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
id="defs2">
|
||||||
|
<!---->
|
||||||
|
</defs>
|
||||||
|
<defs
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
id="defs3">
|
||||||
|
<linearGradient
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
gradientTransform="rotate(25)"
|
||||||
|
id="d27698e6-eb24-4a3f-96af-256d84d5b3ea"
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="1"
|
||||||
|
y2="0">
|
||||||
|
<stop
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
offset="0%"
|
||||||
|
stop-color="#51A4FB"
|
||||||
|
stop-opacity="1"
|
||||||
|
id="stop2" />
|
||||||
|
<stop
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
offset="100%"
|
||||||
|
stop-color="#51A4FB"
|
||||||
|
stop-opacity="1"
|
||||||
|
id="stop3" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g
|
||||||
|
id="g5"
|
||||||
|
transform="translate(-24.999993,-125)">
|
||||||
|
<rect
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
fill="#e6f2fe"
|
||||||
|
x="24.999992"
|
||||||
|
y="125"
|
||||||
|
width="80"
|
||||||
|
height="80"
|
||||||
|
class="logo-background-square"
|
||||||
|
id="rect1"
|
||||||
|
style="stroke-width:0.266666" />
|
||||||
|
<g
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
id="f64f14cc-5390-468e-9cd6-ee0163dcbf09"
|
||||||
|
stroke="none"
|
||||||
|
fill="url(#d27698e6-eb24-4a3f-96af-256d84d5b3ea)"
|
||||||
|
transform="matrix(0.60062915,0,0,0.60062915,34.968536,148.43645)"
|
||||||
|
style="fill:url(#linearGradient5)">
|
||||||
|
<path
|
||||||
|
d="M 77.959,55.154 H 11.282 C 5.061,55.154 0,50.093 0,43.873 0,38.595 3.643,34.152 8.546,32.927 10.734,27.458 15.794,23.541 21.695,22.838 22.317,10.14 32.843,0 45.693,0 55.11,0 63.515,5.536 67.416,13.756 a 21.944,21.944 0 0 1 10.543,-2.682 c 12.153,0 22.041,9.887 22.041,22.04 0,12.153 -9.887,22.04 -22.041,22.04 z M 11.209,40.372 a 3.507,3.507 0 0 0 -3.429,3.501 3.504,3.504 0 0 0 3.501,3.501 h 66.678 c 7.863,0 14.26,-6.397 14.26,-14.26 0,-7.863 -6.396,-14.259 -14.26,-14.259 -3.683,0 -7.18,1.404 -9.847,3.952 L 63.016,27.678 61.612,20.77 C 60.083,13.243 53.388,7.78 45.693,7.78 c -8.958,0 -16.247,7.288 -16.247,16.246 0,0.728 0.054,1.483 0.159,2.246 l 0.73,5.287 -5.256,-0.923 a 8.59,8.59 0 0 0 -1.474,-0.132 c -4.002,0 -7.479,2.842 -8.267,6.757 l -0.65,3.227 -3.29,-0.106 z"
|
||||||
|
id="path3"
|
||||||
|
style="fill:url(#linearGradient4)" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<!---->
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
1
static/images/files.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M64 192v-72a40 40 0 0 1 40-40h75.89a40 40 0 0 1 22.19 6.72l27.84 18.56a40 40 0 0 0 22.19 6.72H408a40 40 0 0 1 40 40v40"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M479.9 226.55L463.68 392a40 40 0 0 1-39.93 40H88.25a40 40 0 0 1-39.93-40L32.1 226.55A32 32 0 0 1 64 192h384.1a32 32 0 0 1 31.8 34.55Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 564 B |
1
static/images/left-circle.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><path fill="currentColor" d="m273.77 169.57l-89.09 74.13a16 16 0 0 0 0 24.6l89.09 74.13A16 16 0 0 0 300 330.14V181.86a16 16 0 0 0-26.23-12.29Z"/><path fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="32" d="M448 256c0-106-86-192-192-192S64 150 64 256s86 192 192 192s192-86 192-192Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 397 B |
1
static/images/left.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><path fill="currentColor" d="M321.94 98L158.82 237.78a24 24 0 0 0 0 36.44L321.94 414c15.57 13.34 39.62 2.28 39.62-18.22v-279.6c0-20.5-24.05-31.56-39.62-18.18Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 252 B |
5
static/images/loading.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="margin:auto;background:transparent;display:block;" width="200px" height="200px" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
|
||||||
|
<path d="M10 50A40 40 0 0 0 90 50A40 42 0 0 1 10 50" fill="#000000" stroke="none">
|
||||||
|
<animateTransform attributeName="transform" type="rotate" dur="1s" repeatCount="indefinite" keyTimes="0;1" values="0 50 51;360 50 51"></animateTransform>
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 478 B |
BIN
static/images/logo-white.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
121
static/images/logo-white.svg
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
viewBox="0 0 250 50"
|
||||||
|
class="iconLeft"
|
||||||
|
version="1.1"
|
||||||
|
id="svg3"
|
||||||
|
sodipodi:docname="logo-white.svg"
|
||||||
|
inkscape:export-filename="logo-white.png"
|
||||||
|
inkscape:export-xdpi="96"
|
||||||
|
inkscape:export-ydpi="96"
|
||||||
|
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
|
||||||
|
width="250"
|
||||||
|
height="50"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview3"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="1.92"
|
||||||
|
inkscape:cx="127.86458"
|
||||||
|
inkscape:cy="83.854167"
|
||||||
|
inkscape:window-width="1440"
|
||||||
|
inkscape:window-height="831"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="89dc4f32-fdad-40b7-9887-523dc72206dd" />
|
||||||
|
<!---->
|
||||||
|
<defs
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
id="defs1">
|
||||||
|
<!---->
|
||||||
|
<linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#d27698e6-eb24-4a3f-96af-256d84d5b3ea"
|
||||||
|
id="linearGradient4"
|
||||||
|
gradientTransform="scale(1.3465159,0.74265739)"
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="74.265739"
|
||||||
|
y2="0"
|
||||||
|
gradientUnits="userSpaceOnUse" />
|
||||||
|
<linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#d27698e6-eb24-4a3f-96af-256d84d5b3ea"
|
||||||
|
id="linearGradient5"
|
||||||
|
gradientTransform="scale(1.3465159,0.74265739)"
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="74.265739"
|
||||||
|
y2="0"
|
||||||
|
gradientUnits="userSpaceOnUse" />
|
||||||
|
</defs>
|
||||||
|
<defs
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
id="defs2">
|
||||||
|
<!---->
|
||||||
|
</defs>
|
||||||
|
<defs
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
id="defs3">
|
||||||
|
<linearGradient
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
gradientTransform="rotate(25)"
|
||||||
|
id="d27698e6-eb24-4a3f-96af-256d84d5b3ea"
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="1"
|
||||||
|
y2="0">
|
||||||
|
<stop
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
offset="0%"
|
||||||
|
stop-color="#51A4FB"
|
||||||
|
stop-opacity="1"
|
||||||
|
id="stop2" />
|
||||||
|
<stop
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
offset="100%"
|
||||||
|
stop-color="#51A4FB"
|
||||||
|
stop-opacity="1"
|
||||||
|
id="stop3" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g
|
||||||
|
id="g5"
|
||||||
|
transform="translate(-24.999993,-125)">
|
||||||
|
<g
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
id="89dc4f32-fdad-40b7-9887-523dc72206dd"
|
||||||
|
fill="#193655"
|
||||||
|
transform="matrix(2.1033077,0,0,2.1033077,110.39171,132.31118)">
|
||||||
|
<path
|
||||||
|
d="m 5.21,6.06 q 1.08,0 1.93,0.48 0.85,0.47 1.34,1.33 0.48,0.85 0.48,1.97 v 0 q 0,1.12 -0.48,1.98 -0.49,0.86 -1.34,1.34 -0.85,0.48 -1.93,0.48 v 0 Q 4.38,13.64 3.7,13.31 3.01,12.99 2.56,12.38 v 0 1.17 H 1.27 V 3.16 H 2.62 V 7.25 Q 3.07,6.66 3.74,6.36 4.41,6.06 5.21,6.06 Z m -0.11,6.4 q 0.71,0 1.28,-0.33 Q 6.94,11.8 7.27,11.21 7.6,10.61 7.6,9.84 v 0 Q 7.6,9.07 7.27,8.48 6.94,7.88 6.38,7.56 5.81,7.24 5.1,7.24 v 0 Q 4.4,7.24 3.82,7.56 3.25,7.88 2.93,8.48 2.6,9.07 2.6,9.84 v 0 q 0,0.77 0.33,1.37 0.32,0.59 0.89,0.92 0.58,0.33 1.28,0.33 z M 17.53,9.88 q 0,0.16 -0.03,0.41 v 0 h -6.02 q 0.13,0.98 0.86,1.58 0.74,0.59 1.83,0.59 v 0 q 1.33,0 2.14,-0.9 v 0 l 0.74,0.87 q -0.5,0.59 -1.25,0.9 -0.75,0.31 -1.67,0.31 v 0 q -1.18,0 -2.09,-0.49 -0.91,-0.48 -1.41,-1.35 -0.49,-0.87 -0.49,-1.96 v 0 q 0,-1.08 0.48,-1.94 0.48,-0.87 1.33,-1.36 0.85,-0.48 1.91,-0.48 v 0 q 1.06,0 1.9,0.48 0.83,0.49 1.3,1.36 0.47,0.86 0.47,1.98 z M 13.86,7.2 q -0.97,0 -1.62,0.58 -0.65,0.59 -0.76,1.54 v 0 h 4.76 Q 16.13,8.39 15.48,7.79 14.83,7.2 13.86,7.2 Z M 29.27,6.13 h 1.21 l -2.77,7.42 H 26.42 L 24.28,7.92 22.11,13.55 H 20.82 L 18.06,6.13 h 1.27 l 2.16,5.91 2.24,-5.91 h 1.13 l 2.2,5.94 z m 7.05,7.53 q -1.47,0 -2.66,-0.65 -1.18,-0.65 -1.85,-1.79 -0.67,-1.14 -0.67,-2.57 v 0 q 0,-1.43 0.67,-2.57 0.67,-1.14 1.86,-1.79 1.19,-0.65 2.66,-0.65 v 0 q 1.15,0 2.1,0.38 0.95,0.39 1.62,1.15 v 0 L 39.14,6.05 Q 38.04,4.89 36.39,4.89 v 0 q -1.1,0 -1.98,0.49 -0.88,0.49 -1.38,1.35 -0.49,0.86 -0.49,1.92 v 0 q 0,1.07 0.49,1.93 0.5,0.86 1.38,1.35 0.88,0.49 1.98,0.49 v 0 q 1.63,0 2.75,-1.18 v 0 l 0.91,0.88 q -0.67,0.76 -1.63,1.15 -0.96,0.39 -2.1,0.39 z m 5.54,-0.11 V 3.16 h 1.34 v 10.39 z m 7.08,0.09 q -1.1,0 -1.98,-0.49 -0.89,-0.49 -1.38,-1.36 -0.5,-0.86 -0.5,-1.95 v 0 q 0,-1.09 0.5,-1.95 0.49,-0.86 1.38,-1.35 0.88,-0.48 1.98,-0.48 v 0 q 1.11,0 1.98,0.48 0.88,0.49 1.38,1.35 0.49,0.86 0.49,1.95 v 0 q 0,1.09 -0.49,1.95 -0.5,0.87 -1.38,1.36 -0.87,0.49 -1.98,0.49 z m 0,-1.18 q 0.72,0 1.28,-0.33 0.57,-0.33 0.89,-0.92 0.33,-0.6 0.33,-1.37 v 0 Q 51.44,9.07 51.11,8.48 50.79,7.88 50.22,7.56 49.66,7.24 48.94,7.24 v 0 q -0.71,0 -1.28,0.32 -0.56,0.32 -0.89,0.92 -0.33,0.59 -0.33,1.36 v 0 q 0,0.77 0.33,1.37 0.33,0.59 0.89,0.92 0.57,0.33 1.28,0.33 z M 60.23,6.13 h 1.34 v 7.42 H 60.3 v -1.12 q -0.41,0.58 -1.07,0.89 -0.67,0.32 -1.45,0.32 v 0 q -1.49,0 -2.34,-0.82 Q 54.59,12 54.59,10.4 v 0 -4.27 h 1.34 v 4.12 q 0,1.08 0.52,1.63 0.52,0.55 1.48,0.55 v 0 q 1.07,0 1.68,-0.64 0.62,-0.65 0.62,-1.82 v 0 z m 9.56,-2.97 h 1.34 v 10.39 h -1.28 v -1.17 q -0.45,0.61 -1.14,0.93 -0.68,0.33 -1.51,0.33 v 0 q -1.08,0 -1.93,-0.48 -0.86,-0.48 -1.34,-1.34 -0.48,-0.86 -0.48,-1.98 v 0 q 0,-1.12 0.48,-1.97 0.48,-0.86 1.34,-1.33 0.85,-0.48 1.93,-0.48 v 0 q 0.8,0 1.47,0.3 0.67,0.3 1.12,0.89 v 0 z m -2.48,9.3 q 0.7,0 1.28,-0.33 0.57,-0.33 0.89,-0.92 0.32,-0.6 0.32,-1.37 v 0 Q 69.8,9.07 69.48,8.48 69.16,7.88 68.59,7.56 68.01,7.24 67.31,7.24 v 0 q -0.71,0 -1.28,0.32 -0.57,0.32 -0.89,0.92 -0.33,0.59 -0.33,1.36 v 0 q 0,0.77 0.33,1.37 0.32,0.59 0.89,0.92 0.57,0.33 1.28,0.33 z"
|
||||||
|
id="path2"
|
||||||
|
style="fill:#ffffff" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
id="f64f14cc-5390-468e-9cd6-ee0163dcbf09"
|
||||||
|
stroke="none"
|
||||||
|
fill="url(#d27698e6-eb24-4a3f-96af-256d84d5b3ea)"
|
||||||
|
transform="matrix(0.60062915,0,0,0.60062915,40,133.43645)"
|
||||||
|
style="fill:url(#linearGradient5)">
|
||||||
|
<path
|
||||||
|
d="M 77.959,55.154 H 11.282 C 5.061,55.154 0,50.093 0,43.873 0,38.595 3.643,34.152 8.546,32.927 10.734,27.458 15.794,23.541 21.695,22.838 22.317,10.14 32.843,0 45.693,0 55.11,0 63.515,5.536 67.416,13.756 a 21.944,21.944 0 0 1 10.543,-2.682 c 12.153,0 22.041,9.887 22.041,22.04 0,12.153 -9.887,22.04 -22.041,22.04 z M 11.209,40.372 a 3.507,3.507 0 0 0 -3.429,3.501 3.504,3.504 0 0 0 3.501,3.501 h 66.678 c 7.863,0 14.26,-6.397 14.26,-14.26 0,-7.863 -6.396,-14.259 -14.26,-14.259 -3.683,0 -7.18,1.404 -9.847,3.952 L 63.016,27.678 61.612,20.77 C 60.083,13.243 53.388,7.78 45.693,7.78 c -8.958,0 -16.247,7.288 -16.247,16.246 0,0.728 0.054,1.483 0.159,2.246 l 0.73,5.287 -5.256,-0.923 a 8.59,8.59 0 0 0 -1.474,-0.132 c -4.002,0 -7.479,2.842 -8.267,6.757 l -0.65,3.227 -3.29,-0.106 z"
|
||||||
|
id="path3"
|
||||||
|
style="fill:url(#linearGradient4)" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<!---->
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.9 KiB |
BIN
static/images/logo.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
120
static/images/logo.svg
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
viewBox="0 0 250 50"
|
||||||
|
class="iconLeft"
|
||||||
|
version="1.1"
|
||||||
|
id="svg3"
|
||||||
|
sodipodi:docname="logo.svg"
|
||||||
|
inkscape:export-filename="default.png"
|
||||||
|
inkscape:export-xdpi="327.67999"
|
||||||
|
inkscape:export-ydpi="327.67999"
|
||||||
|
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
|
||||||
|
width="250"
|
||||||
|
height="50"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview3"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="1.92"
|
||||||
|
inkscape:cx="127.86458"
|
||||||
|
inkscape:cy="83.854167"
|
||||||
|
inkscape:window-width="1440"
|
||||||
|
inkscape:window-height="831"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg3" />
|
||||||
|
<!---->
|
||||||
|
<defs
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
id="defs1">
|
||||||
|
<!---->
|
||||||
|
<linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#d27698e6-eb24-4a3f-96af-256d84d5b3ea"
|
||||||
|
id="linearGradient4"
|
||||||
|
gradientTransform="scale(1.3465159,0.74265739)"
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="74.265739"
|
||||||
|
y2="0"
|
||||||
|
gradientUnits="userSpaceOnUse" />
|
||||||
|
<linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#d27698e6-eb24-4a3f-96af-256d84d5b3ea"
|
||||||
|
id="linearGradient5"
|
||||||
|
gradientTransform="scale(1.3465159,0.74265739)"
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="74.265739"
|
||||||
|
y2="0"
|
||||||
|
gradientUnits="userSpaceOnUse" />
|
||||||
|
</defs>
|
||||||
|
<defs
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
id="defs2">
|
||||||
|
<!---->
|
||||||
|
</defs>
|
||||||
|
<defs
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
id="defs3">
|
||||||
|
<linearGradient
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
gradientTransform="rotate(25)"
|
||||||
|
id="d27698e6-eb24-4a3f-96af-256d84d5b3ea"
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="1"
|
||||||
|
y2="0">
|
||||||
|
<stop
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
offset="0%"
|
||||||
|
stop-color="#51A4FB"
|
||||||
|
stop-opacity="1"
|
||||||
|
id="stop2" />
|
||||||
|
<stop
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
offset="100%"
|
||||||
|
stop-color="#51A4FB"
|
||||||
|
stop-opacity="1"
|
||||||
|
id="stop3" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g
|
||||||
|
id="g5"
|
||||||
|
transform="translate(-24.999993,-125)">
|
||||||
|
<g
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
id="89dc4f32-fdad-40b7-9887-523dc72206dd"
|
||||||
|
fill="#193655"
|
||||||
|
transform="matrix(2.1033077,0,0,2.1033077,110.39171,132.31118)">
|
||||||
|
<path
|
||||||
|
d="m 5.21,6.06 q 1.08,0 1.93,0.48 0.85,0.47 1.34,1.33 0.48,0.85 0.48,1.97 v 0 q 0,1.12 -0.48,1.98 -0.49,0.86 -1.34,1.34 -0.85,0.48 -1.93,0.48 v 0 Q 4.38,13.64 3.7,13.31 3.01,12.99 2.56,12.38 v 0 1.17 H 1.27 V 3.16 H 2.62 V 7.25 Q 3.07,6.66 3.74,6.36 4.41,6.06 5.21,6.06 Z m -0.11,6.4 q 0.71,0 1.28,-0.33 Q 6.94,11.8 7.27,11.21 7.6,10.61 7.6,9.84 v 0 Q 7.6,9.07 7.27,8.48 6.94,7.88 6.38,7.56 5.81,7.24 5.1,7.24 v 0 Q 4.4,7.24 3.82,7.56 3.25,7.88 2.93,8.48 2.6,9.07 2.6,9.84 v 0 q 0,0.77 0.33,1.37 0.32,0.59 0.89,0.92 0.58,0.33 1.28,0.33 z M 17.53,9.88 q 0,0.16 -0.03,0.41 v 0 h -6.02 q 0.13,0.98 0.86,1.58 0.74,0.59 1.83,0.59 v 0 q 1.33,0 2.14,-0.9 v 0 l 0.74,0.87 q -0.5,0.59 -1.25,0.9 -0.75,0.31 -1.67,0.31 v 0 q -1.18,0 -2.09,-0.49 -0.91,-0.48 -1.41,-1.35 -0.49,-0.87 -0.49,-1.96 v 0 q 0,-1.08 0.48,-1.94 0.48,-0.87 1.33,-1.36 0.85,-0.48 1.91,-0.48 v 0 q 1.06,0 1.9,0.48 0.83,0.49 1.3,1.36 0.47,0.86 0.47,1.98 z M 13.86,7.2 q -0.97,0 -1.62,0.58 -0.65,0.59 -0.76,1.54 v 0 h 4.76 Q 16.13,8.39 15.48,7.79 14.83,7.2 13.86,7.2 Z M 29.27,6.13 h 1.21 l -2.77,7.42 H 26.42 L 24.28,7.92 22.11,13.55 H 20.82 L 18.06,6.13 h 1.27 l 2.16,5.91 2.24,-5.91 h 1.13 l 2.2,5.94 z m 7.05,7.53 q -1.47,0 -2.66,-0.65 -1.18,-0.65 -1.85,-1.79 -0.67,-1.14 -0.67,-2.57 v 0 q 0,-1.43 0.67,-2.57 0.67,-1.14 1.86,-1.79 1.19,-0.65 2.66,-0.65 v 0 q 1.15,0 2.1,0.38 0.95,0.39 1.62,1.15 v 0 L 39.14,6.05 Q 38.04,4.89 36.39,4.89 v 0 q -1.1,0 -1.98,0.49 -0.88,0.49 -1.38,1.35 -0.49,0.86 -0.49,1.92 v 0 q 0,1.07 0.49,1.93 0.5,0.86 1.38,1.35 0.88,0.49 1.98,0.49 v 0 q 1.63,0 2.75,-1.18 v 0 l 0.91,0.88 q -0.67,0.76 -1.63,1.15 -0.96,0.39 -2.1,0.39 z m 5.54,-0.11 V 3.16 h 1.34 v 10.39 z m 7.08,0.09 q -1.1,0 -1.98,-0.49 -0.89,-0.49 -1.38,-1.36 -0.5,-0.86 -0.5,-1.95 v 0 q 0,-1.09 0.5,-1.95 0.49,-0.86 1.38,-1.35 0.88,-0.48 1.98,-0.48 v 0 q 1.11,0 1.98,0.48 0.88,0.49 1.38,1.35 0.49,0.86 0.49,1.95 v 0 q 0,1.09 -0.49,1.95 -0.5,0.87 -1.38,1.36 -0.87,0.49 -1.98,0.49 z m 0,-1.18 q 0.72,0 1.28,-0.33 0.57,-0.33 0.89,-0.92 0.33,-0.6 0.33,-1.37 v 0 Q 51.44,9.07 51.11,8.48 50.79,7.88 50.22,7.56 49.66,7.24 48.94,7.24 v 0 q -0.71,0 -1.28,0.32 -0.56,0.32 -0.89,0.92 -0.33,0.59 -0.33,1.36 v 0 q 0,0.77 0.33,1.37 0.33,0.59 0.89,0.92 0.57,0.33 1.28,0.33 z M 60.23,6.13 h 1.34 v 7.42 H 60.3 v -1.12 q -0.41,0.58 -1.07,0.89 -0.67,0.32 -1.45,0.32 v 0 q -1.49,0 -2.34,-0.82 Q 54.59,12 54.59,10.4 v 0 -4.27 h 1.34 v 4.12 q 0,1.08 0.52,1.63 0.52,0.55 1.48,0.55 v 0 q 1.07,0 1.68,-0.64 0.62,-0.65 0.62,-1.82 v 0 z m 9.56,-2.97 h 1.34 v 10.39 h -1.28 v -1.17 q -0.45,0.61 -1.14,0.93 -0.68,0.33 -1.51,0.33 v 0 q -1.08,0 -1.93,-0.48 -0.86,-0.48 -1.34,-1.34 -0.48,-0.86 -0.48,-1.98 v 0 q 0,-1.12 0.48,-1.97 0.48,-0.86 1.34,-1.33 0.85,-0.48 1.93,-0.48 v 0 q 0.8,0 1.47,0.3 0.67,0.3 1.12,0.89 v 0 z m -2.48,9.3 q 0.7,0 1.28,-0.33 0.57,-0.33 0.89,-0.92 0.32,-0.6 0.32,-1.37 v 0 Q 69.8,9.07 69.48,8.48 69.16,7.88 68.59,7.56 68.01,7.24 67.31,7.24 v 0 q -0.71,0 -1.28,0.32 -0.57,0.32 -0.89,0.92 -0.33,0.59 -0.33,1.36 v 0 q 0,0.77 0.33,1.37 0.32,0.59 0.89,0.92 0.57,0.33 1.28,0.33 z"
|
||||||
|
id="path2" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
data-v-fde0c5aa=""
|
||||||
|
id="f64f14cc-5390-468e-9cd6-ee0163dcbf09"
|
||||||
|
stroke="none"
|
||||||
|
fill="url(#d27698e6-eb24-4a3f-96af-256d84d5b3ea)"
|
||||||
|
transform="matrix(0.60062915,0,0,0.60062915,40,133.43645)"
|
||||||
|
style="fill:url(#linearGradient5)">
|
||||||
|
<path
|
||||||
|
d="M 77.959,55.154 H 11.282 C 5.061,55.154 0,50.093 0,43.873 0,38.595 3.643,34.152 8.546,32.927 10.734,27.458 15.794,23.541 21.695,22.838 22.317,10.14 32.843,0 45.693,0 55.11,0 63.515,5.536 67.416,13.756 a 21.944,21.944 0 0 1 10.543,-2.682 c 12.153,0 22.041,9.887 22.041,22.04 0,12.153 -9.887,22.04 -22.041,22.04 z M 11.209,40.372 a 3.507,3.507 0 0 0 -3.429,3.501 3.504,3.504 0 0 0 3.501,3.501 h 66.678 c 7.863,0 14.26,-6.397 14.26,-14.26 0,-7.863 -6.396,-14.259 -14.26,-14.259 -3.683,0 -7.18,1.404 -9.847,3.952 L 63.016,27.678 61.612,20.77 C 60.083,13.243 53.388,7.78 45.693,7.78 c -8.958,0 -16.247,7.288 -16.247,16.246 0,0.728 0.054,1.483 0.159,2.246 l 0.73,5.287 -5.256,-0.923 a 8.59,8.59 0 0 0 -1.474,-0.132 c -4.002,0 -7.479,2.842 -8.267,6.757 l -0.65,3.227 -3.29,-0.106 z"
|
||||||
|
id="path3"
|
||||||
|
style="fill:url(#linearGradient4)" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<!---->
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.8 KiB |