* stripe env

* stripe env

* stripe env

* allow post

* accounts/**

* accounts/**

* accounts/**

* accounts/**

* accounts/**

* accounts/**

* accounts/**

* accounts/**

* accounts/**

* accounts/**

* accounts/**

* accounts/**

* accounts/**

* accounts/**

* accounts/**

* accounts/**

* accounts/**

* favicon

* foo

* foo

* foo

* foo

* foo

* foo

* title

* fix dashboard timestamp

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* prices

* Revert "prices"

This reverts commit 7071ed4ef4.

* Make sure we don't accidentally commit `kratos.yml`.

* Add Oathkeeper access rules for Stripe.

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* Add `max_breaches` to Kratos's sample config file.

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* payments

* cache .next folder

* Use own fork of Kratos's `master` in order to get the fix for the migrations issue.

* Don't retry running Kratos migrations.

* payments

* restart: no

* no

* no

* no

* no

* no

* no

* no

* no

* no

* payments

* accounts

* accounts

* accounts

* accounts

* accounts

* accounts

* accounts

* accounts

* accounts

* accounts

* accounts

* accounts

* accounts

* accounts

* accounts

* accounts

* accounts

* accounts

* limits

* limits

* nginx depends on accounts and kratos-migrate depends on cockroach.

* upload limit rate

* upload limit rate - 2

* upload limit rate - 3

* upload limit rate - 4

* upload limit rate - 5

* upload limit rate - 6

* upload limit rate - 7

* upload limit rate - 8

* upload limit rate - 9

* forgotten password link

* use header for skylink

* use header for skylink

* use header for skylink

* use header for skylink

* use header for skylink

* use header for skylink

* use header for skylink

* use header for skylink

* copy to clipboard

* fix ratelimit issue

* Allow access to the stripe webhook.

* enable allow_promotion_codes

* Allow POST on webhook.

* Add all env vars accounts need to docker-compose.

* Don't use custom port for accounts.

* print recovery

* recovery sign up link

* refactor cors header response

* refactor cors header response

* do not log unauthorized

* fix registration link

* settings logging

* update node and tailwindcss

* move webapp from volume

* host 0.0.0.0

* refactor dockerfile

* enable accounts

* cache public

* uncache public

* remove cache control

* no-cache

* no cache

* Do not use the person's name for registration.

* add verify route

* add verify route

* add verify route

* Go back to using the stock kratos image.

* add verify route

* fix settings link

* clean up verify flow

* refactor Dockerfile

* Remove first and last name from used traits.

* Remove account verification via email.

* Allow additional properties.

* Cookies and tokens last for 30 days now.

* Rename secure.siasky.net to account.siasky.net.

* redirect secure to account

Co-authored-by: Ivaylo Novakov <inovakov@gmail.com>
Co-authored-by: Ivaylo Novakov <ro-tex@users.noreply.github.com>
This commit is contained in:
Karol Wypchło 2021-04-01 15:15:37 +02:00 committed by GitHub
parent 217dcbe36a
commit fede204c6b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
93 changed files with 6140 additions and 127 deletions

11
.gitignore vendored
View File

@ -53,6 +53,7 @@ typings/
# dotenv environment variable files
.env*
./docker/kratos/config/kratos.yml
# Mac files
.DS_Store
@ -82,3 +83,13 @@ docker/nginx/conf.d/server-override/*
__pycache__
/.idea/
/venv*
# CockroachDB certificates
docker/cockroach/certs/*.crt
docker/cockroach/certs/*.key
docker/kratos/cr_certs/*.crt
docker/kratos/cr_certs/*.key
# Oathkeeper JWKS signing token
docker/kratos/oathkeeper/id_token.jwks.json
/docker/kratos/config/kratos.yml

2
.prettierignore Normal file
View File

@ -0,0 +1,2 @@
/package.json
/package-lock.json

152
README.md
View File

@ -26,12 +26,160 @@ following Siacoin address:
`fb6c9320bc7e01fbb9cd8d8c3caaa371386928793c736837832e634aaaa484650a3177d6714a`
### MongoDB Setup
Mongo needs a couple of extra steps in order to start a secure cluster.
- Open port 27017 on all nodes that will take part in the cluster. Ideally, you would only open the port for the other
nodes in the cluster.
- Manually run an initialisation `docker run` with extra environment variables that will initialise the admin user with
a password (example below).
- Manually add a `mgkey` file under `./docker/data/mongo` with the respective secret (
see [Mongo's keyfile access control](https://docs.mongodb.com/manual/tutorial/enforce-keyfile-access-control-in-existing-replica-set/)
for details).
- During the initialisation run mentioned above, we need to make two extra steps within the container:
- Change the ownership of `mgkey` to `mongodb:mongodb`
- Change its permissions to 400
- After these steps are done we can open a mongo shell on the master node and run `rs.add()` in order to add the new
node to the cluster.
Example initialisation docker run command:
```
docker run \
--rm \
--name mg \
-p 27017:27017 \
-e MONGO_INITDB_ROOT_USERNAME=<admin username> \
-e MONGO_INITDB_ROOT_PASSWORD=<admin password> \
-v /home/user/skynet-webportal/docker/data/mongo/db:/data/db \
-v /home/user/skynet-webportal/docker/data/mongo/mgkey:/data/mgkey \
mongo --keyFile=/data/mgkey --replSet=skynet
```
Regular docker run command:
```
docker run \
--rm \
--name mg \
-p 27017:27017 \
-v /home/user/skynet-webportal/docker/data/mongo/db:/data/db \
-v /home/user/skynet-webportal/docker/data/mongo/mgkey:/data/mgkey \
mongo --keyFile=/data/mgkey --replSet=skynet
```
Cluster initialisation mongo command:
```
rs.initiate(
{
_id : "skynet",
members: [
{ _id : 0, host : "mongo:27017" }
]
}
)
```
Add more nodes when they are ready:
```
rs.add("second.node.net:27017")
```
### Kratos & Oathkeeper Setup
[Kratos](https://www.ory.sh/kratos) is our user management system of choice and
[Oathkeeper](https://www.ory.sh/oathkeeper) is the identity and access proxy.
Most of the needed config is already under `docker/kratos`. The only two things that need to be changed are the config
for Kratos that might contain you email server password, and the JWKS Oathkeeper uses to sign its JWT tokens.
Make sure to create your own`docker/kratos/config/kratos.yml` by copying the `kratos.yml.sample` in the same directory.
Also make sure to never add that file to source control because it will most probably contain your email password in
plain text!
To override the JWKS you will need to directly edit
`docker/kratos/oathkeeper/id_token.jwks.json` and replace it with your generated key set. If you don't know how to
generate a key set you can use this code:
```go
package main
import (
"encoding/json"
"log"
"os"
"github.com/ory/hydra/jwk"
)
func main() {
gen := jwk.RS256Generator{
KeyLength: 2048,
}
jwks, err := gen.Generate("", "sig")
if err != nil {
log.Fatal(err)
}
jsonbuf, err := json.MarshalIndent(jwks, "", " ")
if err != nil {
log.Fatal("failed to generate JSON: %s", err)
}
os.Stdout.Write(jsonbuf)
}
```
While you can directly put the output of this programme into the file mentioned above, you can also remove the public
key from the set and change the `kid` of the private key to not include the prefix `private:`.
### CockroachDB Setup
Kratos uses CockroachDB to store its data. For that data to be shared across all nodes that comprise your portal cluster
setup, we need to set up a CockroachDB cluster, complete with secure communication.
#### Generate the certificates for secure communication
For a detailed walk-through, please check [this guide](https://www.cockroachlabs.com/docs/v20.2/secure-a-cluster.html)
out.
Steps:
1. Start a local cockroach docker instance:
`docker run -d -v "<local dir>:/cockroach/cockroach-secure" --name=crdb cockroachdb/cockroach start --insecure`
1. Get a shall into that instance: `docker exec -it crdb /bin/bash`
1. Go to the directory we which we mapped to a local dir: `cd /cockroach/cockroach-secure`
1. Create the subdirectories in which to create certificates and keys: `mkdir certs my-safe-directory`
1. Create the CA (Certificate Authority) certificate and key
pair: `cockroach cert create-ca --certs-dir=certs --ca-key=my-safe-directory/ca.key`
1. Create a client certificate and key pair for the root
user: `cockroach cert create-client root --certs-dir=certs --ca-key=my-safe-directory/ca.key`
1. Create the certificate and key pair for your
nodes: `cockroach cert create-node cockroach mynode.siasky.net --certs-dir=certs --ca-key=my-safe-directory/ca.key`.
Don't forget the `cockroach` node name - it's needed by our docker-compose setup. If you want to create certificates
for more nodes, just delete the `node.*` files (after you've finished the next steps for this node!) and re-run the
above command with the new node name.
1. Put the contents of the `certs` folder under `docker/cockroach/certs/*` under your portal's root dir and store the
content of `my-safe-directory` somewhere safe.
1. Put _another copy_ of those certificates under `docker/kratos/cr_certs` and change permissions of the `*.key` files,
so they can be read by anyone (644).
#### Configure your CockroachDB node
There is some configuration that needs to be added to your `.env`file, namely:
1. CR_NODE - the name of your node
1. CR_IP - the public IP of your node
1. CR_CLUSTER_NODES - a list of IPs and ports which make up your cluster, e.g.
`95.216.13.185:26257,147.135.37.21:26257,144.76.136.122:26257`. This will be the list of nodes that will make up your
cluster, so make sure those are accurate.
## Contributing
### Testing Your Code
Before pushing your code you should verify that it will pass our online test
suite.
Before pushing your code, you should verify that it will pass our online test suite.
**Cypress Tests**
Verify the Cypress test suite by doing the following:

167
docker-compose.accounts.yml Normal file
View File

@ -0,0 +1,167 @@
version: "3.7"
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
webapp:
build:
args:
WITH_ACCOUNTS: 1 # enable accounts frontend
nginx:
environment:
- ACCOUNTS_ENABLED=1
volumes:
- ./docker/accounts/nginx.account.conf:/etc/nginx/conf.extra.d/nginx.account.conf:ro
depends_on:
- accounts
accounts:
build:
context: ./docker/accounts
dockerfile: Dockerfile
container_name: accounts
restart: unless-stopped
logging: *default-logging
env_file:
- .env
environment:
- SKYNET_DB_HOST=${SKYNET_DB_HOST}
- SKYNET_DB_PORT=${SKYNET_DB_PORT}
- SKYNET_DB_USER=${SKYNET_DB_USER}
- SKYNET_DB_PASS=${SKYNET_DB_PASS}
- COOKIE_DOMAIN=${COOKIE_DOMAIN}
- COOKIE_HASH_KEY=${COOKIE_HASH_KEY}
- COOKIE_ENC_KEY=${COOKIE_ENC_KEY}
- STRIPE_API_KEY=${STRIPE_API_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- SKYNET_ACCOUNTS_LOG_LEVEL=${SKYNET_ACCOUNTS_LOG_LEVEL}
- KRATOS_ADDR=${KRATOS_ADDR}
- OATHKEEPER_ADDR=${OATHKEEPER_ADDR}
expose:
- 3000
networks:
shared:
ipv4_address: 10.10.10.70
depends_on:
- mongo
- oathkeeper
mongo:
image: mongo:4.4.1
command: --keyFile=/data/mgkey --replSet=skynet
container_name: mongo
restart: unless-stopped
logging: *default-logging
volumes:
- ./docker/data/mongo/db:/data/db
- ./docker/data/mongo/mgkey:/data/mgkey:rw
networks:
shared:
ipv4_address: 10.10.10.71
ports:
- "27017:27017"
kratos-migrate:
image: oryd/kratos:v0.5.5-alpha.1
container_name: kratos-migrate
restart: "no"
logging: *default-logging
environment:
- DSN=cockroach://root@cockroach:26257/defaultdb?max_conns=20&max_idle_conns=4&sslmode=verify-full&sslcert=/certs/node.crt&sslkey=/certs/node.key&sslrootcert=/certs/ca.crt
- SQA_OPT_OUT=true
volumes:
- ./docker/kratos/config:/etc/config/kratos
- ./docker/data/cockroach/sqlite:/var/lib/sqlite
- ./docker/kratos/cr_certs:/certs
command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes
networks:
shared:
ipv4_address: 10.10.10.80
depends_on:
- cockroach
kratos:
image: oryd/kratos:v0.5.5-alpha.1
container_name: kratos
restart: unless-stopped
logging: *default-logging
expose:
- 4433 # public
- 4434 # admin
environment:
- DSN=cockroach://root@cockroach:26257/defaultdb?max_conns=20&max_idle_conns=4&sslmode=verify-full&sslcert=/certs/node.crt&sslkey=/certs/node.key&sslrootcert=/certs/ca.crt
- LOG_LEVEL=trace
- SERVE_PUBLIC_BASE_URL=${SKYNET_DASHBOARD_URL}/.ory/kratos/public/
- SQA_OPT_OUT=true
command: serve -c /etc/config/kratos/kratos.yml
volumes:
- ./docker/kratos/config:/etc/config/kratos
- ./docker/data/cockroach/sqlite:/var/lib/sqlite
- ./docker/kratos/cr_certs:/certs
networks:
shared:
ipv4_address: 10.10.10.81
depends_on:
- kratos-migrate
dashboard:
build:
context: ./packages/dashboard
dockerfile: Dockerfile
container_name: dashboard
restart: unless-stopped
logging: *default-logging
env_file:
- .env
environment:
- NEXT_PUBLIC_SKYNET_PORTAL_API=${SKYNET_PORTAL_API}
- NEXT_PUBLIC_SKYNET_DASHBOARD_URL=${SKYNET_DASHBOARD_URL}
- NEXT_PUBLIC_KRATOS_BROWSER_URL=${SKYNET_DASHBOARD_URL}/.ory/kratos/public
- NEXT_PUBLIC_KRATOS_PUBLIC_URL=${SKYNET_DASHBOARD_URL}/.ory/kratos/public
- NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY}
volumes:
- ./docker/data/dashboard/.next:/usr/app/.next
networks:
shared:
ipv4_address: 10.10.10.85
expose:
- 3000
oathkeeper:
image: oryd/oathkeeper:v0.38
container_name: oathkeeper
expose:
- 4455
- 4456
command: serve proxy -c "/etc/config/oathkeeper/oathkeeper.yml"
environment:
- LOG_LEVEL=debug
volumes:
- ./docker/kratos/oathkeeper:/etc/config/oathkeeper
restart: on-failure
networks:
shared:
ipv4_address: 10.10.10.83
depends_on:
- kratos
cockroach:
image: cockroachdb/cockroach:v20.2.3
container_name: cockroach
env_file:
- .env
command: start --advertise-addr=${CR_IP} --join=${CR_CLUSTER_NODES} --certs-dir=/certs --listen-addr=0.0.0.0:26257 --http-addr=0.0.0.0:8080
volumes:
- ./docker/data/cockroach/sqlite:/cockroach/cockroach-data
- ./docker/cockroach/certs:/certs
ports:
- "4080:8080"
- "26257:26257"
networks:
shared:
ipv4_address: 10.10.10.84

View File

@ -0,0 +1,12 @@
version: "3.7"
services:
nginx:
build:
context: ./docker/nginx
dockerfile: Dockerfile.bionic
args:
RESTY_ADD_PACKAGE_BUILDDEPS: git
RESTY_EVAL_PRE_CONFIGURE: git clone https://github.com/fdintino/nginx-upload-module /tmp/nginx-upload-module
RESTY_CONFIG_OPTIONS_MORE: --add-module=/tmp/nginx-upload-module
RESTY_EVAL_POST_MAKE: /usr/local/openresty/luajit/bin/luarocks install luasocket

View File

@ -13,9 +13,6 @@ networks:
config:
- subnet: 10.10.10.0/24
volumes:
webapp:
services:
sia:
build:
@ -76,7 +73,6 @@ services:
- ./docker/data/nginx/logs:/usr/local/openresty/nginx/logs
- ./docker/data/nginx/skynet:/data/nginx/skynet:ro
- ./docker/data/sia/apipassword:/data/sia/apipassword:ro
- webapp:/var/www/webportal:ro
networks:
shared:
ipv4_address: 10.10.10.30
@ -94,9 +90,13 @@ services:
container_name: webapp
restart: unless-stopped
logging: *default-logging
tty: true
volumes:
- webapp:/usr/app/public
- ./docker/data/webapp/.cache:/usr/app/.cache
networks:
shared:
ipv4_address: 10.10.10.35
expose:
- 9000
handshake:
build:
@ -163,18 +163,3 @@ services:
depends_on:
- handshake
- handshake-api
mongo:
image: mongo:4.4.1
command: --keyFile=/data/mgkey --replSet=skynet
container_name: mongo
restart: unless-stopped
logging: *default-logging
volumes:
- ./docker/data/mongo/db:/data/db
- ./docker/data/mongo/mgkey:/data/mgkey:rw
networks:
shared:
ipv4_address: 10.10.10.70
ports:
- "27017:27017"

View File

@ -0,0 +1,20 @@
FROM golang:1.15
LABEL maintainer="NebulousLabs <devs@nebulous.tech>"
ENV GOOS linux
ENV GOARCH amd64
WORKDIR /root
RUN git clone --single-branch --branch main https://github.com/NebulousLabs/skynet-accounts.git && \
cd skynet-accounts && \
go mod download && \
make release
ENV SKYNET_DB_HOST="localhost"
ENV SKYNET_DB_PORT="27017"
ENV SKYNET_DB_USER="username"
ENV SKYNET_DB_PASS="password"
ENV SKYNET_ACCOUNTS_PORT=3000
ENTRYPOINT ["skynet-accounts"]

View File

@ -0,0 +1,20 @@
server {
listen 80;
listen [::]:80;
server_name account.*;
location / {
proxy_redirect http://127.0.0.1/ https://$host/;
proxy_pass http://oathkeeper:4455;
}
}
server {
listen 80;
listen [::]:80;
server_name secure.*;
if ($host ~ secure.(.*)) {
return 301 $scheme://account.$1$request_uri;
}
}

View File

@ -0,0 +1,2 @@
This directory needs to contain all certificates needed by this cockroachdb node. Those can be generated by the steps
outlined in the README in the root directory, under "Setting up CockroachDB".

View File

@ -1,8 +1,8 @@
FROM node:15.8.0-alpine
FROM node:15.12.0-alpine
WORKDIR /opt/hsd
RUN apk add --no-cache bash unbound-dev gmp-dev g++ gcc make python2 git
RUN apk update && apk add bash unbound-dev gmp-dev g++ gcc make python2 git
RUN git clone https://github.com/handshake-org/hsd.git /opt/hsd
RUN npm install --production

View File

@ -0,0 +1,31 @@
{
"$id": "https://schemas.ory.sh/presets/kratos/quickstart/email-password/identity.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"title": "E-Mail",
"minLength": 3,
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
},
"recovery": {
"via": "email"
}
}
}
},
"required": ["email"],
"additionalProperties": true
}
}
}

View File

@ -0,0 +1,86 @@
version: v0.5.5-alpha.1
dsn: memory
serve:
public:
base_url: http://127.0.0.1/
cors:
enabled: true
admin:
base_url: http://127.0.0.1/admin/
selfservice:
default_browser_return_url: http://127.0.0.1/
whitelisted_return_urls:
- http://127.0.0.1/
methods:
password:
enabled: true
flows:
error:
ui_url: http://127.0.0.1/error
settings:
ui_url: http://127.0.0.1/settings
privileged_session_max_age: 15m
recovery:
enabled: true
ui_url: http://127.0.0.1/recovery
verification:
enabled: true
ui_url: http://127.0.0.1/verify
after:
default_browser_return_url: http://127.0.0.1/
logout:
after:
default_browser_return_url: http://127.0.0.1/auth/login
login:
ui_url: http://127.0.0.1/auth/login
lifespan: 10m
registration:
lifespan: 10m
ui_url: http://127.0.0.1/auth/registration
after:
password:
hooks:
- hook: session
log:
level: debug
format: text
leak_sensitive_values: true
password:
max_breaches: 100
secrets:
cookie:
- PLEASE-CHANGE-ME-I-AM-VERY-INSECURE
session:
cookie:
domain: account.siasky.net
lifespan: "720h"
hashers:
argon2:
parallelism: 1
memory: 131072
iterations: 2
salt_length: 16
key_length: 16
identity:
default_schema_url: file:///etc/config/kratos/identity.schema.json
courier:
smtp:
connection_uri: smtps://test:test@mailslurper:1025/?skip_ssl_verify=true

View File

@ -0,0 +1,37 @@
{
"$id": "https://schemas.ory.sh/presets/kratos/quickstart/email-password/identity.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"title": "E-Mail",
"minLength": 3,
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
},
"verification": {
"via": "email"
},
"recovery": {
"via": "email"
}
}
},
"website": {
"type": "object"
}
},
"required": ["website", "email"],
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,17 @@
local claims = {
email_verified: false
} + std.extVar('claims');
{
identity: {
traits: {
// Allowing unverified email addresses enables account
// enumeration attacks, especially if the value is used for
// e.g. verification or as a password login identifier.
//
// Therefore we only return the email if it (a) exists and (b) is marked verified
// by GitHub.
[if "email" in claims && claims.email_verified then "email" else null]: claims.email,
},
},
}

View File

@ -0,0 +1,7 @@
This directory needs to contain all certificates needed by this cockroachdb node. Those can be generated by the steps
outlined in the README in the root directory, under "Setting up CockroachDB".
The only difference between the files here and those under
`docker/cockroach/certs` is that the files here need to be readable by anyone, while the files under `cockroach` need to
have their original access rights
(all *.key files should be 600 instead of 644 there).

View File

@ -0,0 +1,116 @@
- id: "ory:kratos:public"
upstream:
preserve_host: true
url: "http://kratos:4433"
strip_path: /.ory/kratos/public
match:
url: "http://oathkeeper:4455/.ory/kratos/public/<**>"
methods:
- GET
- POST
- PUT
- DELETE
- PATCH
authenticators:
- handler: noop
authorizer:
handler: allow
mutators:
- handler: noop
- id: "dashboard:anonymous"
upstream:
preserve_host: true
url: "http://dashboard:3000"
match:
url: "http://oathkeeper:4455/<{_next/*,auth/*,recovery,verify,error,favicon.ico}{/,}>"
methods:
- GET
authenticators:
- handler: anonymous
authorizer:
handler: allow
mutators:
- handler: noop
- id: "dashboard:protected"
upstream:
preserve_host: true
url: "http://dashboard:3000"
match:
url: "http://oathkeeper:4455/<{,api/*,settings,uploads,downloads,payments}>"
methods:
- GET
- POST
- PUT
- DELETE
- PATCH
authenticators:
- handler: cookie_session
authorizer:
handler: allow
mutators:
- handler: id_token
- handler: header
config:
headers:
X-User: "{{ print .Subject }}"
errors:
- handler: redirect
config:
to: http://127.0.0.1/auth/login
- id: "accounts:anonymous"
upstream:
preserve_host: true
url: "http://accounts:3000"
match:
url: "http://oathkeeper<{,:4455}>/<{stripe/prices,stripe/webhook}>"
methods:
- GET
- POST
authenticators:
- handler: anonymous
authorizer:
handler: allow
mutators:
- handler: noop
- id: "accounts:public"
upstream:
preserve_host: true
url: "http://accounts:3000"
match:
url: "http://oathkeeper<{,:4455}>/<{user/limits}>"
methods:
- GET
authenticators:
- handler: cookie_session
- handler: noop
authorizer:
handler: allow
mutators:
- handler: id_token
- id: "accounts:protected"
upstream:
preserve_host: true
url: "http://accounts:3000"
match:
url: "http://oathkeeper<{,:4455}>/<{login,logout,user,user/uploads,user/downloads,user/stats}>"
methods:
- GET
- POST
- PUT
- DELETE
- PATCH
authenticators:
- handler: cookie_session
authorizer:
handler: allow
mutators:
- handler: id_token
errors:
- handler: redirect
config:
to: http://127.0.0.1/auth/login

View File

@ -0,0 +1,94 @@
log:
level: debug
format: json
serve:
proxy:
cors:
enabled: true
allowed_origins:
- "*"
allowed_methods:
- POST
- GET
- PUT
- PATCH
- DELETE
allowed_headers:
- Authorization
- Content-Type
exposed_headers:
- Content-Type
allow_credentials: true
debug: true
errors:
fallback:
- json
handlers:
redirect:
enabled: true
config:
to: http://127.0.0.1/auth/login
when:
- error:
- unauthorized
- forbidden
request:
header:
accept:
- text/html
json:
enabled: true
config:
verbose: true
access_rules:
matching_strategy: glob
repositories:
- file:///etc/config/oathkeeper/access-rules.yml
authenticators:
anonymous:
enabled: true
config:
subject: guest
cookie_session:
enabled: true
config:
check_session_url: http://kratos:4433/sessions/whoami
preserve_path: true
extra_from: "@this"
subject_from: "identity.id"
only:
- ory_kratos_session
noop:
enabled: true
authorizers:
allow:
enabled: true
mutators:
noop:
enabled: true
header:
enabled: true
config:
headers:
X-User: "{{ print .Subject }}"
id_token:
enabled: true
config:
issuer_url: http://oathkeeper:4455/
jwks_url: file:///etc/config/oathkeeper/id_token.jwks.json
ttl: 720h
claims: |
{
"session": {{ .Extra | toJson }}
}

View File

@ -1,7 +1,7 @@
FROM openresty/openresty:1.19.3.1-2-bionic
# RUN apt-get update -qq && apt-get install cron logrotate -qq
# RUN luarocks install luasocket
RUN luarocks install luasocket
# CMD ["sh", "-c", "service cron start;", "/usr/local/openresty/bin/openresty -g daemon off;"]
CMD ["/usr/local/openresty/bin/openresty", "-g", "daemon off;"]

View File

@ -0,0 +1,185 @@
# Dockerfile - Ubuntu Bionic
# https://github.com/openresty/docker-openresty
ARG RESTY_IMAGE_BASE="ubuntu"
ARG RESTY_IMAGE_TAG="bionic"
FROM ${RESTY_IMAGE_BASE}:${RESTY_IMAGE_TAG}
LABEL maintainer="Evan Wies <evan@neomantra.net>"
# Docker Build Arguments
ARG RESTY_IMAGE_BASE="ubuntu"
ARG RESTY_IMAGE_TAG="bionic"
ARG RESTY_VERSION="1.19.3.1"
ARG RESTY_LUAROCKS_VERSION="3.5.0"
ARG RESTY_OPENSSL_VERSION="1.1.1i"
ARG RESTY_OPENSSL_PATCH_VERSION="1.1.1f"
ARG RESTY_OPENSSL_URL_BASE="https://www.openssl.org/source"
ARG RESTY_PCRE_VERSION="8.44"
ARG RESTY_J="1"
ARG RESTY_CONFIG_OPTIONS="\
--with-compat \
--with-file-aio \
--with-http_addition_module \
--with-http_auth_request_module \
--with-http_dav_module \
--with-http_flv_module \
--with-http_geoip_module=dynamic \
--with-http_gunzip_module \
--with-http_gzip_static_module \
--with-http_image_filter_module=dynamic \
--with-http_mp4_module \
--with-http_random_index_module \
--with-http_realip_module \
--with-http_secure_link_module \
--with-http_slice_module \
--with-http_ssl_module \
--with-http_stub_status_module \
--with-http_sub_module \
--with-http_v2_module \
--with-http_xslt_module=dynamic \
--with-ipv6 \
--with-mail \
--with-mail_ssl_module \
--with-md5-asm \
--with-pcre-jit \
--with-sha1-asm \
--with-stream \
--with-stream_ssl_module \
--with-threads \
"
ARG RESTY_CONFIG_OPTIONS_MORE=""
ARG RESTY_LUAJIT_OPTIONS="--with-luajit-xcflags='-DLUAJIT_NUMMODE=2 -DLUAJIT_ENABLE_LUA52COMPAT'"
ARG RESTY_ADD_PACKAGE_BUILDDEPS=""
ARG RESTY_ADD_PACKAGE_RUNDEPS=""
ARG RESTY_EVAL_PRE_CONFIGURE=""
ARG RESTY_EVAL_POST_MAKE=""
# These are not intended to be user-specified
ARG _RESTY_CONFIG_DEPS="--with-pcre \
--with-cc-opt='-DNGX_LUA_ABORT_AT_PANIC -I/usr/local/openresty/pcre/include -I/usr/local/openresty/openssl/include' \
--with-ld-opt='-L/usr/local/openresty/pcre/lib -L/usr/local/openresty/openssl/lib -Wl,-rpath,/usr/local/openresty/pcre/lib:/usr/local/openresty/openssl/lib' \
"
LABEL resty_image_base="${RESTY_IMAGE_BASE}"
LABEL resty_image_tag="${RESTY_IMAGE_TAG}"
LABEL resty_version="${RESTY_VERSION}"
LABEL resty_luarocks_version="${RESTY_LUAROCKS_VERSION}"
LABEL resty_openssl_version="${RESTY_OPENSSL_VERSION}"
LABEL resty_openssl_patch_version="${RESTY_OPENSSL_PATCH_VERSION}"
LABEL resty_openssl_url_base="${RESTY_OPENSSL_URL_BASE}"
LABEL resty_pcre_version="${RESTY_PCRE_VERSION}"
LABEL resty_config_options="${RESTY_CONFIG_OPTIONS}"
LABEL resty_config_options_more="${RESTY_CONFIG_OPTIONS_MORE}"
LABEL resty_config_deps="${_RESTY_CONFIG_DEPS}"
LABEL resty_add_package_builddeps="${RESTY_ADD_PACKAGE_BUILDDEPS}"
LABEL resty_add_package_rundeps="${RESTY_ADD_PACKAGE_RUNDEPS}"
LABEL resty_eval_pre_configure="${RESTY_EVAL_PRE_CONFIGURE}"
LABEL resty_eval_post_make="${RESTY_EVAL_POST_MAKE}"
RUN DEBIAN_FRONTEND=noninteractive apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
curl \
gettext-base \
libgd-dev \
libgeoip-dev \
libncurses5-dev \
libperl-dev \
libreadline-dev \
libxslt1-dev \
make \
perl \
unzip \
zlib1g-dev \
${RESTY_ADD_PACKAGE_BUILDDEPS} \
${RESTY_ADD_PACKAGE_RUNDEPS} \
&& cd /tmp \
&& if [ -n "${RESTY_EVAL_PRE_CONFIGURE}" ]; then eval $(echo ${RESTY_EVAL_PRE_CONFIGURE}); fi \
&& curl -fSL "${RESTY_OPENSSL_URL_BASE}/openssl-${RESTY_OPENSSL_VERSION}.tar.gz" -o openssl-${RESTY_OPENSSL_VERSION}.tar.gz \
&& tar xzf openssl-${RESTY_OPENSSL_VERSION}.tar.gz \
&& cd openssl-${RESTY_OPENSSL_VERSION} \
&& if [ $(echo ${RESTY_OPENSSL_VERSION} | cut -c 1-5) = "1.1.1" ] ; then \
echo 'patching OpenSSL 1.1.1 for OpenResty' \
&& curl -s https://raw.githubusercontent.com/openresty/openresty/master/patches/openssl-${RESTY_OPENSSL_PATCH_VERSION}-sess_set_get_cb_yield.patch | patch -p1 ; \
fi \
&& if [ $(echo ${RESTY_OPENSSL_VERSION} | cut -c 1-5) = "1.1.0" ] ; then \
echo 'patching OpenSSL 1.1.0 for OpenResty' \
&& curl -s https://raw.githubusercontent.com/openresty/openresty/ed328977028c3ec3033bc25873ee360056e247cd/patches/openssl-1.1.0j-parallel_build_fix.patch | patch -p1 \
&& curl -s https://raw.githubusercontent.com/openresty/openresty/master/patches/openssl-${RESTY_OPENSSL_PATCH_VERSION}-sess_set_get_cb_yield.patch | patch -p1 ; \
fi \
&& ./config \
no-threads shared zlib -g \
enable-ssl3 enable-ssl3-method \
--prefix=/usr/local/openresty/openssl \
--libdir=lib \
-Wl,-rpath,/usr/local/openresty/openssl/lib \
&& make -j${RESTY_J} \
&& make -j${RESTY_J} install_sw \
&& cd /tmp \
&& curl -fSL https://ftp.pcre.org/pub/pcre/pcre-${RESTY_PCRE_VERSION}.tar.gz -o pcre-${RESTY_PCRE_VERSION}.tar.gz \
&& tar xzf pcre-${RESTY_PCRE_VERSION}.tar.gz \
&& cd /tmp/pcre-${RESTY_PCRE_VERSION} \
&& ./configure \
--prefix=/usr/local/openresty/pcre \
--disable-cpp \
--enable-jit \
--enable-utf \
--enable-unicode-properties \
&& make -j${RESTY_J} \
&& make -j${RESTY_J} install \
&& cd /tmp \
&& curl -fSL https://openresty.org/download/openresty-${RESTY_VERSION}.tar.gz -o openresty-${RESTY_VERSION}.tar.gz \
&& tar xzf openresty-${RESTY_VERSION}.tar.gz \
&& cd /tmp/openresty-${RESTY_VERSION} \
&& eval ./configure -j${RESTY_J} ${_RESTY_CONFIG_DEPS} ${RESTY_CONFIG_OPTIONS} ${RESTY_CONFIG_OPTIONS_MORE} ${RESTY_LUAJIT_OPTIONS} \
&& make -j${RESTY_J} \
&& make -j${RESTY_J} install \
&& cd /tmp \
&& rm -rf \
openssl-${RESTY_OPENSSL_VERSION}.tar.gz openssl-${RESTY_OPENSSL_VERSION} \
pcre-${RESTY_PCRE_VERSION}.tar.gz pcre-${RESTY_PCRE_VERSION} \
openresty-${RESTY_VERSION}.tar.gz openresty-${RESTY_VERSION} \
&& curl -fSL https://luarocks.github.io/luarocks/releases/luarocks-${RESTY_LUAROCKS_VERSION}.tar.gz -o luarocks-${RESTY_LUAROCKS_VERSION}.tar.gz \
&& tar xzf luarocks-${RESTY_LUAROCKS_VERSION}.tar.gz \
&& cd luarocks-${RESTY_LUAROCKS_VERSION} \
&& ./configure \
--prefix=/usr/local/openresty/luajit \
--with-lua=/usr/local/openresty/luajit \
--lua-suffix=jit-2.1.0-beta3 \
--with-lua-include=/usr/local/openresty/luajit/include/luajit-2.1 \
&& make build \
&& make install \
&& cd /tmp \
&& if [ -n "${RESTY_EVAL_POST_MAKE}" ]; then eval $(echo ${RESTY_EVAL_POST_MAKE}); fi \
&& rm -rf luarocks-${RESTY_LUAROCKS_VERSION} luarocks-${RESTY_LUAROCKS_VERSION}.tar.gz \
&& if [ -n "${RESTY_ADD_PACKAGE_BUILDDEPS}" ]; then DEBIAN_FRONTEND=noninteractive apt-get remove -y --purge ${RESTY_ADD_PACKAGE_BUILDDEPS} ; fi \
&& DEBIAN_FRONTEND=noninteractive apt-get autoremove -y \
&& mkdir -p /var/run/openresty \
&& ln -sf /dev/stdout /usr/local/openresty/nginx/logs/access.log \
&& ln -sf /dev/stderr /usr/local/openresty/nginx/logs/error.log
# Add additional binaries into PATH for convenience
ENV PATH=$PATH:/usr/local/openresty/luajit/bin:/usr/local/openresty/nginx/sbin:/usr/local/openresty/bin
# Add LuaRocks paths
# If OpenResty changes, these may need updating:
# /usr/local/openresty/bin/resty -e 'print(package.path)'
# /usr/local/openresty/bin/resty -e 'print(package.cpath)'
ENV LUA_PATH="/usr/local/openresty/site/lualib/?.ljbc;/usr/local/openresty/site/lualib/?/init.ljbc;/usr/local/openresty/lualib/?.ljbc;/usr/local/openresty/lualib/?/init.ljbc;/usr/local/openresty/site/lualib/?.lua;/usr/local/openresty/site/lualib/?/init.lua;/usr/local/openresty/lualib/?.lua;/usr/local/openresty/lualib/?/init.lua;./?.lua;/usr/local/openresty/luajit/share/luajit-2.1.0-beta3/?.lua;/usr/local/share/lua/5.1/?.lua;/usr/local/share/lua/5.1/?/init.lua;/usr/local/openresty/luajit/share/lua/5.1/?.lua;/usr/local/openresty/luajit/share/lua/5.1/?/init.lua"
ENV LUA_CPATH="/usr/local/openresty/site/lualib/?.so;/usr/local/openresty/lualib/?.so;./?.so;/usr/local/lib/lua/5.1/?.so;/usr/local/openresty/luajit/lib/lua/5.1/?.so;/usr/local/lib/lua/5.1/loadall.so;/usr/local/openresty/luajit/lib/lua/5.1/?.so"
# Copy nginx configuration files
COPY nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
COPY nginx.vh.default.conf /etc/nginx/conf.d/default.conf
CMD ["/usr/local/openresty/bin/openresty", "-g", "daemon off;"]
# Use SIGQUIT instead of default SIGTERM to cleanly drain requests
# See https://github.com/openresty/docker-openresty/blob/master/README.md#tips--pitfalls
STOPSIGNAL SIGQUIT

View File

@ -24,8 +24,8 @@ limit_conn_zone $binary_remote_addr zone=downloads_by_ip:10m;
limit_req_status 429;
limit_conn_status 429;
# since we are proxying request to nginx from caddy, access logs will contain caddy's ip address
# as the request address so we need to use real_ip_header module to use ip address from
# since we are proxying request to nginx from caddy, access logs will contain caddy's ip address
# as the request address so we need to use real_ip_header module to use ip address from
# X-Forwarded-For header as a real ip address of the request
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 127.0.0.1/32;
@ -57,6 +57,7 @@ server {
rewrite ^/portals /skynet/portals permanent;
rewrite ^/stats /skynet/stats permanent;
rewrite ^/skynet/blacklist /skynet/blocklist permanent;
rewrite ^/account/(.*) https://account.$domain.$tld/$1 permanent;
location / {
# This is only safe workaround to reroute based on some conditions
@ -77,7 +78,7 @@ server {
include /etc/nginx/conf.d/include/cors;
root /var/www/webportal;
proxy_pass http://webapp:9000;
}
location /docs {
@ -113,11 +114,11 @@ server {
local file_exists = io.open("/data/nginx/skynet/prevstats.lua")
if file_exists then
file_exists.close()
-- because response data is chunked, we need to concat ngx.arg[1] until
-- last chunk is received (when ngx.arg[2] is set to true)
ngx.var.response_body = ngx.var.response_body .. ngx.arg[1]
if ngx.arg[2] then
local json = require('cjson')
local prevstats = require('/data/nginx/skynet/prevstats')
@ -153,11 +154,11 @@ server {
# variable definititions - we need to define a variable to be able to access it in lua by ngx.var.something
set $skylink ''; # placeholder for the raw 46 bit skylink
set $rest ''; # placeholder for the rest of the url that gets appended to skylink (path and args)
# resolve handshake domain by requesting to /hnsres endpoint and assign correct values to $skylink and $rest
access_by_lua_block {
local json = require('cjson')
-- match the request_uri and extract the hns domain and anything that is passed in the uri after it
-- example: /hns/something/foo/bar?baz=1 matches:
-- > hns_domain_name: something
@ -222,9 +223,6 @@ server {
end
}
# overwrite the Cache-Control header to only cache for 60s in case the domain gets updated
more_set_headers 'Cache-Control: public, max-age=60';
# we proxy to another nginx location rather than directly to siad because we don't want to deal with caching here
proxy_pass http://127.0.0.1/$skylink$rest;
@ -255,7 +253,7 @@ server {
# and we are using it currently for caching registry resolutions from /hns calls
location /skynet/registry/cached {
internal; # internal endpoint only
access_log off; # do not log traffic
access_log off; # do not log traffic
proxy_cache skynet;
proxy_cache_key publickey=$arg_publickey&datakey=$arg_datakey; # cache based on publickey and datakey
@ -276,6 +274,40 @@ server {
proxy_set_header User-Agent: Sia-Agent;
proxy_read_timeout 600; # siad should timeout with 404 after 5 minutes
proxy_pass http://siad/skynet/registry;
access_by_lua_block {
-- this block runs only when accounts are enabled
if os.getenv("ACCOUNTS_ENABLED", "0") == "0" then return end
local res = ngx.location.capture("/accounts/user/limits", { copy_all_vars = true })
if res.status == ngx.HTTP_OK then
local json = require('cjson')
local limits = json.decode(res.body)
if limits.registry > 0 then
ngx.sleep(limits.registry / 1000)
end
end
}
# register the registry access in accounts service (cookies should contain jwt)
log_by_lua_block {
-- this block runs only when accounts are enabled
if os.getenv("ACCOUNTS_ENABLED", "0") == "0" then return end
if ngx.status == ngx.HTTP_OK or ngx.status == ngx.HTTP_NOT_FOUND then
local http = require("socket.http")
local headers = { Cookie = ngx.req.get_headers()["Cookie"] }
local method = ngx.req.get_method() == ngx.HTTP_GET and "read" or "write"
local ok, statusCode, headers, statusText = http.request {
url = "http://accounts:3000/track/registry/" .. method,
method = "POST",
headers = headers
}
if statusCode ~= ngx.HTTP_NO_CONTENT and statusCode ~= ngx.HTTP_UNAUTHORIZED then
ngx.log(ngx.ERR, "accounts endpoint /track/registry/" .. method .. " failed with error " .. statusCode)
end
end
}
}
location /skynet/skyfile {
@ -306,8 +338,37 @@ server {
set $dir3 $3;
}
# access_by_lua_block {
# -- this block runs only when accounts are enabled
# if os.getenv("ACCOUNTS_ENABLED", "0") == "0" then return end
# ngx.var.upload_limit_rate = 5 * 1024 * 1024
# local res = ngx.location.capture("/accounts/user", { copy_all_vars = true })
# if res.status == ngx.HTTP_OK then
# local json = require('cjson')
# local user = json.decode(res.body)
# ngx.var.upload_limit_rate = ngx.var.upload_limit_rate * (user.tier + 1)
# end
# }
# proxy this call to siad endpoint (make sure the ip is correct)
proxy_pass http://siad/skynet/skyfile/$dir1/$dir2/$dir3$is_args$args;
# register the upload in accounts service (cookies should contain jwt)
log_by_lua_block {
-- this block runs only when accounts are enabled
if os.getenv("ACCOUNTS_ENABLED", "0") == "0" then return end
local skylink = ngx.header["Skynet-Skylink"]
if skylink and ngx.status >= ngx.HTTP_OK and ngx.status < ngx.HTTP_SPECIAL_RESPONSE then
local http = require("socket.http")
local headers = { Cookie = ngx.req.get_headers()["Cookie"] }
local ok, statusCode, headers, statusText = http.request { url = "http://accounts:3000/track/upload/" .. skylink, method = "POST", headers = headers }
if statusCode ~= ngx.HTTP_NO_CONTENT and statusCode ~= ngx.HTTP_UNAUTHORIZED then
ngx.log(ngx.ERR, "accounts endpoint /track/upload/" .. skylink .. " failed with error " .. statusCode)
end
end
}
}
location ~ "^/(([a-zA-Z0-9-_]{46}|[a-z0-9]{55})(/.*)?)$" {
@ -322,12 +383,44 @@ server {
}
limit_conn downloads_by_ip 100; # ddos protection: max 100 downloads at a time
add_header Cache-Control "public, max-age=86400"; # allow consumer to cache response
# we need to explicitly use set directive here because $1 will contain the skylink with
# decoded whitespaces and set will re-encode it for us before passing it to proxy_pass
set $skylink $1;
access_by_lua_block {
-- this block runs only when accounts are enabled
if os.getenv("ACCOUNTS_ENABLED", "0") == "0" then return end
local res = ngx.location.capture("/accounts/user/limits", { copy_all_vars = true })
if res.status == ngx.HTTP_OK then
local json = require('cjson')
local limits = json.decode(res.body)
ngx.var.limit_rate = limits.download
end
}
# register the download in accounts service (cookies should contain jwt)
log_by_lua_block {
-- this block runs only when accounts are enabled
if os.getenv("ACCOUNTS_ENABLED", "0") == "0" then return end
local skylink = ngx.header["Skynet-Skylink"]
if skylink and ngx.status >= ngx.HTTP_OK and ngx.status < ngx.HTTP_SPECIAL_RESPONSE then
local http = require("socket.http")
local headers = { Cookie = ngx.req.get_headers()["Cookie"] }
local query = table.concat({ "status=" .. ngx.status, "bytes=" .. ngx.var.body_bytes_sent }, "&")
local ok, statusCode, headers, statusText = http.request {
url = "http://accounts:3000/track/download/" .. skylink .. "?" .. query,
method = "POST",
headers = headers
}
if statusCode ~= ngx.HTTP_NO_CONTENT and statusCode ~= ngx.HTTP_UNAUTHORIZED then
ngx.log(ngx.ERR, "accounts endpoint /track/download/" .. skylink .. " failed with error " .. statusCode)
end
end
}
proxy_read_timeout 600;
proxy_set_header User-Agent: Sia-Agent;
# proxy this call to siad /skynet/skylink/ endpoint (make sure the ip is correct)
@ -365,6 +458,14 @@ server {
proxy_pass http://127.0.0.1/$uri?attachment=true&$args;
}
location /accounts {
internal; # internal endpoint only
access_log off; # do not log traffic
rewrite /accounts(.*) $1 break; # drop the /accounts prefix from uri
proxy_pass http://accounts:3000;
}
# include custom locations, specific to the server
include /etc/nginx/conf.d/server-override/*;
}

View File

@ -26,6 +26,7 @@ worker_processes 1;
#pid logs/nginx.pid;
env SKYNET_PORTAL_API; # declare env variable to use it in config
env ACCOUNTS_ENABLED; # declare env variable to use it in config
events {
worker_connections 1024;
@ -67,4 +68,5 @@ http {
header_filter_by_lua 'ngx.header["Skynet-Portal-Api"] = os.getenv("SKYNET_PORTAL_API")';
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/conf.extra.d/*.conf;
}

View File

@ -0,0 +1,58 @@
# nginx.vh.default.conf -- docker-openresty
#
# This file is installed to:
# `/etc/nginx/conf.d/default.conf`
#
# It tracks the `server` section of the upstream OpenResty's `nginx.conf`.
#
# This config (and any other configs in `etc/nginx/conf.d/`) is loaded by
# default by the `include` directive in `/usr/local/openresty/nginx/conf/nginx.conf`.
#
# See https://github.com/openresty/docker-openresty/blob/master/README.md#nginx-config-files
#
server {
listen 80;
server_name localhost;
#charset koi8-r;
#access_log /var/log/nginx/host.access.log main;
location / {
root /usr/local/openresty/nginx/html;
index index.html index.htm;
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/local/openresty/nginx/html;
}
# proxy the PHP scripts to Apache listening on 127.0.0.1:80
#
#location ~ \.php$ {
# proxy_pass http://127.0.0.1;
#}
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
#location ~ \.php$ {
# root /usr/local/openresty/nginx/html;
# fastcgi_pass 127.0.0.1:9000;
# fastcgi_index index.php;
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
# include fastcgi_params;
#}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}

View File

@ -5,6 +5,10 @@
"packages/*"
],
"dependencies": {
"sharp": "^0.27.2"
"@tailwindcss/forms": "^0.2.1",
"autoprefixer": "^10.2.4",
"postcss": "^8.2.6",
"sharp": "^0.27.2",
"tailwindcss": "^2.0.4"
}
}

4
packages/dashboard/.env Normal file
View File

@ -0,0 +1,4 @@
NEXT_PUBLIC_SKYNET_PORTAL_API=https://siasky.net
NEXT_PUBLIC_SKYNET_DASHBOARD_URL=https://account.siasky.net
NEXT_PUBLIC_KRATOS_BROWSER_URL=https://account.siasky.net/.ory/kratos/public
NEXT_PUBLIC_KRATOS_PUBLIC_URL=https://account.siasky.net/.ory/kratos/public

38
packages/dashboard/.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env defaults
!.env
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
.next

View File

@ -0,0 +1,3 @@
.next
package.json
package-lock.json

View File

@ -0,0 +1,3 @@
{
"printWidth": 120
}

View File

@ -0,0 +1,16 @@
FROM node:15.12.0-alpine
WORKDIR /usr/app
COPY package.json .
ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn --no-lockfile
COPY public ./public
COPY src ./src
COPY styles ./styles
COPY postcss.config.js .
COPY tailwind.config.js .
CMD ["sh", "-c", "env | grep -E 'NEXT_PUBLIC|KRATOS|STRIPE' > .env.local && yarn build && yarn start"]

View File

@ -0,0 +1,34 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/import?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View File

@ -0,0 +1,43 @@
{
"name": "dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@fontsource/metropolis": "^4.1.0",
"@ory/kratos-client": "^0.5.4-alpha.1",
"@stripe/react-stripe-js": "^1.4.0",
"@stripe/stripe-js": "^1.13.0",
"@tailwindcss/forms": "^0.2.1",
"autoprefixer": "^10.2.5",
"classnames": "^2.2.6",
"clipboardy": "^2.3.0",
"dayjs": "^1.10.4",
"express-jwt": "^6.0.0",
"fast-levenshtein": "^3.0.0",
"formik": "^2.2.6",
"http-status-codes": "^2.1.4",
"jwks-rsa": "^1.12.2",
"ky": "0.25.1",
"next": "^10.0.8",
"postcss": "^8.2.8",
"prettier": "^2.2.1",
"pretty-bytes": "^5.5.0",
"react": "17.0.1",
"react-dom": "17.0.1",
"skynet-js": "^3.0.0",
"square": "^9.0.0",
"stripe": "^8.137.0",
"superagent": "^6.1.0",
"swr": "^0.5.0",
"tailwindcss": "^2.0.3",
"yup": "^0.32.9"
},
"devDependencies": {
"@tailwindcss/forms": "^0.2.1"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,62 @@
const types = {
error: {
backgroundColor: "bg-red-50",
titleColor: "text-red-800",
detailsColor: "text-red-700",
iconColor: "text-red-400",
icon: (
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
),
},
info: {
backgroundColor: "bg-blue-50",
titleColor: "text-blue-800",
detailsColor: "text-blue-700",
iconColor: "text-blue-400",
icon: (
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
),
},
};
export default function Message({ type = "info", title, items = [] }) {
const { backgroundColor, titleColor, detailsColor, iconColor, icon } = types[type];
return (
<div className={`rounded-md ${backgroundColor} p-4`}>
<div className="flex">
<div className="flex-shrink-0">
<svg
className={`h-5 w-5 ${iconColor}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
{icon}
</svg>
</div>
<div className="ml-3">
{title && <h3 className={`text-sm font-medium ${titleColor}`}>{title}</h3>}
{items.length > 0 && (
<div className={`${title ? "mt-2" : ""} text-sm ${detailsColor}`}>
<ul className={`${items.length > 1 ? "list-disc pl-5 space-y-1" : ""}`}>
{items.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,104 @@
import { useFormik, getIn, setIn } from "formik";
import classnames from "classnames";
import SelfServiceMessages from "./SelfServiceMessages";
export default function SelfServiceForm({ flow, config, fieldsConfig, title, button = "Submit" }) {
const fields = config.fields
.map((field) => ({ ...field, ...fieldsConfig[field.name] }))
.sort((a, b) => (a.position < b.position ? -1 : 1));
const formik = useFormik({
initialValues: fields.reduce((acc, field) => setIn(acc, field.name, field.value ?? ""), {}),
});
return (
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
{title && <h3 className="pb-5 text-lg leading-6 font-medium text-gray-900">{title}</h3>}
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<form className="space-y-6" action={config.action} method={config.method}>
{fields.map((field) => (
<div key={field.name} className={classnames({ hidden: field.type === "hidden" })}>
<label htmlFor={field.name} className="block text-sm font-medium text-gray-700 mb-1">
{fieldsConfig[field.name]?.label ?? field.name}
</label>
<div>
<input
id={field.type === "hidden" ? null : field.name}
name={field.name}
type={field.type}
autoComplete={field.autoComplete}
required={field.required}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
value={getIn(formik.values, field.name) ?? ""}
className={classnames(
"appearance-none block w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none sm:text-sm",
{
"border-gray-300 placeholder-gray-400 focus:ring-green-500 focus:border-green-500": !Boolean(
field?.messages?.length
),
"border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500": Boolean(
field?.messages?.length
),
}
)}
/>
<SelfServiceMessages messages={field.messages} />
{field.checks && (
<div className="mt-4">
<ul className="space-y-1">
{field.checks.map((check, index) => (
<li
key={index}
className={
check.validate(formik.values, field.name) ? "text-green-600 font-medium" : "text-gray-600"
}
>
<div className="flex space-x-3 items-center">
<span className="flex items-center justify-center ">
<svg
className="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20.618 5.984A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016zM12 9v2m0 4h.01"
/>
</svg>
</span>
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<h3 className="text-xs">{check.label}</h3>
</div>
</div>
</div>
</li>
))}
</ul>
</div>
)}
</div>
</div>
))}
<button
type="submit"
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
{button}
</button>
<SelfServiceMessages messages={config.messages} />
{flow && <SelfServiceMessages messages={flow.messages} />}
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
import classnames from "classnames";
// const types = {
// error: {
// backgroundColor: "bg-red-50",
// titleColor: "text-red-800",
// detailsColor: "text-red-700",
// iconColor: "text-red-400",
// icon: (
// <path
// fillRule="evenodd"
// d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
// clipRule="evenodd"
// />
// ),
// },
// info: {
// backgroundColor: "bg-blue-50",
// titleColor: "text-blue-800",
// detailsColor: "text-blue-700",
// iconColor: "text-blue-400",
// icon: (
// <path
// fillRule="evenodd"
// d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
// clipRule="evenodd"
// />
// ),
// },
// };
export default function SelfServiceMessages({ messages = [] }) {
if (!messages) return null; // make sure we don't throw on invalid data
return messages.map(({ text, type }) => (
<p
className={classnames("mt-2 text-sm", {
"text-red-600": type === "error",
"text-blue-600": type === "info",
})}
>
{text}
</p>
));
}

View File

@ -0,0 +1,327 @@
import Link from "next/link";
import { useRouter } from "next/router";
import Head from "next/head";
import ky from "ky/umd";
import { useState } from "react";
import config from "../../src/config";
export default function Layout({ title, children }) {
const [menuOpen, openMenu] = useState(false);
const [avatarDropdownOpen, openAvatarDropdown] = useState(false);
const router = useRouter();
const handleSignOut = async (e) => {
e.preventDefault();
try {
await ky.post("/logout");
window.location = `${config.kratos.browser}/self-service/browser/flows/logout`;
} catch (error) {
console.log(error); // todo: handle errors with a message
}
};
return (
<div>
<Head>
<title key="title">Skynet - {title}</title>
</Head>
<div className="bg-gray-800 pb-32">
<nav className="bg-gray-800">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div className="border-b border-gray-700">
<div className="flex items-center justify-between h-16 px-4 sm:px-0">
<div className="flex items-center">
<Link href="/">
<a className="flex-shrink-0">
<svg
viewBox="19.88800048828125 37.1175193787 132.07760620117188 132.07760620117188"
width={33}
height={33}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M 116.388 139.371 C 92.969 148.816 66.759 134.5 62.048 109.691 L 46.308 98.821 C 43.843 141.32 88.308 170.55 126.346 151.435 C 130.805 149.195 134.94 146.361 138.638 143.011 L 138.698 143.011 C 141.248 140.637 140.685 136.456 137.598 134.841 L 19.888 72.671 Z"
style={{ fill: "rgb(88, 181, 96)" }}
/>
<path
d="M 149.398 127.121 L 149.398 127.021 C 150.067 124.651 148.83 122.161 146.538 121.261 L 67.478 90.011 L 142.478 130.011 C 145.178 131.489 148.552 130.08 149.398 127.121 Z"
style={{ fill: "rgb(88, 181, 96)" }}
/>
<path
d="M 151.848 109.801 C 152.508 94.561 150.578 79.801 141.228 67.721 C 130.128 53.411 111.498 47.801 96.588 49.081 C 95.428 49.181 94.268 49.351 93.108 49.451 C 77.448 50.901 62.598 59.941 53.728 75.301 C 52.968 76.621 52.278 77.971 51.638 79.301 C 51.238 79.841 50.838 80.371 50.458 80.931 L 63.838 88.061 C 64.463 86.395 65.194 84.772 66.028 83.201 C 80.584 55.935 119.197 54.651 135.532 80.889 C 140.199 88.386 142.264 97.212 141.408 106.001 L 91.518 92.621 L 145.258 113.861 C 148.274 115.053 151.585 112.994 151.848 109.761 Z"
style={{ fill: "rgb(88, 181, 96)" }}
/>
</svg>
</a>
</Link>
<div className="hidden md:block">
<div className="ml-10 flex items-baseline space-x-4">
{/* Current: "bg-gray-900 text-white", Default: "text-gray-300 hover:bg-gray-700 hover:text-white" */}
<Link href="/">
<a
className={`${
router.pathname === "/"
? "bg-gray-900 text-white"
: "text-gray-300 hover:bg-gray-700 hover:text-white"
} px-3 py-2 rounded-md text-sm font-medium`}
>
Dashboard
</a>
</Link>
<Link href="/uploads">
<a
className={`${
router.pathname === "/uploads"
? "bg-gray-900 text-white"
: "text-gray-300 hover:bg-gray-700 hover:text-white"
} px-3 py-2 rounded-md text-sm font-medium`}
>
Your uploads
</a>
</Link>
<Link href="/downloads">
<a
className={`${
router.pathname === "/downloads"
? "bg-gray-900 text-white"
: "text-gray-300 hover:bg-gray-700 hover:text-white"
} px-3 py-2 rounded-md text-sm font-medium`}
>
Your downloads
</a>
</Link>
<a
href={process.env.NEXT_PUBLIC_SKYNET_PORTAL_API}
className="text-gray-300 hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium flex items-center"
target="_blank"
rel="noopener noreferrer"
>
Upload files
<svg
className="flex-shrink-0 h-4 w-4 ml-2"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</div>
</div>
</div>
<div className="hidden md:block">
<div className="ml-4 flex items-center md:ml-6">
{/* Profile dropdown */}
<div className="ml-3 relative">
<div>
<button
className="max-w-xs bg-gray-800 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white"
id="user-menu"
aria-haspopup="true"
onClick={() => openAvatarDropdown(!avatarDropdownOpen)}
>
<span className="sr-only">Open user menu</span>
<span className="inline-block h-8 w-8 rounded-full overflow-hidden bg-gray-100">
<svg className="h-full w-full text-gray-300" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</span>
</button>
</div>
{/*
Profile dropdown panel, show/hide based on dropdown state.
Entering: "transition ease-out duration-100"
From: "transform opacity-0 scale-95"
To: "transform opacity-100 scale-100"
Leaving: "transition ease-in duration-75"
From: "transform opacity-100 scale-100"
To: "transform opacity-0 scale-95"
*/}
{avatarDropdownOpen && (
<div
className="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5"
role="menu"
aria-orientation="vertical"
aria-labelledby="user-menu"
>
<Link href="/settings">
<a className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">
Settings
</a>
</Link>
<Link href="/payments">
<a className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">
Payments
</a>
</Link>
<a
href="#"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer"
role="menuitem"
onClick={handleSignOut}
>
Sign out
</a>
</div>
)}
</div>
</div>
</div>
<div className="-mr-2 flex md:hidden">
{/* Mobile menu button */}
<button
className="bg-gray-800 inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white"
onClick={() => openMenu(!menuOpen)}
>
<span className="sr-only">Open main menu</span>
<svg
className={`${menuOpen ? "hidden" : "block"} h-6 w-6`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
<svg
className={`${menuOpen ? "block" : "hidden"} h-6 w-6`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
<div className={`${menuOpen ? "block" : "hidden"} border-b border-gray-700 md:hidden`}>
<div className="px-2 py-3 space-y-1 sm:px-3">
{/* Current: "bg-gray-900 text-white", Default: "text-gray-300 hover:bg-gray-700 hover:text-white" */}
<Link href="/">
<a
className={`${
router.pathname === "/"
? "bg-gray-900 text-white"
: "text-gray-300 hover:bg-gray-700 hover:text-white"
} block px-3 py-2 rounded-md text-base font-medium`}
>
Dashboard
</a>
</Link>
<Link href="/uploads">
<a
className={`${
router.pathname === "/uploads"
? "bg-gray-900 text-white"
: "text-gray-300 hover:bg-gray-700 hover:text-white"
} block px-3 py-2 rounded-md text-base font-medium`}
>
Your uploads
</a>
</Link>
<Link href="/downloads">
<a
className={`${
router.pathname === "/downloads"
? "bg-gray-900 text-white"
: "text-gray-300 hover:bg-gray-700 hover:text-white"
} block px-3 py-2 rounded-md text-base font-medium`}
>
Your downloads
</a>
</Link>
<a
href={process.env.NEXT_PUBLIC_SKYNET_PORTAL_API}
className="text-gray-300 hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium flex items-center"
target="_blank"
rel="noopener noreferrer"
>
Upload files
<svg
className="flex-shrink-0 h-4 w-4 ml-2"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</div>
<div className="pt-4 pb-3 border-t border-gray-700">
{/* <div className="flex items-center px-5">
<div className="flex-shrink-0">
<span className="inline-block h-10 w-10 rounded-full overflow-hidden bg-gray-100">
<svg className="h-full w-full text-gray-300" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</span>
</div>
<div className="ml-3">
<div className="text-base font-medium leading-none text-white">John Doe</div>
<div className="text-sm font-medium leading-none text-gray-400">john@example.com</div>
</div>
</div> */}
<div className="mt-3 px-2 space-y-1">
<Link href="/settings">
<a className="block px-3 py-2 rounded-md text-base font-medium text-gray-400 hover:text-white hover:bg-gray-700">
Settings
</a>
</Link>
<Link href="/payments">
<a className="block px-3 py-2 rounded-md text-base font-medium text-gray-400 hover:text-white hover:bg-gray-700">
Payments
</a>
</Link>
<a
href="#"
onClick={handleSignOut}
className="block px-3 py-2 rounded-md text-base font-medium text-gray-400 hover:text-white hover:bg-gray-700 cursor-pointer"
>
Sign out
</a>
</div>
</div>
</div>
</nav>
<header className="py-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold text-white">{title}</h1>
</div>
</header>
</div>
<main className="-mt-32">
<div className="max-w-7xl mx-auto pb-12 px-4 sm:px-6 lg:px-8">
{children || (
<div className="bg-white rounded-lg shadow px-5 py-6 sm:px-6">
<div className="border-4 border-dashed border-gray-200 rounded-lg h-96" />
</div>
)}
</div>
</main>
<footer className="max-w-7xl mx-auto py-4 sm:py-6 px-4 sm:px-6 md:flex md:items-center md:justify-between lg:px-8">
<p className="text-center text-sm text-gray-400">© 2021 Skynet Labs Inc. All rights reserved.</p>
</footer>
</div>
);
}

View File

@ -0,0 +1,128 @@
import { useEffect } from "react";
import classnames from "classnames";
function Button({ children, disabled, className, ...props }) {
return (
<button
type="button"
className={classnames(
"inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white",
{
"hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500": !disabled,
"cursor-auto opacity-50": disabled,
},
className
)}
disabled={disabled}
{...props}
>
{children}
</button>
);
}
export default function Table({ items, count, headers, actions, offset, setOffset, pageSize = 10 }) {
useEffect(() => {
if (offset < 0) setOffset(0);
else if (offset >= count && count > 0) setOffset(Math.floor(count / pageSize - 1) * pageSize);
else if (offset % pageSize) setOffset(offset - (offset % pageSize));
}, [offset, pageSize, setOffset]);
return (
<div className="flex flex-col">
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div className="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{headers.map(({ key, name }) => (
<th
key={key}
scope="col"
className="px-6 py-3 whitespace-nowrap text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{name}
</th>
))}
{actions.map(({ key, name }) => (
<th key={key} scope="col" className="relative px-6 py-3">
<span className="sr-only">{name}</span>
</th>
))}
</tr>
</thead>
<tbody>
{items && items.length ? (
items.map((row, index) => (
<tr className={index % 2 ? "bg-gray-100" : "bg-white"} key={index}>
{headers.map(({ key, formatter, href, nowrap = true }) => (
<td
key={key}
className={`${nowrap ? "whitespace-nowrap" : ""} px-6 py-4 text-sm font-medium text-gray-900`}
>
{(formatter ? (
formatter(row, key)
) : href ? (
<a
href={href(row, key)}
className="text-green-600 hover:text-green-900"
target="_blank"
rel="noopener noreferrer"
>
{row[key]}
</a>
) : (
row[key]
)) || <>&mdash;</>}
</td>
))}
{actions.map(({ key, name, action }) => (
<td key={key} className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a href="#" className="text-green-600 hover:text-green-900" onClick={action}>
{name}
</a>
</td>
))}
</tr>
))
) : (
<tr className="bg-white">
<td colspan={headers.length + actions.length} className="text-center py-6 text-sm text-gray-500">
no entries
</td>
</tr>
)}
</tbody>
</table>
{/* This example requires Tailwind CSS v2.0+ */}
<nav
className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6"
aria-label="Pagination"
>
<div className="hidden sm:block">
<p className="text-sm text-gray-700">
Showing <span className="font-medium">{count ? offset + 1 : 0}</span> to{" "}
<span className="font-medium">{offset + pageSize > count ? count : offset + pageSize}</span> of{" "}
<span className="font-medium">{count}</span> results
</p>
</div>
<div className="flex-1 flex justify-between sm:justify-end">
<Button disabled={offset - pageSize < 0} onClick={() => setOffset(offset - pageSize)}>
Previous
</Button>
<Button
className="ml-3"
disabled={offset + pageSize >= count}
onClick={() => setOffset(offset + pageSize)}
>
Next
</Button>
</div>
</nav>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,14 @@
export default {
// https://github.com/ory/kratos-selfservice-ui-node#configuration
kratos: {
// The URL where ORY Kratos's Public API is located at. If this app and ORY Kratos are running in the same
// private network, this should be the private network address (e.g. kratos-public.svc.cluster.local)
public: process.env.NEXT_PUBLIC_KRATOS_PUBLIC_URL.replace(/\/+$/, ""),
// The URL where ORY Kratos's public API is located, when accessible from the public internet via ORY Oathkeeper.
// This could be for example http://kratos.my-app.com/.
browser: process.env.NEXT_PUBLIC_KRATOS_BROWSER_URL.replace(/\/+$/, ""),
},
tiers: {
starter: { id: "starter", tier: 1, name: "Free", description: "Pin up to 100GB" },
},
};

View File

@ -0,0 +1,21 @@
import { Elements } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import Head from "next/head";
import "tailwindcss/tailwind.css";
import "@fontsource/metropolis/all.css";
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
function MyApp({ Component, pageProps }) {
return (
<Elements stripe={stripePromise}>
<Head>
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<title key="title">Skynet</title>
</Head>
<Component {...pageProps} />
</Elements>
);
}
export default MyApp;

View File

@ -0,0 +1,19 @@
import superagent from "superagent";
export default async (req, res) => {
if (req.cookies.ory_kratos_session) {
try {
const { header } = await superagent
.post("http://oathkeeper:4455/login")
.set("cookie", `ory_kratos_session=${req.cookies.ory_kratos_session}`);
res.setHeader("Set-Cookie", header["set-cookie"]);
res.redirect(req.query.return_to ?? "/");
} catch (error) {
// credentials were correct but accounts service failed
res.redirect("/.ory/kratos/public/self-service/browser/flows/logout");
}
} else {
res.redirect("/auth/login"); // redirect to login page if kratos session is missing
}
};

View File

@ -0,0 +1,58 @@
import { Client, Environment } from "square";
import { StatusCodes } from "http-status-codes";
const client = new Client({
environment: Environment.Sandbox,
accessToken: process.env.SQUARE_ACCESS_TOKEN,
});
const api = {
GET: async (req, res) => {
const user = "R7R0NY1Z8WT11D43564EEFKTYR"; // req.headers["x-user"];
try {
const { result: customerResult } = await client.customersApi.retrieveCustomer(user);
const { customer } = customerResult;
res.json(customer.cards);
} catch (error) {
res.json([]);
}
},
// POST: async (req, res) => {
// const user = req.headers["x-user"];
// const card = {
// cardNonce: "YOUR_CARD_NONCE",
// cardholderName: "Amelia Earhart",
// billingAddress: {},
// verificationToken: "verification_token0",
// };
// card.bodyBillingAddress.addressLine1 = "500 Electric Ave";
// card.bodyBillingAddress.addressLine2 = "Suite 600";
// card.bodyBillingAddress.addressLine3 = "address_line_38";
// card.bodyBillingAddress.locality = "New York";
// card.bodyBillingAddress.sublocality = "sublocality2";
// card.bodyBillingAddress.administrativeDistrictLevel1 = "NY";
// card.bodyBillingAddress.postalCode = "10003";
// card.bodyBillingAddress.country = "US";
// try {
// const { result } = await client.customersApi.createCustomerCard(user, card);
// res.status(StatusCodes.NO_CONTENT);
// } catch (error) {
// console.log(Object.keys(error));
// res.status(StatusCodes.BAD_REQUEST);
// }
// },
};
export default (req, res) => {
if (req.method in api) {
api[req.method](req, res);
} else {
res.status(StatusCodes.NOT_FOUND);
}
};

View File

@ -0,0 +1,45 @@
import { Client, Environment } from "square";
import { StatusCodes } from "http-status-codes";
const client = new Client({
environment: Environment.Sandbox,
accessToken: process.env.SQUARE_ACCESS_TOKEN,
});
const api = {
GET: async (req, res) => {
const user = "NBE7TRXZPGZXNBD64JB6DR5AGR"; // req.headers["x-user"];
try {
// get locations for invoices search query
const { result: locationsResponse } = await client.locationsApi.listLocations();
const { locations } = locationsResponse;
// create invoices serach query
const locationIds = locations.map(({ id }) => id);
const customerIds = [user];
const filter = { locationIds, customerIds };
const sort = { field: "INVOICE_SORT_DATE", order: "DESC" };
const query = { filter, sort };
// query invoices with given search criteria
const { result: invoicesResponse } = await client.invoicesApi.searchInvoices({ query, limit: 10 });
const { invoices } = invoicesResponse;
res.json(invoices);
} catch (error) {
console.log(error);
console.log(error?.errors);
res.json([]); // todo: error handling
}
},
};
export default (req, res) => {
if (req.method in api) {
return api[req.method](req, res);
}
return res.status(StatusCodes.NOT_FOUND);
};

View File

@ -0,0 +1,46 @@
import { Client, Environment } from "square";
import { StatusCodes } from "http-status-codes";
const client = new Client({
environment: Environment.Sandbox,
accessToken: process.env.SQUARE_ACCESS_TOKEN,
});
const api = {
GET: async (req, res) => {
try {
const user = "NBE7TRXZPGZXNBD64JB6DR5AGR"; // req.headers["x-user"];
// create subscriptions search query
const query = { filter: { customerIds: [user] } };
// query subscriptions with given search criteria
const { result: subscriptionsResponse } = await client.subscriptionsApi.searchSubscriptions({ query });
const { subscriptions } = subscriptionsResponse;
// get active subscription
const subscription = subscriptions.find(({ status }) => status === "ACTIVE");
if (!subscription) {
return res.status(StatusCodes.NO_CONTENT).end(); // no active subscription found
}
console.log("....", subscription);
return res.json(subscription);
} catch (error) {
console.log(error);
console.log(error?.errors);
return res.status(StatusCodes.BAD_REQUEST).end(); // todo: error handling
}
},
};
export default (req, res) => {
if (req.method in api) {
return api[req.method](req, res);
}
return res.status(StatusCodes.NOT_FOUND).end();
};

View File

@ -0,0 +1,55 @@
import { Client, Environment } from "square";
import { StatusCodes } from "http-status-codes";
const client = new Client({
environment: Environment.Sandbox,
accessToken: process.env.SQUARE_ACCESS_TOKEN,
});
const cancelSubscription = async (id) => {
const { result: subscriptionsResponse } = await client.subscriptionsApi.cancelSubscription(id);
const { subscription } = subscriptionsResponse;
return subscription;
};
const getActiveSubscription = async (customerId) => {
// create subscriptions search query
const query = { filter: { customerIds: [customerId] } };
// query subscriptions with given search criteria
const { result: subscriptionsResponse } = await client.subscriptionsApi.searchSubscriptions({ query });
const { subscriptions } = subscriptionsResponse;
// get active subscription with a set cancellation date
return subscriptions.find(({ status, canceledDate }) => status === "ACTIVE" && !canceledDate);
};
const api = {
POST: async (req, res) => {
try {
const user = "NBE7TRXZPGZXNBD64JB6DR5AGR"; // req.headers["x-user"];
const subscription = await getActiveSubscription(user);
if (!subscription) {
return res.status(StatusCodes.BAD_REQUEST).end(); // no active subscription found
}
const canceledSubscription = await cancelSubscription(subscription.id);
return res.json(canceledSubscription);
} catch (error) {
console.log(error.errors);
return res.status(StatusCodes.BAD_REQUEST).end(); // todo: error handling
}
},
};
export default (req, res) => {
if (req.method in api) {
return api[req.method](req, res);
}
return res.status(StatusCodes.NOT_FOUND).end();
};

View File

@ -0,0 +1,58 @@
import { Client, Environment } from "square";
import { StatusCodes } from "http-status-codes";
const client = new Client({
environment: Environment.Sandbox,
accessToken: process.env.SQUARE_ACCESS_TOKEN,
});
const updateSubscription = async (id, body) => {
const { result: subscriptionsResponse } = await client.subscriptionsApi.updateSubscription(id, body);
const { subscription } = subscriptionsResponse;
return subscription;
};
const getActiveCanceledSubscription = async (customerId) => {
// create subscriptions search query
const query = { filter: { customerIds: [customerId] } };
// query subscriptions with given search criteria
const { result: subscriptionsResponse } = await client.subscriptionsApi.searchSubscriptions({ query });
const { subscriptions } = subscriptionsResponse;
// get active subscription with a set cancellation date
return subscriptions.find(({ status, canceledDate }) => status === "ACTIVE" && canceledDate);
};
const api = {
POST: async (req, res) => {
try {
const user = "NBE7TRXZPGZXNBD64JB6DR5AGR"; // req.headers["x-user"];
const subscription = await getActiveCanceledSubscription(user);
if (!subscription) {
return res.status(StatusCodes.BAD_REQUEST).end(); // no active subscription with cancel date found
}
// update the subscription setting empty canceledDate
const updatedSubscription = await updateSubscription(subscription.id, {
subscription: { ...subscription, canceledDate: "" },
});
return res.json(updatedSubscription);
} catch (error) {
console.log(error.errors);
return res.status(StatusCodes.BAD_REQUEST).end(); // todo: error handling
}
},
};
export default (req, res) => {
if (req.method in api) {
return api[req.method](req, res);
}
return res.status(StatusCodes.NOT_FOUND).end();
};

View File

@ -0,0 +1,28 @@
import ky from "ky/umd";
import Stripe from "stripe";
import { StatusCodes } from "http-status-codes";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const getStripeCustomer = (stripeCustomerId = null) => {
if (stripeCustomerId) {
return stripe.customers.retrieve(stripeCustomerId);
}
return stripe.customers.create();
};
export default async (req, res) => {
try {
const authorization = req.headers.authorization; // authorization header from request
const { stripeCustomerId } = await ky("http://accounts:3000/user", { headers: { authorization } }).json();
const customer = await getStripeCustomer(stripeCustomerId);
const session = await stripe.billingPortal.sessions.create({
customer: customer.id,
return_url: `${process.env.SKYNET_DASHBOARD_URL}/payments`,
});
res.redirect(session.url);
} catch ({ message }) {
res.status(StatusCodes.BAD_REQUEST).json({ error: { message } });
}
};

View File

@ -0,0 +1,59 @@
import ky from "ky/umd";
import Stripe from "stripe";
import { StatusCodes } from "http-status-codes";
import { isPaidTier } from "../../../services/tiers";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const getStripeCustomer = async (user, authorization) => {
if (user.stripeCustomerId) {
return stripe.customers.retrieve(user.stripeCustomerId);
}
const customer = await stripe.customers.create();
// update user instance and include the customer id once created
await ky.put(`http://accounts:3000/user`, { headers: { authorization }, json: { stripeCustomerId: customer.id } });
return customer;
};
export default async (req, res) => {
if (req.method !== "POST") {
return res.status(StatusCodes.NOT_FOUND).end();
}
const { price } = req.body;
if (!price) {
return res.status(StatusCodes.BAD_REQUEST).json({ error: { message: "Missing 'price' attribute" } });
}
try {
const authorization = req.headers.authorization; // authorization header from request
const user = await ky("http://accounts:3000/user", { headers: { authorization } }).json();
if (isPaidTier(user.tier)) {
const message = `Customer can have only one active subscription at a time, use Stripe Customer Portal to manage active subscription`;
return res.status(StatusCodes.BAD_REQUEST).json({ error: { message } });
}
const customer = await getStripeCustomer(user, authorization);
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
line_items: [{ price, quantity: 1 }],
customer: customer.id,
client_reference_id: user.sub,
allow_promotion_codes: true,
success_url: `${process.env.SKYNET_DASHBOARD_URL}/payments?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.SKYNET_DASHBOARD_URL}/payments`,
});
res.json({ sessionId: session.id });
} catch (error) {
console.log(error);
res.status(StatusCodes.BAD_REQUEST).json({ error: { message: error.message } });
}
};

View File

@ -0,0 +1,23 @@
import ky from "ky/umd";
import Stripe from "stripe";
import { StatusCodes } from "http-status-codes";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export default async (req, res) => {
try {
const authorization = req.headers.authorization; // authorization header from request
const { stripeCustomerId } = await ky("http://accounts:3000/user", { headers: { authorization } }).json();
const stripeCustomer = await stripe.customers.retrieve(stripeCustomerId, { expand: ["subscriptions"] });
const { subscriptions } = stripeCustomer;
// todo: find a better way to get current subscription
if (subscriptions.total_count) {
return res.json(subscriptions.data[0]);
}
res.status(StatusCodes.NO_CONTENT).end();
} catch ({ message }) {
res.status(StatusCodes.BAD_REQUEST).json({ error: { message } });
}
};

View File

@ -0,0 +1,37 @@
export default (req, res) => {
res.json([
{
id: "price_1IReYFIzjULiPWN6DqN2DwjN",
name: "Skynet Extreme",
description: "Skynet Extreme description",
tier: 4,
price: 80,
currency: "usd",
stripe: "price_1IReYFIzjULiPWN6DqN2DwjN",
productId: "prod_J3m6IuVyh3XOc5",
livemode: false,
},
{
id: "price_1IReY5IzjULiPWN6AxPytHEG",
name: "Skynet Pro",
description: "Skynet Pro description",
tier: 3,
price: 20,
currency: "usd",
stripe: "price_1IReY5IzjULiPWN6AxPytHEG",
productId: "prod_J3m6ioQg90kZj5",
livemode: false,
},
{
id: "price_1IReXpIzjULiPWN66PvsxHL4",
name: "Skynet Plus",
description: "Skynet Plus description",
tier: 2,
price: 5,
currency: "usd",
stripe: "price_1IReXpIzjULiPWN66PvsxHL4",
productId: "prod_J3m6xMfDiz2LGE",
livemode: false,
},
]);
};

View File

@ -0,0 +1,5 @@
import user from "./user.json";
export default (req, res) => {
res.json(user);
};

View File

@ -0,0 +1,12 @@
{
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"sub": "ab776d6d-f324-4fa7-4k21-7587d5215481",
"tier": 1,
"subscribedUntil": "0001-01-01T00:00:00Z",
"subscriptionStatus": "active",
"subscriptionCancelAt": "2021-04-21T00:00:00Z",
"subscriptionCancelAtPeriodEnd": true,
"stripeCustomerId": "cus_J0iYnAp6LRgsTI"
}

View File

@ -0,0 +1,8 @@
import items from "./downloads.json";
export default (req, res) => {
const offset = parseInt(req.query?.offset ?? 0, 10);
const pageSize = parseInt(req.query?.pageSize ?? 10, 10);
res.json({ items: items.slice(offset, offset + pageSize), count: items.length, pageSize, offset });
};

View File

@ -0,0 +1,44 @@
[
{
"id": 1111,
"skylink": "PAL0w4SdA5rFCDGEutgpeQ50Om-YkBabtXVOJAkmedslKw",
"name": "ugabuga.pdf",
"size": 123123,
"downloadedOn": "2020-04-02T08:02:17-05:00"
},
{
"id": 2222,
"skylink": "XABvi7JtJbQSMAcDwnUnmp2FKDPjg8_tTTFP4BwMSxVdEg",
"name": "ugabuga.pdf",
"size": 8912739812,
"downloadedOn": "2020-04-02T08:02:17-05:00"
},
{
"id": 3333,
"skylink": "IADUs8d9CQjUO34LmdaaNPK_STuZo24rpKVfYW3wPPM2uQ",
"name": "ugabuga.pdf",
"size": 123123,
"downloadedOn": "2020-04-02T08:02:17-05:00"
},
{
"id": 4444,
"skylink": "_A2zt5SKoqwnnZU4cBF8uBycSKULXMyeg1c5ZISBr2Q3dA",
"name": "ugabuga.pdf",
"size": 83943,
"downloadedOn": "2020-04-02T08:02:17-05:00"
},
{
"id": 5555,
"skylink": "AAC0uO43g64ULpyrW0zO3bjEknSFbAhm8c-RFP21EQlmSQ",
"name": "ugabuga.pdf",
"size": 3290489120,
"downloadedOn": "2020-04-02T08:02:17-05:00"
},
{
"id": 6666,
"skylink": "CACqf4NlIMlA0CCCieYGjpViPGyfyJ4v1x3bmuCKZX8FKA",
"name": "ugabuga.pdf",
"size": 1290389,
"downloadedOn": "2020-04-02T08:02:17-05:00"
}
]

View File

@ -0,0 +1,5 @@
import stats from "./stats.json";
export default (req, res) => {
res.json(stats);
};

View File

@ -0,0 +1,13 @@
{
"storageUsed": 809500672,
"numRegReads": 0,
"numRegWrites": 0,
"numUploads": 13,
"numDownloads": 78,
"totalUploadsSize": 618649028,
"totalDownloadsSize": 32307956843,
"bwUploads": 2810183680,
"bwDownloads": 32323934976,
"bwRegReads": 0,
"bwRegWrites": 0
}

View File

@ -0,0 +1,8 @@
import items from "./uploads.json";
export default (req, res) => {
const offset = parseInt(req.query?.offset ?? 0, 10);
const pageSize = parseInt(req.query?.pageSize ?? 10, 10);
res.json({ items: items.slice(offset, offset + pageSize), count: items.length, pageSize, offset });
};

View File

@ -0,0 +1,44 @@
[
{
"id": 1111,
"skylink": "PAL0w4SdA5rFCDGEutgpeQ50Om-YkBabtXVOJAkmedslKw",
"name": "ugabuga.pdf",
"size": 123123,
"uploadedOn": "2020-04-02T08:02:17-05:00"
},
{
"id": 2222,
"skylink": "XABvi7JtJbQSMAcDwnUnmp2FKDPjg8_tTTFP4BwMSxVdEg",
"name": "ugabuga.pdf",
"size": 8912739812,
"uploadedOn": "2020-04-02T08:02:17-05:00"
},
{
"id": 3333,
"skylink": "IADUs8d9CQjUO34LmdaaNPK_STuZo24rpKVfYW3wPPM2uQ",
"name": "ugabuga.pdf",
"size": 123123,
"uploadedOn": "2020-04-02T08:02:17-05:00"
},
{
"id": 4444,
"skylink": "_A2zt5SKoqwnnZU4cBF8uBycSKULXMyeg1c5ZISBr2Q3dA",
"name": "ugabuga.pdf",
"size": 83943,
"uploadedOn": "2020-04-02T08:02:17-05:00"
},
{
"id": 5555,
"skylink": "AAC0uO43g64ULpyrW0zO3bjEknSFbAhm8c-RFP21EQlmSQ",
"name": "ugabuga.pdf",
"size": 3290489120,
"uploadedOn": "2020-04-02T08:02:17-05:00"
},
{
"id": 6666,
"skylink": "CACqf4NlIMlA0CCCieYGjpViPGyfyJ4v1x3bmuCKZX8FKA",
"name": "ugabuga.pdf",
"size": 1290389,
"uploadedOn": "2020-04-02T08:02:17-05:00"
}
]

View File

@ -0,0 +1,96 @@
import Link from "next/link";
import { Configuration, PublicApi } from "@ory/kratos-client";
import config from "../../config";
import SelfServiceForm from "../../components/Form/SelfServiceForm";
const kratos = new PublicApi(new Configuration({ basePath: config.kratos.public }));
export async function getServerSideProps(context) {
const flow = context.query.flow;
const redirect = encodeURIComponent(`/api/accounts/login?return_to=${context.query.return_to ?? "/"}`);
if (process.env.NODE_ENV === "development") {
return { props: { flow: require("../../../stubs/login.json") } };
}
// The flow is used to identify the login and registration flow and
// return data like the csrf_token and so on.
if (!flow || typeof flow !== "string") {
// No flow ID found in URL, initializing login flow.
return {
redirect: {
permanent: false,
destination: `${config.kratos.browser}/self-service/login/browser?return_to=${redirect}`,
},
};
}
try {
const { status, data } = await kratos.getSelfServiceLoginFlow(flow);
if (status === 200) return { props: { flow: data } };
throw new Error(`Failed to retrieve flow ${flow} with code ${status}`);
} catch (error) {
return {
redirect: {
permanent: false,
destination: `${config.kratos.browser}/self-service/login/browser?return_to=${redirect}`,
},
};
}
}
const fieldsConfig = {
identifier: {
label: "Email address",
autoComplete: "email",
position: 0,
},
password: {
label: "Password",
autoComplete: "current-password",
position: 1,
},
csrf_token: {
position: 99,
},
};
export default function Login({ flow }) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<svg
xmlns="http://www.w3.org/2000/svg"
width="169"
height="39"
viewBox="0 0 169 39"
className="mx-auto h-12 w-auto"
>
<path
d="M160.701 31.245c-2.736 0-4.788-.706-6.156-2.118-1.369-1.411-2.053-3.424-2.053-6.039V2.674h7.487v6.33H169v6.15h-9.02v7.355c0 1.753.886 2.63 2.66 2.63h6.134v6.106h-8.073zm-24.942 0c-1.744 0-3.352-.268-4.826-.803-1.473-.535-2.751-1.292-3.833-2.273a10.275 10.275 0 01-2.526-3.521c-.602-1.367-.902-2.882-.902-4.546 0-1.635.3-3.135.902-4.502a10.275 10.275 0 012.526-3.521c1.082-.98 2.36-1.738 3.833-2.273 1.474-.535 3.082-.803 4.826-.803h5.728c1.293 0 2.42.179 3.383.535.962.357 1.767.847 2.413 1.471a5.823 5.823 0 011.443 2.251c.316.877.474 1.835.474 2.875 0 1.961-.571 3.432-1.714 4.412-1.143.981-3.022 1.471-5.638 1.471h-8.028v-4.056h6.45c1.172 0 1.759-.52 1.759-1.56 0-1.1-.572-1.649-1.714-1.649h-4.6c-1.354 0-2.451.46-3.293 1.382-.842.921-1.263 2.228-1.263 3.922s.42 2.964 1.263 3.811c.842.847 1.94 1.27 3.292 1.27h12.268v6.107H135.76zm-22.867 0V19.567c0-2.526-1.308-3.789-3.924-3.789h-7.532v15.467H93.86V9.003h15.29c3.728 0 6.54.922 8.434 2.764 1.894 1.842 2.841 4.457 2.841 7.844v11.634h-7.532zM67.293 39v-6.418h2.39c.963 0 1.715-.193 2.256-.58a4.58 4.58 0 001.308-1.426L62.422 9.003h7.623l6.675 14.263 6.855-14.263h7.668L78.749 33.43c-.48.95-.984 1.775-1.51 2.473a7.85 7.85 0 01-1.782 1.739 7.11 7.11 0 01-2.233 1.025c-.827.223-1.781.334-2.864.334h-3.067zm-14.207-7.755l-7.713-9.316a4.647 4.647 0 01-.54-.914 2.306 2.306 0 01-.181-.913v-.535c0-.654.225-1.278.676-1.872l7.487-8.692h8.48l-9.02 10.787 9.47 11.455h-8.66zm-17.41 0V0h7.532v31.245h-7.532zm-35.315 0v-7.488h21.92c.571 0 1.037-.171 1.398-.513.36-.342.541-.825.541-1.449 0-.624-.18-1.114-.541-1.47-.36-.357-.827-.535-1.398-.535H9.38c-1.353 0-2.608-.216-3.766-.647-1.157-.43-2.15-1.025-2.976-1.782a8.005 8.005 0 01-1.94-2.742C.233 13.55 0 12.361 0 11.054 0 9.746.233 8.55.7 7.466A8.184 8.184 0 012.638 4.68c.826-.773 1.819-1.367 2.976-1.783 1.158-.416 2.413-.624 3.766-.624h22.281v7.444H9.742c-.571 0-1.03.163-1.375.49-.346.327-.52.802-.52 1.426 0 .594.174 1.07.52 1.426.345.357.804.535 1.375.535h12.9c1.383 0 2.646.216 3.788.647a9.097 9.097 0 012.977 1.805 8.038 8.038 0 011.962 2.785c.466 1.085.7 2.266.7 3.544 0 1.337-.234 2.548-.7 3.632a8.267 8.267 0 01-1.962 2.808 8.62 8.62 0 01-2.977 1.806c-1.142.416-2.405.624-3.788.624H.36z"
fill="#57B560"
fillRule="evenodd"
/>
</svg>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Sign in to your account</h2>
<p className="mt-2 text-center text-sm text-gray-600 max-w">
or{" "}
<Link href="/auth/registration">
<a className="font-medium text-green-600 hover:text-green-500">sign up</a>
</Link>{" "}
if you don't have one yet
</p>
</div>
<SelfServiceForm flow={flow} config={flow.methods.password.config} fieldsConfig={fieldsConfig} button="Sign in" />
<div className="sm:mx-auto sm:w-full sm:max-w-md text-center mt-2">
<Link href="/recovery">
<a className="text-sm font-medium text-green-600 hover:text-green-500">Forgot your password?</a>
</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,113 @@
import Link from "next/link";
import { Configuration, PublicApi } from "@ory/kratos-client";
import { getIn } from "formik";
import config from "../../config";
import levenshtein from "fast-levenshtein";
import lcs from "../../services/longestCommonSequence";
import SelfServiceForm from "../../components/Form/SelfServiceForm";
const kratos = new PublicApi(new Configuration({ basePath: config.kratos.public }));
export async function getServerSideProps(context) {
const flow = context.query.flow;
const redirect = encodeURIComponent(`/api/accounts/login?return_to=${context.query.return_to ?? "/"}`);
if (process.env.NODE_ENV === "development") {
return { props: { flow: require("../../../stubs/registration.json") } };
}
// The flow is used to identify the login and registration flow and
// return data like the csrf_token and so on.
if (!flow || typeof flow !== "string") {
// No flow ID found in URL, initializing registration flow.
return {
redirect: {
permanent: false,
destination: `${config.kratos.browser}/self-service/registration/browser?return_to=${redirect}`,
},
};
}
try {
const { status, data } = await kratos.getSelfServiceRegistrationFlow(flow);
if (status === 200) return { props: { flow: data } };
throw new Error(`Failed to retrieve flow ${flow} with code ${status}`);
} catch (error) {
return {
redirect: {
permanent: false,
destination: `${config.kratos.browser}/self-service/registration/browser?return_to=${redirect}`,
},
};
}
}
const fieldsConfig = {
"traits.email": {
label: "Email address",
autoComplete: "email",
position: 0,
},
password: {
label: "Password",
autoComplete: "new-password",
position: 1,
checks: [
{
label: "At least 6 characters long",
validate: (values, field) => {
const value = getIn(values, field);
return value && value.length > 5;
},
},
{
label: "Significantly different from the email",
validate: (values, field) => {
const value = getIn(values, field);
const email = getIn(values, "traits.email");
// levenshtein distance higher than 5 and longest common sequence shorter than half of the password
return value && email && levenshtein.get(value, email) > 5 && lcs(value, email).length / value.length <= 0.5;
},
},
],
},
csrf_token: {
position: 99,
},
};
export default function Registration({ flow }) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<svg
xmlns="http://www.w3.org/2000/svg"
width="169"
height="39"
viewBox="0 0 169 39"
className="mx-auto h-12 w-auto"
>
<path
d="M160.701 31.245c-2.736 0-4.788-.706-6.156-2.118-1.369-1.411-2.053-3.424-2.053-6.039V2.674h7.487v6.33H169v6.15h-9.02v7.355c0 1.753.886 2.63 2.66 2.63h6.134v6.106h-8.073zm-24.942 0c-1.744 0-3.352-.268-4.826-.803-1.473-.535-2.751-1.292-3.833-2.273a10.275 10.275 0 01-2.526-3.521c-.602-1.367-.902-2.882-.902-4.546 0-1.635.3-3.135.902-4.502a10.275 10.275 0 012.526-3.521c1.082-.98 2.36-1.738 3.833-2.273 1.474-.535 3.082-.803 4.826-.803h5.728c1.293 0 2.42.179 3.383.535.962.357 1.767.847 2.413 1.471a5.823 5.823 0 011.443 2.251c.316.877.474 1.835.474 2.875 0 1.961-.571 3.432-1.714 4.412-1.143.981-3.022 1.471-5.638 1.471h-8.028v-4.056h6.45c1.172 0 1.759-.52 1.759-1.56 0-1.1-.572-1.649-1.714-1.649h-4.6c-1.354 0-2.451.46-3.293 1.382-.842.921-1.263 2.228-1.263 3.922s.42 2.964 1.263 3.811c.842.847 1.94 1.27 3.292 1.27h12.268v6.107H135.76zm-22.867 0V19.567c0-2.526-1.308-3.789-3.924-3.789h-7.532v15.467H93.86V9.003h15.29c3.728 0 6.54.922 8.434 2.764 1.894 1.842 2.841 4.457 2.841 7.844v11.634h-7.532zM67.293 39v-6.418h2.39c.963 0 1.715-.193 2.256-.58a4.58 4.58 0 001.308-1.426L62.422 9.003h7.623l6.675 14.263 6.855-14.263h7.668L78.749 33.43c-.48.95-.984 1.775-1.51 2.473a7.85 7.85 0 01-1.782 1.739 7.11 7.11 0 01-2.233 1.025c-.827.223-1.781.334-2.864.334h-3.067zm-14.207-7.755l-7.713-9.316a4.647 4.647 0 01-.54-.914 2.306 2.306 0 01-.181-.913v-.535c0-.654.225-1.278.676-1.872l7.487-8.692h8.48l-9.02 10.787 9.47 11.455h-8.66zm-17.41 0V0h7.532v31.245h-7.532zm-35.315 0v-7.488h21.92c.571 0 1.037-.171 1.398-.513.36-.342.541-.825.541-1.449 0-.624-.18-1.114-.541-1.47-.36-.357-.827-.535-1.398-.535H9.38c-1.353 0-2.608-.216-3.766-.647-1.157-.43-2.15-1.025-2.976-1.782a8.005 8.005 0 01-1.94-2.742C.233 13.55 0 12.361 0 11.054 0 9.746.233 8.55.7 7.466A8.184 8.184 0 012.638 4.68c.826-.773 1.819-1.367 2.976-1.783 1.158-.416 2.413-.624 3.766-.624h22.281v7.444H9.742c-.571 0-1.03.163-1.375.49-.346.327-.52.802-.52 1.426 0 .594.174 1.07.52 1.426.345.357.804.535 1.375.535h12.9c1.383 0 2.646.216 3.788.647a9.097 9.097 0 012.977 1.805 8.038 8.038 0 011.962 2.785c.466 1.085.7 2.266.7 3.544 0 1.337-.234 2.548-.7 3.632a8.267 8.267 0 01-1.962 2.808 8.62 8.62 0 01-2.977 1.806c-1.142.416-2.405.624-3.788.624H.36z"
fill="#57B560"
fillRule="evenodd"
/>
</svg>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Sign up for a new account</h2>
<p className="mt-2 text-center text-sm text-gray-600 max-w">
or{" "}
<Link href="/auth/login">
<a className="font-medium text-green-600 hover:text-green-500">sign in</a>
</Link>{" "}
if you already have one
</p>
</div>
<SelfServiceForm flow={flow} config={flow.methods.password.config} fieldsConfig={fieldsConfig} button="Sign up" />
</div>
);
}

View File

@ -0,0 +1,44 @@
import dayjs from "dayjs";
import prettyBytes from "pretty-bytes";
import { useState } from "react";
import Layout from "../components/Layout";
import Table from "../components/Table";
import authServerSideProps from "../services/authServerSideProps";
import { SkynetClient } from "skynet-js";
import useAccountsApi from "../services/useAccountsApi";
const skynetClient = new SkynetClient(process.env.NEXT_PUBLIC_SKYNET_PORTAL_API);
const apiPrefix = process.env.NODE_ENV === "development" ? "/api/stubs" : "";
const getSkylinkLink = ({ skylink }) => skynetClient.getSkylinkUrl(skylink);
const getRelativeDate = ({ downloadedOn }) => dayjs(downloadedOn).format("YYYY-MM-DD HH:mm:ss");
const headers = [
{ key: "name", name: "Name", nowrap: false, href: getSkylinkLink },
{ key: "skylink", name: "Skylink" },
{ key: "size", name: "Size", formatter: ({ size }) => prettyBytes(size) },
{ key: "downloadedOn", name: "Accessed on", formatter: getRelativeDate },
];
const actions = [];
export const getServerSideProps = authServerSideProps(async (context, api) => {
const initialData = await api.get("user/downloads?pageSize=10&offset=0").json();
return { props: { initialData } };
});
export default function Downloads({ initialData }) {
const [offset, setOffset] = useState(0);
const { data } = useAccountsApi(`${apiPrefix}/user/downloads?pageSize=10&offset=${offset}`, {
initialData: offset === 0 ? initialData : undefined,
revalidateOnMount: true,
});
// preload next page if it exists (based on the response from the current page query)
const nextPageOffset = data && data.offset + data.pageSize < data.count ? data.offset + data.pageSize : offset;
useAccountsApi(`${apiPrefix}/user/downloads?pageSize=10&offset=${nextPageOffset}`);
return (
<Layout title="Your downloads">
<Table {...data} headers={headers} actions={actions} setOffset={setOffset} />
</Layout>
);
}

View File

@ -0,0 +1,68 @@
import Link from "next/link";
import { Configuration, PublicApi } from "@ory/kratos-client";
import config from "../config";
const kratos = new PublicApi(new Configuration({ basePath: config.kratos.public }));
export async function getServerSideProps(context) {
const error = context.query.error;
// No error was send, redirecting back to home.
if (!error || typeof error !== "string") {
console.log("No error ID found in URL, redirecting to homepage.");
return { redirect: { permanent: false, destination: "/" } };
}
try {
const { status, data } = await kratos.getSelfServiceError(error);
if ("errors" in data) return { props: { errors: data.errors } };
throw new Error(`Expected error ${error} to contain "errors" but got ${JSON.stringify(data)}`);
} catch (error) {
return { redirect: { permanent: false, destination: "/" } };
}
}
export default function Error({ errors }) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<svg
xmlns="http://www.w3.org/2000/svg"
width="169"
height="39"
viewBox="0 0 169 39"
className="mx-auto h-12 w-auto"
>
<path
d="M160.701 31.245c-2.736 0-4.788-.706-6.156-2.118-1.369-1.411-2.053-3.424-2.053-6.039V2.674h7.487v6.33H169v6.15h-9.02v7.355c0 1.753.886 2.63 2.66 2.63h6.134v6.106h-8.073zm-24.942 0c-1.744 0-3.352-.268-4.826-.803-1.473-.535-2.751-1.292-3.833-2.273a10.275 10.275 0 01-2.526-3.521c-.602-1.367-.902-2.882-.902-4.546 0-1.635.3-3.135.902-4.502a10.275 10.275 0 012.526-3.521c1.082-.98 2.36-1.738 3.833-2.273 1.474-.535 3.082-.803 4.826-.803h5.728c1.293 0 2.42.179 3.383.535.962.357 1.767.847 2.413 1.471a5.823 5.823 0 011.443 2.251c.316.877.474 1.835.474 2.875 0 1.961-.571 3.432-1.714 4.412-1.143.981-3.022 1.471-5.638 1.471h-8.028v-4.056h6.45c1.172 0 1.759-.52 1.759-1.56 0-1.1-.572-1.649-1.714-1.649h-4.6c-1.354 0-2.451.46-3.293 1.382-.842.921-1.263 2.228-1.263 3.922s.42 2.964 1.263 3.811c.842.847 1.94 1.27 3.292 1.27h12.268v6.107H135.76zm-22.867 0V19.567c0-2.526-1.308-3.789-3.924-3.789h-7.532v15.467H93.86V9.003h15.29c3.728 0 6.54.922 8.434 2.764 1.894 1.842 2.841 4.457 2.841 7.844v11.634h-7.532zM67.293 39v-6.418h2.39c.963 0 1.715-.193 2.256-.58a4.58 4.58 0 001.308-1.426L62.422 9.003h7.623l6.675 14.263 6.855-14.263h7.668L78.749 33.43c-.48.95-.984 1.775-1.51 2.473a7.85 7.85 0 01-1.782 1.739 7.11 7.11 0 01-2.233 1.025c-.827.223-1.781.334-2.864.334h-3.067zm-14.207-7.755l-7.713-9.316a4.647 4.647 0 01-.54-.914 2.306 2.306 0 01-.181-.913v-.535c0-.654.225-1.278.676-1.872l7.487-8.692h8.48l-9.02 10.787 9.47 11.455h-8.66zm-17.41 0V0h7.532v31.245h-7.532zm-35.315 0v-7.488h21.92c.571 0 1.037-.171 1.398-.513.36-.342.541-.825.541-1.449 0-.624-.18-1.114-.541-1.47-.36-.357-.827-.535-1.398-.535H9.38c-1.353 0-2.608-.216-3.766-.647-1.157-.43-2.15-1.025-2.976-1.782a8.005 8.005 0 01-1.94-2.742C.233 13.55 0 12.361 0 11.054 0 9.746.233 8.55.7 7.466A8.184 8.184 0 012.638 4.68c.826-.773 1.819-1.367 2.976-1.783 1.158-.416 2.413-.624 3.766-.624h22.281v7.444H9.742c-.571 0-1.03.163-1.375.49-.346.327-.52.802-.52 1.426 0 .594.174 1.07.52 1.426.345.357.804.535 1.375.535h12.9c1.383 0 2.646.216 3.788.647a9.097 9.097 0 012.977 1.805 8.038 8.038 0 011.962 2.785c.466 1.085.7 2.266.7 3.544 0 1.337-.234 2.548-.7 3.632a8.267 8.267 0 01-1.962 2.808 8.62 8.62 0 01-2.977 1.806c-1.142.416-2.405.624-3.788.624H.36z"
fill="#57B560"
fillRule="evenodd"
/>
</svg>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">An error occurred</h2>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
{errors.map((error, index) => (
<div className={`${index > 1 ? "mt-3 sm:mt-5" : ""} text-center`}>
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
{error.code} - {error.message}
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">{error.reason}</p>
</div>
</div>
))}
</div>
</div>
<div className="text-center mt-8">
<Link href="/">
<a className="font-medium text-green-600 hover:text-green-500">back to homepage</a>
</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,276 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import prettyBytes from "pretty-bytes";
import Link from "next/link";
import Layout from "../components/Layout";
import authServerSideProps from "../services/authServerSideProps";
import { SkynetClient } from "skynet-js";
import config from "../config";
import useAccountsApi from "../services/useAccountsApi";
import { isFreeTier } from "../services/tiers";
import { write } from "clipboardy";
dayjs.extend(relativeTime);
const skynetClient = new SkynetClient(process.env.NEXT_PUBLIC_SKYNET_PORTAL_API);
const apiPrefix = process.env.NODE_ENV === "development" ? "/api/stubs" : "";
export const getServerSideProps = authServerSideProps(async (context, api) => {
const stripe = await api.get("stripe/prices").json();
const plans = [config.tiers.starter, ...stripe].sort((a, b) => a.tier - b.tier);
return { props: { plans } };
});
function SkylinkList({ items = [], timestamp }) {
return (
<ul className="divide-y divide-gray-200">
{items.slice(0, 3).map((item) => (
<li key={item.id}>
<div className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<a
href={skynetClient.getSkylinkUrl(item.skylink)}
className="text-sm font-medium text-green-600 hover:text-green-900 truncate"
target="_blank"
rel="noopener noreferrer"
>
{item.name || "— file name not available —"}
</a>
<abbr
className="text-xs text-gray-400 whitespace-nowrap ml-2 cursor-pointer"
title="Click to copy"
onClick={() => write(`sia://${item.skylink}`)}
>
sia://{item.skylink.substr(0, 5)}…{item.skylink.substr(-5)}
</abbr>
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="sm:flex">
<p className="flex items-center text-sm text-gray-500">
<svg
className="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"
/>
</svg>
{prettyBytes(item.size)}
</p>
</div>
<div className="mt-2 flex items-center text-sm text-gray-500 sm:mt-0">
<svg
className="flex-shrink-0 mr-1.5 h-4 w-4 text-gray-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{item[timestamp] && <time dateTime={item[timestamp]}>{dayjs(item[timestamp]).fromNow()}</time>}
</div>
</div>
</div>
</li>
))}
{!items.length && (
<li>
<div className="px-4 py-4 sm:px-6">
<p className="text-sm text-gray-500">no entries yet</p>
</div>
</li>
)}
</ul>
);
}
export default function Home({ plans }) {
const { data: user } = useAccountsApi(`${apiPrefix}/user`);
const { data: stats } = useAccountsApi(`${apiPrefix}/user/stats`);
const { data: downloads } = useAccountsApi(`${apiPrefix}/user/downloads?pageSize=3&offset=0`);
const { data: uploads } = useAccountsApi(`${apiPrefix}/user/uploads?pageSize=3&offset=0`);
const activePlan = plans.find(({ tier }) => (user ? user.tier === tier : isFreeTier(tier)));
return (
<Layout title="Dashboard">
<div className="bg-white rounded-lg shadow px-5 py-6 sm:px-6">
<dl className="mt-5 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-2">
<div className="flex flex-col bg-white overflow-hidden shadow rounded-lg">
<div className="flex-grow px-4 py-5 sm:p-6">
<div className="flex items-center">
<div className="flex-shrink-0 bg-green-500 rounded-md p-3">
{/* Heroicon name: outline/users */}
<svg
className="h-6 w-6 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2"
/>
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dt className="text-sm font-medium text-gray-500 truncate">Current plan</dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-gray-900">{activePlan.name}</div>
</dd>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-4 sm:px-6">
<div className="text-sm">
<Link href="/payments">
<a className="font-medium text-green-600 hover:text-green-500 flex items-center">
Check upgrade options
<svg
className="h-4 w-4 ml-1"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"
/>
</svg>
<svg
className="h-4 w-4 ml-1"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"
/>
</svg>
</a>
</Link>
</div>
</div>
</div>
<div className="flex flex-col bg-white overflow-hidden shadow rounded-lg">
<div className="flex-grow px-4 py-5 sm:p-6">
<div className="flex items-center">
<div className="flex-shrink-0 bg-green-500 rounded-md p-3">
<svg
className="h-6 w-6 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"
/>
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dt className="text-sm font-medium text-gray-500 truncate">Storage used</dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-grey-900">{prettyBytes(stats?.storageUsed ?? 0)}</div>
</dd>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-4 sm:px-6">
<div className="text-sm">
<Link href="/uploads">
<a className="font-medium text-green-600 hover:text-green-500">View all uploads</a>
</Link>
</div>
</div>
</div>
{/* <div className="flex flex-col bg-white overflow-hidden shadow rounded-lg">
<div className="flex-grow px-4 py-5 sm:p-6">
<div className="flex items-center">
<div className="flex-shrink-0 bg-green-500 rounded-md p-3">
<svg
className="h-6 w-6 text-white transform rotate-45"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"
/>
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dt className="text-sm font-medium text-gray-500 truncate">Bandwidth used</dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-grey-900">{prettyBytes(stats?.bwDownloads ?? 0)}</div>
</dd>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-4 sm:px-6">
<div className="text-sm">
<Link href="/downloads">
<a className="font-medium text-green-600 hover:text-green-500">View all downloads</a>
</Link>
</div>
</div>
</div> */}
</dl>
{/* ============ */}
<div className="mt-5 grid grid-cols-1 gap-5 sm:grid-cols-2">
<div className="flex flex-col">
<h3 className="pb-5 text-lg leading-6 font-medium text-gray-900">Recent downloads</h3>
{/* This example requires Tailwind CSS v2.0+ */}
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<SkylinkList items={downloads?.items} timestamp="downloadedOn" />
</div>
</div>
<div className="flex flex-col">
<h3 className="pb-5 text-lg leading-6 font-medium text-gray-900">Recent uploads</h3>
{/* This example requires Tailwind CSS v2.0+ */}
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<SkylinkList items={uploads?.items} timestamp="uploadedOn" />
</div>
</div>
</div>
</div>
</Layout>
);
}

View File

@ -0,0 +1,193 @@
import dayjs from "dayjs";
import Layout from "../components/Layout";
import ky from "ky/umd";
import { useEffect, useState } from "react";
import authServerSideProps from "../services/authServerSideProps";
import classnames from "classnames";
import prettyBytes from "pretty-bytes";
import config from "../config";
import useAccountsApi from "../services/useAccountsApi";
import { isFreeTier, isPaidTier } from "../services/tiers";
const apiPrefix = process.env.NODE_ENV === "development" ? "/api/stubs" : "";
const ActiveBadge = () => {
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs bg-green-200 text-green-800 ml-3">
active
</span>
);
};
export const getServerSideProps = authServerSideProps(async (context, api) => {
const [user, stats, stripe] = await Promise.all([
api.get("user").json(),
api.get("user/stats").json(),
api.get("stripe/prices").json(),
]);
const plans = [config.tiers.starter, ...stripe].sort((a, b) => a.tier - b.tier);
return { props: { plans, user, stats } };
});
export default function Payments({ plans, user: initialUserData, stats: initialStatsData }) {
const { data: user } = useAccountsApi(`${apiPrefix}/user`, { initialData: initialUserData });
const { data: stats } = useAccountsApi(`${apiPrefix}/user/stats`, { initialData: initialStatsData });
const [selectedPlan, setSelectedPlan] = useState(plans.find(({ tier }) => isFreeTier(tier)));
const activePlan = plans.find(({ tier }) => (user ? user.tier === tier : isFreeTier(tier)));
const handleSubscribe = async () => {
try {
const price = selectedPlan.stripe;
const { sessionId } = await ky.post("/api/stripe/checkout", { json: { price } }).json();
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
await stripe.redirectToCheckout({ sessionId });
} catch (error) {
console.log(error); // todo: handle error
}
};
useEffect(() => {
if (activePlan && isPaidTier(activePlan.tier)) {
setSelectedPlan(activePlan);
}
}, [activePlan, selectedPlan, setSelectedPlan]);
return (
<Layout title="Payments">
<div className="bg-white rounded-lg shadow px-5 py-6 sm:px-6">
<div className="space-y-6 sm:px-6 lg:px-0 lg:col-span-9">
<dl className="mt-5 grid grid-cols-1 gap-5 sm:grid-cols-3">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<dt className="text-sm font-medium text-gray-500 truncate">Current plan</dt>
<dd className="mt-1 text-3xl font-semibold text-gray-900">{activePlan?.name || "—"}</dd>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<dt className="text-sm font-medium text-gray-500 truncate">Subscription status</dt>
<dd className="mt-1 text-3xl font-semibold text-gray-900 capitalize">
{isFreeTier(activePlan?.tier) ? "—" : user?.subscriptionStatus}
</dd>
</div>
{user?.subscriptionCancelAtPeriodEnd && (
<div className="bg-gray-50 px-4 py-4 sm:px-6">
<div className="text-sm">
Your plan will be cancelled on {dayjs(user.subscriptionCancelAt).format("D MMM YYYY")}.
</div>
</div>
)}
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<dt className="text-sm font-medium text-gray-500 truncate">Storage used</dt>
<dd className="mt-1 text-3xl font-semibold text-gray-900">{prettyBytes(stats.storageUsed)}</dd>
</div>
</div>
</dl>
<section aria-labelledby="plan_heading">
<form action="#" method="POST">
<div className="shadow sm:rounded-md sm:overflow-hidden">
<div className="bg-white py-6 px-4 space-y-6 sm:p-6">
<div className="-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap">
<div className="ml-4 mt-2">
<h3 id="plan_heading" className="text-lg leading-6 font-medium text-gray-900">
Plan
</h3>
</div>
</div>
<fieldset>
<legend className="sr-only">Pricing plans</legend>
<ul className="relative bg-white rounded-md -space-y-px">
{plans.map((plan, index) => (
<li key={plan.id}>
<label
className={`${classnames({
"rounded-tl-md rounded-tr-md": index === 0,
"rounded-bl-md rounded-br-md": index === plans.length - 1,
"bg-green-50 border-green-200 z-10": plan === selectedPlan,
"border-gray-200": plan !== selectedPlan,
"cursor-pointer": isFreeTier(user?.tier),
})} relative border p-4 flex flex-col md:pl-4 md:pr-6 md:grid md:grid-cols-3`}
>
<span className="flex items-center text-sm">
{isFreeTier(activePlan?.tier) && (
<input
name="pricing_plan"
type="radio"
className="h-4 w-4 text-orange-500 focus:ring-gray-900 border-gray-300"
aria-describedby="plan-option-pricing-0 plan-option-limit-0"
checked={plan === selectedPlan}
onChange={() => setSelectedPlan(plan)}
/>
)}
<span
className={classnames("ml-3 font-medium", {
"text-green-900": plan === selectedPlan,
"text-gray-900": plan !== selectedPlan,
})}
>
{plan.name}
</span>
{activePlan === plan && <ActiveBadge />}
</span>
<p id="plan-option-pricing-0" className="ml-6 pl-1 text-sm md:ml-0 md:pl-0 md:text-center">
<span
className={classnames("font-medium", {
"text-green-900": plan === selectedPlan,
"text-gray-900": plan !== selectedPlan,
})}
>
{plan.price ? `$${plan.price} / mo` : "no cost"}
</span>
</p>
<p
className={classnames("ml-6 pl-1 text-sm md:ml-0 md:pl-0 md:text-right", {
"text-green-700": plan === selectedPlan,
"text-gray-500": plan !== selectedPlan,
})}
>
{plan.description}
</p>
</label>
</li>
))}
</ul>
</fieldset>
</div>
<div className="px-4 py-3 bg-gray-50 sm:px-6 flex flex-col">
{user && isPaidTier(user.tier) ? (
<div className="text-sm text-gray-500 flex justify-between items-center space-x-4 md:space-x-0 flex-col md:flex-row space-y-4 md:space-y-0">
<span className="text-center md:text-left">
Use Stripe Customer Portal to manage your active subscription, payment methods and view your
billing history
</span>
<a
href="/api/stripe/billing"
className="text-right flex-shrink-0 w-full md:w-auto bg-green-800 disabled:bg-gray-300 disabled:text-gray-400 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-green-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900"
>
Stripe Customer Portal
</a>
</div>
) : (
<button
type="button"
onClick={handleSubscribe}
disabled={activePlan === selectedPlan}
className="self-end text-right w-full md:w-auto bg-green-800 disabled:bg-gray-300 disabled:text-gray-400 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-green-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900"
>
Subscribe
</button>
)}
</div>
</div>
</form>
</section>
</div>
</div>
</Layout>
);
}

View File

@ -0,0 +1,389 @@
import Layout from "../components/Layout";
export default function Payments() {
return (
<Layout title="Pricing Plans">
{/* This example requires Tailwind CSS v2.0+ */}
<div className="bg-white rounded-lg shadow px-5 py-6 sm:px-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="sm:flex sm:flex-col sm:align-center">
<p className="mt-5 text-xl text-gray-500 sm:text-center">
Start using for free, then add a plan to improve the experience. Account plans unlock additional features.
</p>
<div className="relative self-center mt-6 bg-gray-100 rounded-lg p-0.5 flex sm:mt-8">
<button
type="button"
className="relative w-1/2 bg-white border-gray-200 rounded-md shadow-sm py-2 text-sm font-medium text-gray-700 whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-green-500 focus:z-10 sm:w-auto sm:px-8"
>
Monthly billing
</button>
<button
type="button"
className="ml-0.5 relative w-1/2 border border-transparent rounded-md py-2 text-sm font-medium text-gray-700 whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-green-500 focus:z-10 sm:w-auto sm:px-8"
>
Yearly billing
</button>
</div>
</div>
<div className="mt-12 space-y-4 sm:mt-16 sm:space-y-0 sm:grid sm:grid-cols-2 sm:gap-6 lg:max-w-4xl lg:mx-auto xl:max-w-none xl:mx-0 xl:grid-cols-4">
<div className="border border-gray-200 rounded-lg shadow-sm divide-y divide-gray-200">
<div className="p-6">
<h2 className="text-lg leading-6 font-medium text-gray-900">Free</h2>
<p className="mt-4 text-sm text-gray-500">All the basics for starting a new business</p>
<p className="mt-8">
<span className="text-4xl font-extrabold text-gray-900">no cost</span>
</p>
<a
href="#"
className="opacity-50 mt-8 block w-full bg-gray-400 border border-gray-400 rounded-md py-2 text-sm font-semibold text-white text-center hover:bg-gray-900"
>
Active
</a>
</div>
<div className="pt-6 pb-8 px-6">
<h3 className="text-xs font-medium text-gray-900 tracking-wide uppercase">What's included</h3>
<ul className="mt-6 space-y-4">
<li className="flex space-x-3">
{/* Heroicon name: solid/check */}
<svg
className="flex-shrink-0 h-5 w-5 text-green-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-500">Potenti felis, in cras at at ligula nunc.</span>
</li>
<li className="flex space-x-3">
{/* Heroicon name: solid/check */}
<svg
className="flex-shrink-0 h-5 w-5 text-green-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-500">Orci neque eget pellentesque.</span>
</li>
</ul>
</div>
</div>
<div className="border border-gray-200 rounded-lg shadow-sm divide-y divide-gray-200">
<div className="p-6">
<h2 className="text-lg leading-6 font-medium text-gray-900">Skynet Plus</h2>
<p className="mt-4 text-sm text-gray-500">All the basics for starting a new business</p>
<p className="mt-8">
<span className="text-4xl font-extrabold text-gray-900">$5</span>
<span className="text-base font-medium text-gray-500">/mo</span>
</p>
<a
href="#"
className="mt-8 block w-full bg-gray-800 border border-gray-800 rounded-md py-2 text-sm font-semibold text-white text-center hover:bg-gray-900"
>
Buy Skynet Plus
</a>
</div>
<div className="pt-6 pb-8 px-6">
<h3 className="text-xs font-medium text-gray-900 tracking-wide uppercase">What's included</h3>
<ul className="mt-6 space-y-4">
<li className="flex space-x-3">
{/* Heroicon name: solid/check */}
<svg
className="flex-shrink-0 h-5 w-5 text-green-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-500">Potenti felis, in cras at at ligula nunc. </span>
</li>
<li className="flex space-x-3">
{/* Heroicon name: solid/check */}
<svg
className="flex-shrink-0 h-5 w-5 text-green-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-500">Orci neque eget pellentesque.</span>
</li>
<li className="flex space-x-3">
{/* Heroicon name: solid/check */}
<svg
className="flex-shrink-0 h-5 w-5 text-green-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-500">Donec mauris sit in eu tincidunt etiam.</span>
</li>
</ul>
</div>
</div>
<div className="border border-gray-200 rounded-lg shadow-sm divide-y divide-gray-200">
<div className="p-6">
<h2 className="text-lg leading-6 font-medium text-gray-900">Skynet Pro</h2>
<p className="mt-4 text-sm text-gray-500">All the basics for starting a new business</p>
<p className="mt-8">
<span className="text-4xl font-extrabold text-gray-900">$20</span>
<span className="text-base font-medium text-gray-500">/mo</span>
</p>
<a
href="#"
className="mt-8 block w-full bg-gray-800 border border-gray-800 rounded-md py-2 text-sm font-semibold text-white text-center hover:bg-gray-900"
>
Buy Skynet Pro
</a>
</div>
<div className="pt-6 pb-8 px-6">
<h3 className="text-xs font-medium text-gray-900 tracking-wide uppercase">What's included</h3>
<ul className="mt-6 space-y-4">
<li className="flex space-x-3">
{/* Heroicon name: solid/check */}
<svg
className="flex-shrink-0 h-5 w-5 text-green-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-500">Potenti felis, in cras at at ligula nunc. </span>
</li>
<li className="flex space-x-3">
{/* Heroicon name: solid/check */}
<svg
className="flex-shrink-0 h-5 w-5 text-green-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-500">Orci neque eget pellentesque.</span>
</li>
<li className="flex space-x-3">
{/* Heroicon name: solid/check */}
<svg
className="flex-shrink-0 h-5 w-5 text-green-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-500">Donec mauris sit in eu tincidunt etiam.</span>
</li>
<li className="flex space-x-3">
{/* Heroicon name: solid/check */}
<svg
className="flex-shrink-0 h-5 w-5 text-green-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-500">Faucibus volutpat magna.</span>
</li>
</ul>
</div>
</div>
<div className="border border-gray-200 rounded-lg shadow-sm divide-y divide-gray-200">
<div className="p-6">
<h2 className="text-lg leading-6 font-medium text-gray-900">Skynet Extreme</h2>
<p className="mt-4 text-sm text-gray-500">All the basics for starting a new business</p>
<p className="mt-8">
<span className="text-4xl font-extrabold text-gray-900">$80</span>
<span className="text-base font-medium text-gray-500">/mo</span>
</p>
<a
href="#"
className="mt-8 block w-full bg-gray-800 border border-gray-800 rounded-md py-2 text-sm font-semibold text-white text-center hover:bg-gray-900"
>
Buy Skynet Extreme
</a>
</div>
<div className="pt-6 pb-8 px-6">
<h3 className="text-xs font-medium text-gray-900 tracking-wide uppercase">What's included</h3>
<ul className="mt-6 space-y-4">
<li className="flex space-x-3">
{/* Heroicon name: solid/check */}
<svg
className="flex-shrink-0 h-5 w-5 text-green-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-500">Potenti felis, in cras at at ligula nunc. </span>
</li>
<li className="flex space-x-3">
{/* Heroicon name: solid/check */}
<svg
className="flex-shrink-0 h-5 w-5 text-green-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-500">Orci neque eget pellentesque.</span>
</li>
<li className="flex space-x-3">
{/* Heroicon name: solid/check */}
<svg
className="flex-shrink-0 h-5 w-5 text-green-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-500">Donec mauris sit in eu tincidunt etiam.</span>
</li>
<li className="flex space-x-3">
{/* Heroicon name: solid/check */}
<svg
className="flex-shrink-0 h-5 w-5 text-green-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-500">Faucibus volutpat magna.</span>
</li>
<li className="flex space-x-3">
{/* Heroicon name: solid/check */}
<svg
className="flex-shrink-0 h-5 w-5 text-green-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-500">Id sed tellus in varius quisque.</span>
</li>
<li className="flex space-x-3">
{/* Heroicon name: solid/check */}
<svg
className="flex-shrink-0 h-5 w-5 text-green-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-500">Risus egestas faucibus.</span>
</li>
<li className="flex space-x-3">
{/* Heroicon name: solid/check */}
<svg
className="flex-shrink-0 h-5 w-5 text-green-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-gray-500">Risus cursus ullamcorper.</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</Layout>
);
}

View File

@ -0,0 +1,95 @@
import Link from "next/link";
import { Configuration, PublicApi } from "@ory/kratos-client";
import config from "../config";
import SelfServiceForm from "../components/Form/SelfServiceForm";
const kratos = new PublicApi(new Configuration({ basePath: config.kratos.public }));
export async function getServerSideProps(context) {
const flow = context.query.flow;
if (process.env.NODE_ENV === "development") {
return { props: { flow: require("../../stubs/recovery.json") } };
}
// The flow is used to identify the login and registration flow and
// return data like the csrf_token and so on.
if (!flow || typeof flow !== "string") {
// No flow ID found in URL, initializing recovery flow.
return {
redirect: {
permanent: false,
destination: `${config.kratos.browser}/self-service/recovery/browser`,
},
};
}
try {
const { status, data } = await kratos.getSelfServiceRecoveryFlow(flow);
if (status === 200) return { props: { flow: data } };
throw new Error(`Failed to retrieve flow ${flow} with code ${status}`);
} catch (error) {
return {
redirect: {
permanent: false,
destination: `${config.kratos.browser}/self-service/recovery/browser`,
},
};
}
}
const fieldsConfig = {
email: {
label: "Your email",
autoComplete: "email",
position: 0,
},
csrf_token: {
position: 99,
},
};
export default function Recovery({ flow }) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<svg
xmlns="http://www.w3.org/2000/svg"
width="169"
height="39"
viewBox="0 0 169 39"
className="mx-auto h-12 w-auto"
>
<path
d="M160.701 31.245c-2.736 0-4.788-.706-6.156-2.118-1.369-1.411-2.053-3.424-2.053-6.039V2.674h7.487v6.33H169v6.15h-9.02v7.355c0 1.753.886 2.63 2.66 2.63h6.134v6.106h-8.073zm-24.942 0c-1.744 0-3.352-.268-4.826-.803-1.473-.535-2.751-1.292-3.833-2.273a10.275 10.275 0 01-2.526-3.521c-.602-1.367-.902-2.882-.902-4.546 0-1.635.3-3.135.902-4.502a10.275 10.275 0 012.526-3.521c1.082-.98 2.36-1.738 3.833-2.273 1.474-.535 3.082-.803 4.826-.803h5.728c1.293 0 2.42.179 3.383.535.962.357 1.767.847 2.413 1.471a5.823 5.823 0 011.443 2.251c.316.877.474 1.835.474 2.875 0 1.961-.571 3.432-1.714 4.412-1.143.981-3.022 1.471-5.638 1.471h-8.028v-4.056h6.45c1.172 0 1.759-.52 1.759-1.56 0-1.1-.572-1.649-1.714-1.649h-4.6c-1.354 0-2.451.46-3.293 1.382-.842.921-1.263 2.228-1.263 3.922s.42 2.964 1.263 3.811c.842.847 1.94 1.27 3.292 1.27h12.268v6.107H135.76zm-22.867 0V19.567c0-2.526-1.308-3.789-3.924-3.789h-7.532v15.467H93.86V9.003h15.29c3.728 0 6.54.922 8.434 2.764 1.894 1.842 2.841 4.457 2.841 7.844v11.634h-7.532zM67.293 39v-6.418h2.39c.963 0 1.715-.193 2.256-.58a4.58 4.58 0 001.308-1.426L62.422 9.003h7.623l6.675 14.263 6.855-14.263h7.668L78.749 33.43c-.48.95-.984 1.775-1.51 2.473a7.85 7.85 0 01-1.782 1.739 7.11 7.11 0 01-2.233 1.025c-.827.223-1.781.334-2.864.334h-3.067zm-14.207-7.755l-7.713-9.316a4.647 4.647 0 01-.54-.914 2.306 2.306 0 01-.181-.913v-.535c0-.654.225-1.278.676-1.872l7.487-8.692h8.48l-9.02 10.787 9.47 11.455h-8.66zm-17.41 0V0h7.532v31.245h-7.532zm-35.315 0v-7.488h21.92c.571 0 1.037-.171 1.398-.513.36-.342.541-.825.541-1.449 0-.624-.18-1.114-.541-1.47-.36-.357-.827-.535-1.398-.535H9.38c-1.353 0-2.608-.216-3.766-.647-1.157-.43-2.15-1.025-2.976-1.782a8.005 8.005 0 01-1.94-2.742C.233 13.55 0 12.361 0 11.054 0 9.746.233 8.55.7 7.466A8.184 8.184 0 012.638 4.68c.826-.773 1.819-1.367 2.976-1.783 1.158-.416 2.413-.624 3.766-.624h22.281v7.444H9.742c-.571 0-1.03.163-1.375.49-.346.327-.52.802-.52 1.426 0 .594.174 1.07.52 1.426.345.357.804.535 1.375.535h12.9c1.383 0 2.646.216 3.788.647a9.097 9.097 0 012.977 1.805 8.038 8.038 0 011.962 2.785c.466 1.085.7 2.266.7 3.544 0 1.337-.234 2.548-.7 3.632a8.267 8.267 0 01-1.962 2.808 8.62 8.62 0 01-2.977 1.806c-1.142.416-2.405.624-3.788.624H.36z"
fill="#57B560"
fillRule="evenodd"
/>
</svg>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Recover your account</h2>
<p className="mt-2 text-center text-sm text-gray-600 max-w">
<Link href="/auth/login">
<a className="font-medium text-green-600 hover:text-green-500">sign in</a>
</Link>{" "}
if you suddenly remembered your password
</p>
<p className="mt-2 text-center text-sm text-gray-600 max-w">
or{" "}
<Link href="/auth/registration">
<a className="font-medium text-green-600 hover:text-green-500">sign up</a>
</Link>{" "}
for a new account
</p>
</div>
<SelfServiceForm
flow={flow}
config={flow.methods.link.config}
fieldsConfig={fieldsConfig}
button="Send recovery link"
/>
</div>
);
}

View File

@ -0,0 +1,83 @@
import { Configuration, PublicApi } from "@ory/kratos-client";
import Layout from "../components/Layout";
import config from "../config";
import SelfServiceForm from "../components/Form/SelfServiceForm";
import authServerSideProps from "../services/authServerSideProps";
const kratos = new PublicApi(new Configuration({ basePath: config.kratos.public }));
export const getServerSideProps = authServerSideProps(async (context) => {
const flow = context.query.flow;
if (process.env.NODE_ENV === "development") {
return { props: { flow: require("../../stubs/settings.json") } };
}
// The flow is used to identify the login and registration flow and
// return data like the csrf_token and so on.
if (!flow || typeof flow !== "string") {
// No flow ID found in URL, initializing settings flow.
return {
redirect: {
permanent: false,
destination: `${config.kratos.browser}/self-service/settings/browser`,
},
};
}
try {
const { status, data } = await kratos.getSelfServiceSettingsFlow(flow, {
headers: { cookie: context.req.headers.cookie },
});
console.log(flow, status, data);
if (status === 200) return { props: { flow: data } };
throw new Error(`Failed to retrieve flow ${flow} with code ${status}`);
} catch (error) {
console.log(error);
return {
redirect: {
permanent: false,
destination: `${config.kratos.browser}/self-service/settings/browser`,
},
};
}
});
const fieldsConfig = {
"traits.email": {
label: "Email address",
autoComplete: "email",
position: 0,
},
password: {
label: "Password",
autoComplete: "new-password",
position: 1,
},
csrf_token: {
position: 99,
},
};
export default function Settings({ flow }) {
const profileConfig = flow.methods.profile.config;
const passwordConfig = flow.methods.password.config;
return (
<Layout title="Settings">
<div className="bg-white rounded-lg shadow px-5 py-6 sm:px-6 grid grid-cols-1 gap-5 sm:grid-cols-2">
<SelfServiceForm config={profileConfig} fieldsConfig={fieldsConfig} title="Account settings" button="Update" />
<SelfServiceForm
config={passwordConfig}
fieldsConfig={fieldsConfig}
title="Authentication settings"
button="Update"
/>
</div>
</Layout>
);
}

View File

@ -0,0 +1,44 @@
import dayjs from "dayjs";
import prettyBytes from "pretty-bytes";
import { useState } from "react";
import Layout from "../components/Layout";
import Table from "../components/Table";
import authServerSideProps from "../services/authServerSideProps";
import { SkynetClient } from "skynet-js";
import useAccountsApi from "../services/useAccountsApi";
const skynetClient = new SkynetClient(process.env.NEXT_PUBLIC_SKYNET_PORTAL_API);
const apiPrefix = process.env.NODE_ENV === "development" ? "/api/stubs" : "";
const getSkylinkLink = ({ skylink }) => skynetClient.getSkylinkUrl(skylink);
const getRelativeDate = ({ uploadedOn }) => dayjs(uploadedOn).format("YYYY-MM-DD HH:mm:ss");
const headers = [
{ key: "name", name: "Name", nowrap: false, href: getSkylinkLink },
{ key: "skylink", name: "Skylink" },
{ key: "size", name: "Size", formatter: ({ size }) => prettyBytes(size) },
{ key: "uploadedOn", name: "Uploaded on", formatter: getRelativeDate },
];
const actions = [];
export const getServerSideProps = authServerSideProps(async (context, api) => {
const initialData = await api.get("user/uploads?pageSize=10&offset=0").json();
return { props: { initialData } };
});
export default function Uploads({ initialData }) {
const [offset, setOffset] = useState(0);
const { data } = useAccountsApi(`${apiPrefix}/user/uploads?pageSize=10&offset=${offset}`, {
initialData: offset === 0 ? initialData : undefined,
revalidateOnMount: true,
});
// preload next page if it exists (based on the response from the current page query)
const nextPageOffset = data && data.offset + data.pageSize < data.count ? data.offset + data.pageSize : offset;
useAccountsApi(`${apiPrefix}/user/uploads?pageSize=10&offset=${nextPageOffset}`);
return (
<Layout title="Your uploads">
<Table {...data} headers={headers} actions={actions} setOffset={setOffset} />
</Layout>
);
}

View File

@ -0,0 +1,103 @@
import Link from "next/link";
import { Configuration, PublicApi } from "@ory/kratos-client";
import config from "../config";
import SelfServiceForm from "../components/Form/SelfServiceForm";
import { useEffect } from "react";
const kratos = new PublicApi(new Configuration({ basePath: config.kratos.public }));
export async function getServerSideProps(context) {
const flow = context.query.flow;
// if (process.env.NODE_ENV === "development") {
// return { props: { flow: require("../../stubs/recovery.json") } };
// }
if (!flow || typeof flow !== "string") {
return {
redirect: {
permanent: false,
destination: `${config.kratos.browser}/self-service/verification/browser`,
},
};
}
try {
const { status, data } = await kratos.getSelfServiceVerificationFlow(flow);
if (status === 200) return { props: { flow: data } };
throw new Error(`Failed to retrieve flow ${flow} with code ${status}`);
} catch (error) {
return {
redirect: {
permanent: false,
destination: `${config.kratos.browser}/self-service/verification/browser`,
},
};
}
}
const fieldsConfig = {
email: {
label: "Your email",
autoComplete: "email",
position: 0,
},
csrf_token: {
position: 99,
},
};
export default function Verify({ flow }) {
const state = flow.state;
useEffect(() => {
if (state === "passed_challenge") {
setTimeout(() => (window.location = "/"), 5000);
}
}, [state]);
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<svg
xmlns="http://www.w3.org/2000/svg"
width="169"
height="39"
viewBox="0 0 169 39"
className="mx-auto h-12 w-auto"
>
<path
d="M160.701 31.245c-2.736 0-4.788-.706-6.156-2.118-1.369-1.411-2.053-3.424-2.053-6.039V2.674h7.487v6.33H169v6.15h-9.02v7.355c0 1.753.886 2.63 2.66 2.63h6.134v6.106h-8.073zm-24.942 0c-1.744 0-3.352-.268-4.826-.803-1.473-.535-2.751-1.292-3.833-2.273a10.275 10.275 0 01-2.526-3.521c-.602-1.367-.902-2.882-.902-4.546 0-1.635.3-3.135.902-4.502a10.275 10.275 0 012.526-3.521c1.082-.98 2.36-1.738 3.833-2.273 1.474-.535 3.082-.803 4.826-.803h5.728c1.293 0 2.42.179 3.383.535.962.357 1.767.847 2.413 1.471a5.823 5.823 0 011.443 2.251c.316.877.474 1.835.474 2.875 0 1.961-.571 3.432-1.714 4.412-1.143.981-3.022 1.471-5.638 1.471h-8.028v-4.056h6.45c1.172 0 1.759-.52 1.759-1.56 0-1.1-.572-1.649-1.714-1.649h-4.6c-1.354 0-2.451.46-3.293 1.382-.842.921-1.263 2.228-1.263 3.922s.42 2.964 1.263 3.811c.842.847 1.94 1.27 3.292 1.27h12.268v6.107H135.76zm-22.867 0V19.567c0-2.526-1.308-3.789-3.924-3.789h-7.532v15.467H93.86V9.003h15.29c3.728 0 6.54.922 8.434 2.764 1.894 1.842 2.841 4.457 2.841 7.844v11.634h-7.532zM67.293 39v-6.418h2.39c.963 0 1.715-.193 2.256-.58a4.58 4.58 0 001.308-1.426L62.422 9.003h7.623l6.675 14.263 6.855-14.263h7.668L78.749 33.43c-.48.95-.984 1.775-1.51 2.473a7.85 7.85 0 01-1.782 1.739 7.11 7.11 0 01-2.233 1.025c-.827.223-1.781.334-2.864.334h-3.067zm-14.207-7.755l-7.713-9.316a4.647 4.647 0 01-.54-.914 2.306 2.306 0 01-.181-.913v-.535c0-.654.225-1.278.676-1.872l7.487-8.692h8.48l-9.02 10.787 9.47 11.455h-8.66zm-17.41 0V0h7.532v31.245h-7.532zm-35.315 0v-7.488h21.92c.571 0 1.037-.171 1.398-.513.36-.342.541-.825.541-1.449 0-.624-.18-1.114-.541-1.47-.36-.357-.827-.535-1.398-.535H9.38c-1.353 0-2.608-.216-3.766-.647-1.157-.43-2.15-1.025-2.976-1.782a8.005 8.005 0 01-1.94-2.742C.233 13.55 0 12.361 0 11.054 0 9.746.233 8.55.7 7.466A8.184 8.184 0 012.638 4.68c.826-.773 1.819-1.367 2.976-1.783 1.158-.416 2.413-.624 3.766-.624h22.281v7.444H9.742c-.571 0-1.03.163-1.375.49-.346.327-.52.802-.52 1.426 0 .594.174 1.07.52 1.426.345.357.804.535 1.375.535h12.9c1.383 0 2.646.216 3.788.647a9.097 9.097 0 012.977 1.805 8.038 8.038 0 011.962 2.785c.466 1.085.7 2.266.7 3.544 0 1.337-.234 2.548-.7 3.632a8.267 8.267 0 01-1.962 2.808 8.62 8.62 0 01-2.977 1.806c-1.142.416-2.405.624-3.788.624H.36z"
fill="#57B560"
fillRule="evenodd"
/>
</svg>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
{flow.state === "passed_challenge" ? "Verification successful!" : "Account verification"}
</h2>
{flow.state === "passed_challenge" && (
<>
<p className="mt-2 text-center text-sm text-gray-600 max-w">You will be redirected automatically</p>
<p className="mt-2 text-center text-sm text-gray-600 max-w">
<Link href="/">
<a className="font-medium text-green-600 hover:text-green-500">go to dashboard</a>
</Link>
</p>
</>
)}
</div>
{flow.state !== "passed_challenge" && (
<SelfServiceForm
flow={flow}
config={flow.methods.link.config}
fieldsConfig={fieldsConfig}
button="Resend verification link"
/>
)}
</div>
);
}

View File

@ -0,0 +1,27 @@
import ky from "ky/umd";
const isProduction = process.env.NODE_ENV === "production";
export default function authServerSideProps(getServerSideProps) {
return function authenticate(context) {
if (isProduction && (!("ory_kratos_session" in context.req.cookies) || !("skynet-jwt" in context.req.cookies))) {
return {
redirect: {
permanent: false,
destination: `/api/accounts/login?return_to=${encodeURIComponent(context.resolvedUrl)}`,
},
};
}
if (getServerSideProps) {
const api = ky.create({
headers: { cookie: context.req.headers.cookie },
prefixUrl: isProduction ? "http://oathkeeper:4455" : "http://localhost:3000/api/stubs",
});
return getServerSideProps(context, api);
}
return { props: {} };
};
}

View File

@ -0,0 +1,65 @@
// @source https://github.com/trekhleb/javascript-algorithms/blob/master/src/algorithms/sets/longest-common-subsequence/longestCommonSubsequence.js
// @license MIT https://github.com/trekhleb/javascript-algorithms/blob/master/LICENSE
/**
* @param {string[]} set1
* @param {string[]} set2
* @return {string[]}
*/
export default function longestCommonSubsequence(set1, set2) {
// Init LCS matrix.
const lcsMatrix = Array(set2.length + 1)
.fill(null)
.map(() => Array(set1.length + 1).fill(null));
// Fill first row with zeros.
for (let columnIndex = 0; columnIndex <= set1.length; columnIndex += 1) {
lcsMatrix[0][columnIndex] = 0;
}
// Fill first column with zeros.
for (let rowIndex = 0; rowIndex <= set2.length; rowIndex += 1) {
lcsMatrix[rowIndex][0] = 0;
}
// Fill rest of the column that correspond to each of two strings.
for (let rowIndex = 1; rowIndex <= set2.length; rowIndex += 1) {
for (let columnIndex = 1; columnIndex <= set1.length; columnIndex += 1) {
if (set1[columnIndex - 1] === set2[rowIndex - 1]) {
lcsMatrix[rowIndex][columnIndex] = lcsMatrix[rowIndex - 1][columnIndex - 1] + 1;
} else {
lcsMatrix[rowIndex][columnIndex] = Math.max(
lcsMatrix[rowIndex - 1][columnIndex],
lcsMatrix[rowIndex][columnIndex - 1]
);
}
}
}
// Calculate LCS based on LCS matrix.
if (!lcsMatrix[set2.length][set1.length]) {
// If the length of largest common string is zero then return empty string.
return [""];
}
const longestSequence = [];
let columnIndex = set1.length;
let rowIndex = set2.length;
while (columnIndex > 0 || rowIndex > 0) {
if (set1[columnIndex - 1] === set2[rowIndex - 1]) {
// Move by diagonal left-top.
longestSequence.unshift(set1[columnIndex - 1]);
columnIndex -= 1;
rowIndex -= 1;
} else if (lcsMatrix[rowIndex][columnIndex] === lcsMatrix[rowIndex][columnIndex - 1]) {
// Move left.
columnIndex -= 1;
} else {
// Move up.
rowIndex -= 1;
}
}
return longestSequence;
}

View File

@ -0,0 +1,2 @@
export const isFreeTier = (tier) => tier === 1;
export const isPaidTier = (tier) => tier > 1;

View File

@ -0,0 +1,15 @@
import useSWR from "swr";
import { StatusCodes } from "http-status-codes";
const fetcher = (url) =>
fetch(url).then((res) => {
if (res.status === StatusCodes.UNAUTHORIZED) {
window.location.href = `/auth/login?return_to=${encodeURIComponent(window.location.href)}`;
}
return res.json();
});
export default function useAccountsApi(key, config) {
return useSWR(key, fetcher, config);
}

View File

@ -0,0 +1,45 @@
{
"id": "bda73c77-1e21-4bfd-b85a-322fce2e4576",
"expires_at": "2020-01-28T13:48:04.690715Z",
"issued_at": "2020-01-28T13:38:04.690732Z",
"request_url": "http://127.0.0.1:4455/auth/browser/login",
"methods": {
"oidc": {
"method": "oidc",
"config": {
"action": "http://127.0.0.1:4455/.ory/kratos/public/auth/browser/methods/oidc/auth/bda73c77-1e21-4bfd-b85a-322fce2e4576",
"method": "POST",
"fields": [
{
"name": "csrf_token",
"type": "hidden",
"required": true,
"value": "QJreyXtUD4oUSJfGNjA/+6ydsQyq0o/rfTL6QK86VadVFg6mwgX5x1QHVQ6uRqKxmwAcavQup3ILCSwl7ke97g=="
}
]
}
},
"password": {
"method": "password",
"config": {
"action": "http://127.0.0.1:4455/.ory/kratos/public/auth/browser/methods/password/login?request=bda73c77-1e21-4bfd-b85a-322fce2e4576",
"method": "POST",
"fields": [
{
"name": "csrf_token",
"type": "hidden",
"required": true,
"value": "M1gAKA8fIhw4JOpQ/5m9mKARBAvKhzWkyhbxjtZNLG8m1NBHtk7UUXhrKJhn7yDSl4ypbZR7HT28LSfrlzDEJg=="
},
{ "name": "identifier", "type": "text", "required": true, "value": "asfdasdffads" },
{ "name": "password", "type": "password", "required": true }
],
"errors": [
{
"message": "The provided credentials are invalid. Check for spelling mistakes in your password or username, email address, or phone number."
}
]
}
}
}
}

View File

@ -0,0 +1,31 @@
{
"id": "01e21a99-8e69-41f9-b3ba-4967f714c8eb",
"type": "browser",
"expires_at": "2021-03-18T15:46:30.634635Z",
"issued_at": "2021-03-18T14:46:30.634635Z",
"request_url": "http://oathkeeper:4455/self-service/recovery/browser",
"messages": null,
"methods": {
"link": {
"method": "link",
"config": {
"action": "http://127.0.0.1:4455/.ory/kratos/public/self-service/recovery/methods/link?flow=01e21a99-8e69-41f9-b3ba-4967f714c8eb",
"method": "POST",
"fields": [
{
"name": "csrf_token",
"type": "hidden",
"required": true,
"value": "XvPfLJzxmcTWnr2rmLzFcED9Ef1PIkqSBQgx5t0yu0WuIp2S6ll+B9cQ0ClIcLQ=="
},
{
"name": "email",
"type": "email",
"required": true
}
]
}
}
},
"state": "choose_method"
}

View File

@ -0,0 +1,58 @@
{
"id": "dbff7b96-8116-42c5-8624-f9fb28f1db15",
"expires_at": "2020-01-28T13:49:01.2274112Z",
"issued_at": "2020-01-28T13:39:01.2274261Z",
"request_url": "http://127.0.0.1:4455/auth/browser/registration",
"methods": {
"oidc": {
"method": "oidc",
"config": {
"action": "http://127.0.0.1:4455/.ory/kratos/public/auth/browser/methods/oidc/auth/dbff7b96-8116-42c5-8624-f9fb28f1db15",
"method": "POST",
"fields": [
{
"name": "csrf_token",
"type": "hidden",
"required": true,
"value": "xwb6A6iHdsguYwkAM6m3jj196E7TcmiWpAavIRxuAgXSiipsEdaAhW4sy8ir3yrECuBFKI2OQA/SPXlEXRPqTA=="
}
]
}
},
"password": {
"method": "password",
"config": {
"errors": [
{
"message": "The provided credentials are invalid. Check for spelling mistakes in your password or username, email address, or phone number."
}
],
"action": "http://127.0.0.1:4455/.ory/kratos/public/auth/browser/methods/password/registration?request=dbff7b96-8116-42c5-8624-f9fb28f1db15",
"method": "POST",
"fields": [
{
"name": "csrf_token",
"type": "hidden",
"required": true,
"value": "xLg4B9WnuC0Ue+j9ay5EQvleaJpOl0H9xJJ7W3+Bwv7RNOhobPZOYFQ0KjXzWNkIzsPF/BBraWSyqa0+Pvwqtw=="
},
{
"name": "password",
"type": "password",
"required": true,
"errors": [{ "message": "password: Is required" }]
},
{
"name": "traits.email",
"type": "text",
"value": "",
"errors": [
{ "message": "traits.email: String length must be greater than or equal to 3" },
{ "message": "traits.email: Does not match format 'email'" }
]
}
]
}
}
}
}

View File

@ -0,0 +1,49 @@
{
"id": "e01f6bf9-845c-4c4b-a3ce-60691dd7a97c",
"type": "browser",
"expires_at": "2021-02-18T13:31:43.947193Z",
"issued_at": "2021-02-18T12:31:43.947193Z",
"request_url": "http://oathkeeper:4455/self-service/settings/browser",
"messages": null,
"methods": {
"password": {
"method": "password",
"config": {
"action": "https://account.siasky.xyz/.ory/kratos/public/self-service/settings/methods/password?flow=e01f6bf9-845c-4c4b-a3ce-60691dd7a97c",
"method": "POST",
"fields": [
{ "name": "password", "type": "password", "required": true },
{
"name": "csrf_token",
"type": "hidden",
"required": true,
"value": "i33iydUBN6jqwrkHlLPZ3ap7Ah4uC0UrTadn7zKT0jyouDpr+DF0/1Hbkshye9t7IKwyzOcLt7i6oR/OoEVg+g=="
}
]
}
},
"profile": {
"method": "profile",
"config": {
"action": "https://account.siasky.xyz/.ory/kratos/public/self-service/settings/methods/profile?flow=e01f6bf9-845c-4c4b-a3ce-60691dd7a97c",
"method": "POST",
"fields": [
{
"name": "csrf_token",
"type": "hidden",
"required": true,
"value": "i33iydUBN6jqwrkHlLPZ3ap7Ah4uC0UrTadn7zKT0jyouDpr+DF0/1Hbkshye9t7IKwyzOcLt7i6oR/OoEVg+g=="
},
{ "name": "traits.email", "type": "email", "value": "karol@nebulous.tech" }
]
}
}
},
"identity": {
"id": "ab776d6d-f324-4fa7-a728-7587d5215481",
"schema_id": "default",
"schema_url": "",
"traits": { "email": "karol@nebulous.tech" }
},
"state": "show_form"
}

View File

@ -0,0 +1,122 @@
.container {
min-height: 100vh;
padding: 0 0.5rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.main {
padding: 5rem 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.footer {
width: 100%;
height: 100px;
border-top: 1px solid #eaeaea;
display: flex;
justify-content: center;
align-items: center;
}
.footer img {
margin-left: 0.5rem;
}
.footer a {
display: flex;
justify-content: center;
align-items: center;
}
.title a {
color: #0070f3;
text-decoration: none;
}
.title a:hover,
.title a:focus,
.title a:active {
text-decoration: underline;
}
.title {
margin: 0;
line-height: 1.15;
font-size: 4rem;
}
.title,
.description {
text-align: center;
}
.description {
line-height: 1.5;
font-size: 1.5rem;
}
.code {
background: #fafafa;
border-radius: 5px;
padding: 0.75rem;
font-size: 1.1rem;
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New,
monospace;
}
.grid {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
max-width: 800px;
margin-top: 3rem;
}
.card {
margin: 1rem;
flex-basis: 45%;
padding: 1.5rem;
text-align: left;
color: inherit;
text-decoration: none;
border: 1px solid #eaeaea;
border-radius: 10px;
transition: color 0.15s ease, border-color 0.15s ease;
}
.card:hover,
.card:focus,
.card:active {
color: #0070f3;
border-color: #0070f3;
}
.card h3 {
margin: 0 0 1rem 0;
font-size: 1.5rem;
}
.card p {
margin: 0;
font-size: 1.25rem;
line-height: 1.5;
}
.logo {
height: 1em;
}
@media (max-width: 600px) {
.grid {
width: 100%;
flex-direction: column;
}
}

View File

@ -0,0 +1,16 @@
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans,
Helvetica Neue, sans-serif;
}
a {
color: inherit;
text-decoration: none;
}
* {
box-sizing: border-box;
}

View File

@ -0,0 +1,18 @@
module.exports = {
purge: ["./src/**/*.js"],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {
fontFamily: {
sans: ["Metropolis", "Helvetica", "Arial", "Sans-Serif"],
},
},
},
variants: {
extend: {
backgroundColor: ["disabled"],
textColor: ["disabled"],
},
},
plugins: [require("@tailwindcss/forms")],
};

View File

@ -1,4 +1,4 @@
FROM node:15.8.0-alpine
FROM node:15.12.0-alpine
WORKDIR /usr/app

View File

@ -1,4 +1,4 @@
FROM node:15.8.0-alpine
FROM node:15.12.0-alpine
WORKDIR /usr/app

View File

@ -1,15 +1,24 @@
FROM node:15.8.0-alpine
FROM node:15.12.0-alpine
RUN apk add --no-cache autoconf automake libtool gcc make g++ zlib-dev file nasm util-linux
RUN apk update && apk add autoconf automake libtool gcc make g++ zlib-dev file nasm util-linux
WORKDIR /usr/app
COPY package.json .
ENV CYPRESS_INSTALL_BINARY 0
RUN yarn --no-lockfile
COPY src ./src
COPY static ./static
COPY gatsby-config.js .
COPY package.json .
ENV CYPRESS_INSTALL_BINARY 0
ARG WITH_ACCOUNTS=0
ENV GATSBY_TELEMETRY_DISABLED 1
RUN yarn --no-lockfile
RUN yarn build
ENV GATSBY_WITH_ACCOUNTS $WITH_ACCOUNTS
EXPOSE 9000
CMD [ "sh", "-c", "yarn build && yarn serve --host 0.0.0.0" ]

View File

@ -6,6 +6,7 @@
"dependencies": {
"@fontsource/metropolis": "^4.2.1",
"axios": "0.21.1",
"boolean": "^3.0.2",
"bytes": "3.1.0",
"classnames": "2.2.6",
"gatsby": "^3.0.4",
@ -17,6 +18,7 @@
"gatsby-plugin-sass": "^4.0.2",
"gatsby-source-filesystem": "^3.0.0",
"http-status-codes": "2.1.4",
"js-cookie": "^2.2.1",
"jsonp": "0.2.1",
"node-sass": "5.0.0",
"path-browserify": "1.0.1",

View File

@ -1,4 +1,5 @@
import React from "react";
import { boolean } from "boolean";
import logo from "../../images/logo.svg";
import "./HomeTop.scss";
import { Skynet, Deco1, Deco2 } from "../../svg";
@ -15,6 +16,18 @@ export default function HomeTop() {
The decentralized CDN and file sharing platform for devs. Skynet is the storage foundation for a Free Internet!
</p>
{boolean(process.env.GATSBY_WITH_ACCOUNTS) && (
<p className="auth-links">
<a href="/account/auth/registration" className="link">
Sign up now!
</a>{" "}
Already have an account? Go to your{" "}
<a href="/account/" className="link">
dashboard
</a>
</p>
)}
<Deco1 className="deco-1" />
<Deco2 className="deco-2" />
</div>

View File

@ -36,10 +36,22 @@
max-width: 560px;
margin: 0 auto;
&.auth-links {
font-size: 18px;
.link {
font-weight: bold;
}
}
@media (min-width: $largebp) {
font-size: 24px;
max-width: 670px;
}
& + p {
margin-top: 24px;
}
}
}

21
scripts/crdb_backup.sh Normal file
View File

@ -0,0 +1,21 @@
#!/bin/bash
# Get current working directory (pwd doesn't cut it)
cwd=$(cd -P -- "$(dirname -- "$0")" && pwd -P)
# Set the environment:
set -o allexport
source $cwd/../.env
set +o allexport
# Check for AWS credentials:
if [[ $AWS_ACCESS_KEY_ID == "" || $AWS_SECRET_ACCESS_KEY == "" ]]; then
echo "Missing AWS credentials!"
exit 1
fi
# Take the current datetime:
DT=`date +%Y-%m-%d`
# Create the backup:
docker exec cockroach \
cockroach sql \
--host cockroach:26257 \
--certs-dir=/certs \
--execute="BACKUP TO 's3://skynet-crdb-backups/backups/cockroach/$DT?AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID&AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY';"

25
scripts/crdb_restore.sh Normal file
View File

@ -0,0 +1,25 @@
#!/bin/bash
BACKUP=$1
if [[ $BACKUP == "" ]]; then
echo "No backup name given. It should look like '2020-01-29'."
exit 1
fi
# Get current working directory (pwd doesn't cut it)
cwd=$(cd -P -- "$(dirname -- "$0")" && pwd -P)
# Set the environment:
set -o allexport
source $cwd/../.env
set +o allexport
# Check for AWS credentials:
if [[ $AWS_ACCESS_KEY_ID == "" || $AWS_SECRET_ACCESS_KEY == "" ]]; then
echo "Missing AWS credentials!"
exit 1
fi
# Restore the backup:
docker exec cockroach \
cockroach sql \
--host cockroach:26257 \
--certs-dir=/certs \
--execute="RESTORE DATABASE defaultdb FROM 's3://skynet-crdb-backups/backups/cockroach/$DT?AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID&AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY';"

32
scripts/mongo_backup.sh Normal file
View File

@ -0,0 +1,32 @@
#!/bin/bash
# Get current working directory (pwd doesn't cut it)
cwd=$(cd -P -- "$(dirname -- "$0")" && pwd -P)
# Set the environment:
set -o allexport
source $cwd/../.env
set +o allexport
# Check for AWS credentials:
if [[ $AWS_ACCESS_KEY_ID == "" || $AWS_SECRET_ACCESS_KEY == "" ]]; then
echo "Missing AWS credentials!"
exit 1
fi
# Take the current datetime:
DT=`date +%Y-%m-%d`
# Check if a backup already exists:
totalFoundObjects=$(aws s3 ls s3://skynet-crdb-backups/backups/mongo/ --recursive --summarize | grep "$DT.tgz" | wc -l)
if [ "$totalFoundObjects" -eq "1" ]; then
echo "Backup already exists for today. Exiting."
exit 0
fi
# Create the backup:
docker exec mongo \
mongodump \
-o /data/db/backups/$DT \
mongodb://$SKYNET_DB_USER:$SKYNET_DB_PASS@$SKYNET_DB_HOST:$SKYNET_DB_PORT
# Compress the backup:
cd $cwd/../docker/data/mongo/db/backups/ && tar -czf $DT.tgz $DT && cd -
# Upload the backup to S3:
aws s3 cp $DT.tgz s3://skynet-crdb-backups/backups/mongo/
# Clean up
rm -rf $DT.tgz $cwd/../docker/data/mongo/db/backups/$DT

40
scripts/mongo_restore.sh Normal file
View File

@ -0,0 +1,40 @@
#!/bin/bash
BACKUP=$1
if [[ $BACKUP == "" ]]; then
echo "No backup name given. It should look like '2020-01-29'."
exit 1
fi
# Get current working directory (pwd doesn't cut it)
cwd=$(cd -P -- "$(dirname -- "$0")" && pwd -P)
# Set the environment:
set -o allexport
source $cwd/../.env
set +o allexport
# Check for AWS credentials:
if [[ $AWS_ACCESS_KEY_ID == "" || $AWS_SECRET_ACCESS_KEY == "" ]]; then
echo "Missing AWS credentials!"
exit 1
fi
# Check if the backup exists:
totalFoundObjects=$(aws s3 ls s3://skynet-crdb-backups/backups/mongo/ --recursive --summarize | grep "$DT.tgz" | wc -l)
if [ "$totalFoundObjects" -eq "0" ]; then
echo "This backup doesn't exist!"
exit 1
fi
# Get the backup from S3:
aws s3 cp s3://skynet-crdb-backups/backups/mongo/$BACKUP.tgz $BACKUP.tgz
# Prepare a clean `to_restore` dir:
rm -rf $cwd/../docker/data/mongo/db/backups/to_restore
mkdir -p $cwd/../docker/data/mongo/db/backups/to_restore
# Decompress the backup:
tar -xzf $BACKUP.tgz -C $cwd/../docker/data/mongo/db/backups/to_restore
rm $BACKUP.tgz
# Restore the backup:
docker exec mongo \
mongorestore \
mongodb://$SKYNET_DB_USER:$SKYNET_DB_PASS@$SKYNET_DB_HOST:$SKYNET_DB_PORT \
/data/db/backups/to_restore/$BACKUP
# Clean up:
rm -rf $cwd/../docker/data/mongo/db/backups/to_restore

View File

@ -19,6 +19,8 @@ You may want to fork this repository and replace ssh keys in
- [handshake](https://handshake.org) ([github](https://github.com/handshake-org/hsd)): full handshake node
- [handshake-api](https://github.com/NebulousLabs/skynet-webportal/tree/master/packages/handshake-api): simple API talking to the handshake node - [read more](https://github.com/NebulousLabs/skynet-webportal/blob/master/packages/handshake-api/README.md)
- [webapp](https://github.com/NebulousLabs/skynet-webportal/tree/master/packages/webapp): portal frontend application - [read more](https://github.com/NebulousLabs/skynet-webportal/blob/master/packages/webapp/README.md)
- [kratos](https://www.ory.sh/kratos/): user account management system
- [oathkeeper](https://www.ory.sh/oathkeeper/): identity and access proxy
- discord integration
- [funds-checker](funds-checker.py): script that checks wallet balance and sends status messages to discord periodically
- [health-checker](health-checker.py): script that monitors health-check service for server health issues and reports them to discord periodically
@ -83,10 +85,21 @@ At this point we have almost everything running, we just need to set up your wal
- `CLOUDFLARE_AUTH_TOKEN` (optional) if using cloudflare as dns loadbalancer (need to change it in Caddyfile too)
- `AWS_ACCESS_KEY_ID` (optional) if using route53 as a dns loadbalancer
- `AWS_SECRET_ACCESS_KEY` (optional) if using route53 as a dns loadbalancer
- `PORTAL_NAME` (optional) e.g. `siasky.xyz`
- `DISCORD_BOT_TOKEN` (optional) if you're using Discord notifications for health checks and such
- `SKYNET_DB_USER` (optional) if using `accounts` this is the MongoDB username
- `SKYNET_DB_PASS` (optional) if using `accounts` this is the MongoDB password
- `SKYNET_DB_HOST` (optional) if using `accounts` this is the MongoDB address or container name
- `SKYNET_DB_PORT` (optional) if using `accounts` this is the MongoDB port
- `COOKIE_DOMAIN` (optional) if using `accounts` this is the domain to which your cookies will be issued
- `COOKIE_HASH_KEY` (optional) if using `accounts` hashing secret, at least 32 bytes
- `COOKIE_ENC_KEY` (optional) if using `accounts` encryption key, at least 32 bytes
1. if you have a custom domain and you configured it in `DOMAIN_NAME`, edit `/home/user/skynet-webportal/docker/caddy/Caddyfile` and uncomment `import custom.domain`
1. only for siasky.net domain instances: edit `/home/user/skynet-webportal/docker/caddy/Caddyfile`, uncomment `import siasky.net`
1. `docker-compose up -d` to restart the services so they pick up new env variables
1. `docker exec caddy caddy reload --config /etc/caddy/Caddyfile` to reload Caddyfile configuration
1. add your custom Kratos configuration to `/home/user/skynet-webportal/docker/kratos/config/kratos.yml` (in particular, the credentials for your mail server should be here, rather than in your source control). For a starting point you can take `docker/kratos/config/kratos.yml.sample`.
## Subdomains

View File

@ -23,17 +23,27 @@ docker-compose --version # sanity check
# Create dummy .env file for docker-compose usage with variables
# * DOMAIN_NAME - the domain name your server is using ie. example.com
# * SKYNET_PORTAL_API - absolute url to the portal api ie. https://example.com
# * SKYNET_DASHBOARD_URL - (optional) absolute url to the portal dashboard ie. https://account.example.com
# * EMAIL_ADDRESS - this is the administrator contact email you need to supply for communication regarding SSL certification
# * HSD_API_KEY - this is auto generated secure key for your handshake service integration
# * CLOUDFLARE_AUTH_TOKEN` - (optional) if using cloudflare as dns loadbalancer (need to change it in Caddyfile too)
# * CLOUDFLARE_AUTH_TOKEN - (optional) if using cloudflare as dns loadbalancer (need to change it in Caddyfile too)
# * AWS_ACCESS_KEY_ID - (optional) if using route53 as a dns loadbalancer
# * AWS_SECRET_ACCESS_KEY - (optional) if using route53 as a dns loadbalancer
# * API_PORT - (optional) the port on which siad is listening, defaults to 9980
# * PORTAL_NAME - the name of the portal, required by the discord bot
# * DISCORD_BOT_TOKEN - required by the discord bot
# * DISCORD_BOT_TOKEN - (optional) only required if you're using the discord notifications integration
# * SKYNET_DB_USER - (optional) if using `accounts` this is the MongoDB username
# * SKYNET_DB_PASS - (optional) if using `accounts` this is the MongoDB password
# * SKYNET_DB_HOST - (optional) if using `accounts` this is the MongoDB address or container name
# * SKYNET_DB_PORT - (optional) if using `accounts` this is the MongoDB port
# * COOKIE_DOMAIN - (optional) if using `accounts` this is the domain to which your cookies will be issued
# * COOKIE_HASH_KEY - (optional) if using `accounts` hashing secret, at least 32 bytes
# * COOKIE_ENC_KEY - (optional) if using `accounts` encryption key, at least 32 bytes
# * CR_IP - (optional) if using `accounts` the public IP/domain of your server, e.g. `helsinki.siasky.net`
# * CR_CLUSTER_NODES - (optional) if using `accounts` the list of servers (with ports) which make up your CockroachDB cluster, e.g. `helsinki.siasky.net:26257,germany.siasky.net:26257,us-east.siasky.net:26257`
if ! [ -f /home/user/skynet-webportal/.env ]; then
HSD_API_KEY=$(openssl rand -base64 32) # generate safe random key for handshake
printf "DOMAIN_NAME=example.com\nSKYNET_PORTAL_API=https://example.com\nEMAIL_ADDRESS=email@example.com\nSIA_WALLET_PASSWORD=\nHSD_API_KEY=${HSD_API_KEY}\nCLOUDFLARE_AUTH_TOKEN=\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nPORTAL_NAME=\nDISCORD_BOT_TOKEN=\n" > /home/user/skynet-webportal/.env
printf "DOMAIN_NAME=example.com\nSKYNET_PORTAL_API=https://example.com\nSKYNET_DASHBOARD_URL=https://account.example.com\nEMAIL_ADDRESS=email@example.com\nSIA_WALLET_PASSWORD=\nHSD_API_KEY=${HSD_API_KEY}\nCLOUDFLARE_AUTH_TOKEN=\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nPORTAL_NAME=\nDISCORD_BOT_TOKEN=\n" > /home/user/skynet-webportal/.env
fi
# Start docker container with nginx and client

View File

@ -14,7 +14,7 @@ mkdir -p /home/user/.ssh
# Install apt packages
sudo apt-get update
sudo apt-get -y install ufw tmux ranger htop nload gcc g++ make git vim unzip curl
sudo apt-get -y install ufw tmux ranger htop nload gcc g++ make git vim unzip curl awscli
# Setup GIT credentials (so commands like git stash would work)
git config --global user.email "devs@nebulous.tech"

1516
yarn.lock

File diff suppressed because it is too large Load Diff