Compare commits

..

4 Commits

Author SHA1 Message Date
Michał Leszczyk 87f6352d06
tests wip 2022-04-07 11:35:12 +02:00
Michał Leszczyk 91be333fb9
ops(dashboard-v2): prepare Dockerfile 2022-04-07 11:35:12 +02:00
Michał Leszczyk 1a0fb9a806
Improve performance on mobile 2022-04-07 11:35:12 +02:00
Michał Leszczyk a3b21edf0c
Metadata improvements 2022-04-07 11:35:12 +02:00
552 changed files with 57687 additions and 212 deletions

1
.github/CODEOWNERS vendored
View File

@ -1 +0,0 @@
* @kwypchlo @meeh0w

View File

@ -1,6 +1,50 @@
version: 2
updates:
- package-ecosystem: npm
directory: "/packages/dashboard"
schedule:
interval: weekly
- package-ecosystem: npm
directory: "/packages/dnslink-api"
schedule:
interval: weekly
- package-ecosystem: npm
directory: "/packages/handshake-api"
schedule:
interval: weekly
- package-ecosystem: npm
directory: "/packages/health-check"
schedule:
interval: weekly
- package-ecosystem: npm
directory: "/packages/website"
schedule:
interval: weekly
- package-ecosystem: docker
directory: "/docker/nginx"
schedule:
interval: weekly
- package-ecosystem: docker
directory: "/docker/sia"
schedule:
interval: monthly
interval: weekly
- package-ecosystem: docker
directory: "/packages/dashboard"
schedule:
interval: weekly
- package-ecosystem: docker
directory: "/packages/dnslink-api"
schedule:
interval: weekly
- package-ecosystem: docker
directory: "/packages/handshake-api"
schedule:
interval: weekly
- package-ecosystem: docker
directory: "/packages/health-check"
schedule:
interval: weekly
- package-ecosystem: docker
directory: "/packages/website"
schedule:
interval: weekly

71
.github/workflows/codeql-analysis.yml vendored Normal file
View File

@ -0,0 +1,71 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ main ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
schedule:
- cron: '32 21 * * 0'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript', 'python' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

View File

@ -0,0 +1,31 @@
name: Build Storybook - packages/dashboard-v2
on:
push:
branches:
- master
paths:
- "packages/dashboard-v2/**"
pull_request:
paths:
- "packages/dashboard-v2/**"
defaults:
run:
working-directory: packages/dashboard-v2
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16.x
- run: yarn install
- run: yarn build-storybook
- name: "Deploy to Skynet"
uses: skynetlabs/deploy-to-skynet-action@v2
with:
upload-dir: packages/dashboard-v2/storybook-build
github-token: ${{ secrets.GITHUB_TOKEN }}

49
.github/workflows/deploy-website.yml vendored Normal file
View File

@ -0,0 +1,49 @@
name: Deploy website to Skynet
on:
push:
branches:
- master
paths:
- "packages/website/**"
pull_request:
paths:
- "packages/website/**"
defaults:
run:
working-directory: packages/website
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16.x
- run: yarn
- run: yarn build
- name: "Integration tests"
uses: cypress-io/github-action@v2
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
working-directory: packages/website
install: false
record: true
start: yarn develop
wait-on: http://localhost:8000
wait-on-timeout: 120
config: baseUrl=http://localhost:8000
- name: "Deploy to Skynet"
uses: skynetlabs/deploy-to-skynet-action@v2
with:
upload-dir: packages/website/public
github-token: ${{ secrets.GITHUB_TOKEN }}
registry-seed: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && secrets.WEBSITE_REGISTRY_SEED || '' }}

27
.github/workflows/lint-dockerfiles.yml vendored Normal file
View File

@ -0,0 +1,27 @@
name: Dockerfile Lint
on:
push:
branches:
- master
pull_request:
jobs:
hadolint:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
dockerfile:
- docker/nginx/Dockerfile
- docker/sia/Dockerfile
- packages/dashboard/Dockerfile
- packages/dnslink-api/Dockerfile
- packages/handshake-api/Dockerfile
- packages/health-check/Dockerfile
- packages/website/Dockerfile
steps:
- uses: actions/checkout@v3
- uses: hadolint/hadolint-action@v2.0.0
with:
dockerfile: ${{ matrix.dockerfile }}

View File

@ -0,0 +1,24 @@
name: Lint - packages/dashboard-v2
on:
pull_request:
paths:
- packages/dashboard-v2/**
defaults:
run:
working-directory: packages/dashboard-v2
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16.x
- run: yarn
- run: yarn prettier --check
- run: yarn lint

View File

@ -0,0 +1,24 @@
name: Lint - packages/dashboard
on:
pull_request:
paths:
- packages/dashboard/**
defaults:
run:
working-directory: packages/dashboard
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16.x
- run: yarn
- run: yarn prettier --check .
- run: yarn next lint

View File

@ -0,0 +1,23 @@
name: Lint - packages/dnslink-api
on:
pull_request:
paths:
- packages/dnslink-api/**
defaults:
run:
working-directory: packages/dnslink-api
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16.x
- run: yarn
- run: yarn prettier --check .

View File

@ -0,0 +1,23 @@
name: Lint - packages/handshake-api
on:
pull_request:
paths:
- packages/handshake-api/**
defaults:
run:
working-directory: packages/handshake-api
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16.x
- run: yarn
- run: yarn prettier --check .

View File

@ -0,0 +1,23 @@
name: Lint - packages/health-check
on:
pull_request:
paths:
- packages/health-check/**
defaults:
run:
working-directory: packages/health-check
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16.x
- run: yarn
- run: yarn prettier --check .

View File

@ -0,0 +1,23 @@
name: Lint - packages/website
on:
pull_request:
paths:
- packages/website/**
defaults:
run:
working-directory: packages/website
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16.x
- run: yarn
- run: yarn prettier --check .

View File

@ -0,0 +1,53 @@
# Install and run unit tests with busted
# Docs: http://olivinelabs.com/busted/
name: Nginx Lua Unit Tests
on:
push:
branches:
- "master"
paths:
- ".github/workflows/nginx-lua-unit-tests.yml"
- "docker/nginx/libs/**.lua"
pull_request:
paths:
- ".github/workflows/nginx-lua-unit-tests.yml"
- "docker/nginx/libs/**.lua"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: "3.x"
architecture: "x64"
- name: Install Dependencies
run: |
pip install hererocks
hererocks env --lua=5.1 -rlatest
source env/bin/activate
luarocks install busted
luarocks install luacov
luarocks install hasher
luarocks install luacheck
- name: Lint code
run: |
source env/bin/activate
luacheck docker/nginx/libs --std ngx_lua+busted
- name: Unit Tests
run: |
source env/bin/activate
busted --verbose --coverage --pattern=spec --directory=docker/nginx/libs .
cd docker/nginx/libs && luacov
- uses: codecov/codecov-action@v2
with:
directory: docker/nginx/libs
flags: nginx-lua

View File

@ -0,0 +1,23 @@
name: Test - packages/health-check
on:
pull_request:
paths:
- packages/health-check/**
defaults:
run:
working-directory: packages/health-check
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16.x
- run: yarn
- run: yarn jest

8
.gitignore vendored
View File

@ -67,6 +67,10 @@ yarn-error.log
# Yarn Integrity file
.yarn-integrity
# Cypress
packages/website/cypress/screenshots
packages/website/cypress/videos
# Docker data
docker/data
@ -82,10 +86,6 @@ __pycache__
/.idea/
/venv*
# Luacov file
luacov.stats.out
luacov.report.out
# Setup-script log files
setup-scripts/serverload.log
setup-scripts/serverload.json

View File

@ -3,10 +3,27 @@
## Latest Setup Documentation
Latest Skynet Webportal setup documentation and the setup process Skynet Labs
supports is located at https://portal-docs.skynetlabs.com/.
supports is located at https://docs.siasky.net/webportal-management/overview.
Some scripts and setup documentation contained in this repository
(`skynet-webportal`) may be outdated and generally should not be used.
Some of the scripts and setup documentation contained in this repository
(`skynet-webportal`) can be outdated and generally should not be used.
## Web application
Change current directory with `cd packages/website`.
Use `yarn start` to start the development server.
Use `yarn build` to compile the application to `/public` directory.
You can use the below build parameters to customize your web application.
- development example `GATSBY_API_URL=https://siasky.dev yarn start`
- production example `GATSBY_API_URL=https://siasky.net yarn build`
List of available parameters:
- `GATSBY_API_URL`: override api url (defaults to location origin)
## License
@ -16,3 +33,19 @@ and distribute the software, but you must preserve the payment mechanism in the
For the purposes of complying with our code license, you can use the following Siacoin address:
`fb6c9320bc7e01fbb9cd8d8c3caaa371386928793c736837832e634aaaa484650a3177d6714a`
## Running a Portal
For those interested in running a Webportal, head over to our developer docs [here](https://docs.siasky.net/webportal-management/overview.) to learn more.
## Contributing
### Testing Your Code
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:
1. In one terminal screen run `GATSBY_API_URL=https://siasky.net website serve`
1. In a second terminal screen run `yarn cypress run`

View File

@ -1,2 +0,0 @@
- Fix `dashboard-v2` Dockerfile context in `docker-compose.accounts.yml` to
avoid Ansible deploy (docker compose build) `permission denied` issues.

View File

@ -1 +0,0 @@
- Fix missing `logs` dir that is required for backup scripts (cron jobs).

View File

@ -1 +0,0 @@
- Add Pinner service to the portal stack. Activate it by selecting the 'p' module.

34
dc
View File

@ -5,59 +5,51 @@
# would use docker-compose with the only difference being that you don't need to specify compose files. For more
# information you can run `./dc` or `./dc help`.
# get current working directory of this script and prefix all files with it to
# be able to call this script from anywhere and not only root directory of
# skynet-webportal project
cwd="$(dirname -- "$0";)";
# get portal modules configuration from .env file (if defined more than once, the last one is used)
if [[ -f "${cwd}/.env" ]]; then
PORTAL_MODULES=$(grep -e "^PORTAL_MODULES=" ${cwd}/.env | tail -1 | sed "s/PORTAL_MODULES=//")
if [ -f .env ]; then
OLD_IFS=$IFS
IFS=$'\n'
for x in $(grep -v '^#.*' .env); do export $x; done
IFS=$OLD_IFS
fi
# include base docker compose file
COMPOSE_FILES="-f ${cwd}/docker-compose.yml"
COMPOSE_FILES="-f docker-compose.yml"
for i in $(seq 1 ${#PORTAL_MODULES}); do
# accounts module - alias "a"
if [[ ${PORTAL_MODULES:i-1:1} == "a" ]]; then
COMPOSE_FILES+=" -f ${cwd}/docker-compose.mongodb.yml -f ${cwd}/docker-compose.accounts.yml"
COMPOSE_FILES+=" -f docker-compose.mongodb.yml -f docker-compose.accounts.yml"
fi
# blocker module - alias "b"
if [[ ${PORTAL_MODULES:i-1:1} == "b" ]]; then
COMPOSE_FILES+=" -f ${cwd}/docker-compose.mongodb.yml -f ${cwd}/docker-compose.blocker.yml"
COMPOSE_FILES+=" -f docker-compose.mongodb.yml -f docker-compose.blocker.yml"
fi
# jaeger module - alias "j"
if [[ ${PORTAL_MODULES:i-1:1} == "j" ]]; then
COMPOSE_FILES+=" -f ${cwd}/docker-compose.jaeger.yml"
COMPOSE_FILES+=" -f docker-compose.jaeger.yml"
fi
# malware-scanner module - alias "s"
if [[ ${PORTAL_MODULES:i-1:1} == "s" ]]; then
COMPOSE_FILES+=" -f ${cwd}/docker-compose.blocker.yml -f ${cwd}/docker-compose.mongodb.yml -f ${cwd}/docker-compose.malware-scanner.yml"
COMPOSE_FILES+=" -f docker-compose.blocker.yml -f docker-compose.mongodb.yml -f docker-compose.malware-scanner.yml"
fi
# mongodb module - alias "m"
if [[ ${PORTAL_MODULES:i-1:1} == "m" ]]; then
COMPOSE_FILES+=" -f ${cwd}/docker-compose.mongodb.yml"
COMPOSE_FILES+=" -f docker-compose.mongodb.yml"
fi
# abuse-scanner module - alias "u"
if [[ ${PORTAL_MODULES:i-1:1} == "u" ]]; then
COMPOSE_FILES+=" -f ${cwd}/docker-compose.mongodb.yml -f ${cwd}/docker-compose.blocker.yml -f ${cwd}/docker-compose.abuse-scanner.yml"
fi
# pinner module - alias "p"
if [[ ${PORTAL_MODULES:i-1:1} == "p" ]]; then
COMPOSE_FILES+=" -f ${cwd}/docker-compose.mongodb.yml -f ${cwd}/docker-compose.pinner.yml"
COMPOSE_FILES+=" -f docker-compose.mongodb.yml -f docker-compose.blocker.yml -f docker-compose.abuse-scanner.yml"
fi
done
# override file if exists
if [[ -f docker-compose.override.yml ]]; then
COMPOSE_FILES+=" -f ${cwd}/docker-compose.override.yml"
COMPOSE_FILES+=" -f docker-compose.override.yml"
fi
docker-compose $COMPOSE_FILES $@

View File

@ -1,4 +1,4 @@
version: "3.8"
version: "3.7"
x-logging: &default-logging
driver: json-file
@ -10,7 +10,7 @@ services:
abuse-scanner:
# uncomment "build" and comment out "image" to build from sources
# build: https://github.com/SkynetLabs/abuse-scanner.git#main
image: skynetlabs/abuse-scanner:0.4.0
image: skynetlabs/abuse-scanner
container_name: abuse-scanner
restart: unless-stopped
logging: *default-logging
@ -36,6 +36,3 @@ services:
depends_on:
- mongo
- blocker
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /tmp:/tmp

View File

@ -1,4 +1,4 @@
version: "3.8"
version: "3.7"
x-logging: &default-logging
driver: json-file
@ -21,8 +21,8 @@ services:
accounts:
# uncomment "build" and comment out "image" to build from sources
# build: https://github.com/SkynetLabs/skynet-accounts.git#main
image: skynetlabs/skynet-accounts:1.3.0
build: https://github.com/SkynetLabs/skynet-accounts.git#main
# image: skynetlabs/skynet-accounts
container_name: accounts
restart: unless-stopped
logging: *default-logging
@ -55,22 +55,44 @@ services:
- mongo
dashboard:
# uncomment "build" and comment out "image" to build from sources
# build:
# context: https://github.com/SkynetLabs/webportal-accounts-dashboard.git#main
# dockerfile: Dockerfile
image: skynetlabs/webportal-accounts-dashboard:2.1.1
build:
context: ./packages/dashboard
dockerfile: Dockerfile
container_name: dashboard
restart: unless-stopped
logging: *default-logging
env_file:
- .env
environment:
- NEXT_PUBLIC_PORTAL_DOMAIN=${PORTAL_DOMAIN}
- NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY}
volumes:
- ./docker/data/dashboard/.cache:/usr/app/.cache
- ./docker/data/dashboard/public:/usr/app/public
- ./docker/data/dashboard/.next:/usr/app/.next
networks:
shared:
ipv4_address: 10.10.10.85
expose:
- 3000
depends_on:
- mongo
dashboard-v2:
build:
context: ./packages/dashboard-v2
dockerfile: Dockerfile
container_name: dashboard-v2
restart: unless-stopped
logging: *default-logging
env_file:
- .env
environment:
- GATSBY_PORTAL_DOMAIN=${PORTAL_DOMAIN}
volumes:
- ./docker/data/dashboard-v2/.cache:/usr/app/.cache
- ./docker/data/dashboard-v2/public:/usr/app/public
networks:
shared:
ipv4_address: 10.10.10.90
expose:
- 9000
depends_on:

View File

@ -1,4 +1,4 @@
version: "3.8"
version: "3.7"
x-logging: &default-logging
driver: json-file
@ -15,7 +15,7 @@ services:
blocker:
# uncomment "build" and comment out "image" to build from sources
# build: https://github.com/SkynetLabs/blocker.git#main
image: skynetlabs/blocker:0.1.2
image: skynetlabs/blocker
container_name: blocker
restart: unless-stopped
logging: *default-logging

View File

@ -1,4 +1,4 @@
version: "3.8"
version: "3.7"
x-logging: &default-logging
driver: json-file
@ -21,7 +21,7 @@ services:
- JAEGER_REPORTER_LOG_SPANS=false
jaeger-agent:
image: jaegertracing/jaeger-agent:1.38.1
image: jaegertracing/jaeger-agent:1.32.0
command:
[
"--reporter.grpc.host-port=jaeger-collector:14250",
@ -43,7 +43,7 @@ services:
- jaeger-collector
jaeger-collector:
image: jaegertracing/jaeger-collector:1.38.1
image: jaegertracing/jaeger-collector:1.32.0
entrypoint: /wait_to_start.sh
container_name: jaeger-collector
restart: on-failure
@ -68,7 +68,7 @@ services:
- elasticsearch
jaeger-query:
image: jaegertracing/jaeger-query:1.38.1
image: jaegertracing/jaeger-query:1.32.0
entrypoint: /wait_to_start.sh
container_name: jaeger-query
restart: on-failure
@ -93,7 +93,7 @@ services:
- elasticsearch
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.6
image: docker.elastic.co/elasticsearch/elasticsearch:7.13.2
container_name: elasticsearch
restart: on-failure
logging: *default-logging

View File

@ -1,4 +1,4 @@
version: "3.8"
version: "3.7"
x-logging: &default-logging
driver: json-file
@ -17,6 +17,10 @@ services:
- ./docker/clamav/clamd.conf:/etc/clamav/clamd.conf:ro
expose:
- 3310 # NEVER expose this outside of the local network!
deploy:
resources:
limits:
cpus: "${CLAMAV_CPU:-0.50}"
networks:
shared:
ipv4_address: 10.10.10.100
@ -24,7 +28,7 @@ services:
malware-scanner:
# uncomment "build" and comment out "image" to build from sources
# build: https://github.com/SkynetLabs/malware-scanner.git#main
image: skynetlabs/malware-scanner:0.1.0
image: skynetlabs/malware-scanner
container_name: malware-scanner
restart: unless-stopped
logging: *default-logging

View File

@ -1,4 +1,4 @@
version: "3.8"
version: "3.7"
x-logging: &default-logging
driver: json-file
@ -14,8 +14,8 @@ services:
- MONGODB_PASSWORD=${SKYNET_DB_PASS}
mongo:
image: mongo:4.4.17
command: --keyFile=/data/mgkey --replSet=${SKYNET_DB_REPLICASET:-skynet} --setParameter ShardingTaskExecutorPoolMinSize=10
image: mongo:4.4.1
command: --keyFile=/data/mgkey --replSet=${SKYNET_DB_REPLICASET:-skynet}
container_name: mongo
restart: unless-stopped
logging: *default-logging

View File

@ -1,30 +0,0 @@
version: "3.8"
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
pinner:
# uncomment "build" and comment out "image" to build from sources
# build: https://github.com/SkynetLabs/pinner.git#main
image: skynetlabs/pinner:0.7.8
container_name: pinner
restart: unless-stopped
logging: *default-logging
env_file:
- .env
volumes:
- ./docker/data/pinner/logs:/logs
environment:
- PINNER_LOG_LEVEL=${PINNER_LOG_LEVEL:-info}
expose:
- 4000
networks:
shared:
ipv4_address: 10.10.10.130
depends_on:
- mongo
- sia

View File

@ -1,4 +1,4 @@
version: "3.8"
version: "3.7"
x-logging: &default-logging
driver: json-file
@ -15,19 +15,16 @@ networks:
services:
sia:
# uncomment "build" and comment out "image" to build from sources
# build:
# context: https://github.com/SkynetLabs/docker-skyd.git#main
# dockerfile: scratch/Dockerfile
# args:
# branch: master
image: skynetlabs/skyd:1.6.9
command: --disable-api-security --api-addr :9980 --modules gctwra
build:
context: ./docker/sia
dockerfile: Dockerfile
args:
branch: portal-latest
container_name: sia
restart: unless-stopped
stop_grace_period: 5m
logging: *default-logging
environment:
- SIA_MODULES=gctwra
- SKYD_DISK_CACHE_ENABLED=${SKYD_DISK_CACHE_ENABLED:-true}
- SKYD_DISK_CACHE_SIZE=${SKYD_DISK_CACHE_SIZE:-53690000000} # 50GB
- SKYD_DISK_CACHE_MIN_HITS=${SKYD_DISK_CACHE_MIN_HITS:-3}
@ -43,24 +40,7 @@ services:
- 9980
certbot:
# replace this image with the image supporting your dns provider from
# https://hub.docker.com/r/certbot/certbot and adjust CERTBOT_ARGS env variable
# note: you will need to authenticate your dns request so consult the plugin docs
# configuration https://eff-certbot.readthedocs.io/en/stable/using.html#dns-plugins
#
# =================================================================================
# example docker-compose.yml changes required for Cloudflare dns provider:
#
# image: certbot/dns-cloudflare
# environment:
# - CERTBOT_ARGS=--dns-cloudflare --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini
#
# create ./docker/data/certbot/cloudflare.ini file with the following content:
# dns_cloudflare_api_token = <api key generated at https://dash.cloudflare.com/profile/api-tokens>
#
# make sure that the file has 0400 permissions with:
# chmod 0400 ./docker/data/certbot/cloudflare.ini
image: certbot/dns-route53:v1.31.0
image: certbot/dns-route53:v1.25.0
entrypoint: sh /entrypoint.sh
container_name: certbot
restart: unless-stopped
@ -74,11 +54,9 @@ services:
- ./docker/data/certbot:/etc/letsencrypt
nginx:
# uncomment "build" and comment out "image" to build from sources
# build:
# context: https://github.com/SkynetLabs/webportal-nginx.git#main
# dockerfile: Dockerfile
image: skynetlabs/webportal-nginx:1.0.0
build:
context: ./docker/nginx
dockerfile: Dockerfile
container_name: nginx
restart: unless-stopped
logging: *default-logging
@ -104,17 +82,12 @@ services:
- website
website:
# uncomment "build" and comment out "image" to build from sources
# build:
# context: https://github.com/SkynetLabs/webportal-website.git#main
# dockerfile: Dockerfile
image: skynetlabs/webportal-website:0.2.3
build:
context: ./packages/website
dockerfile: Dockerfile
container_name: website
restart: unless-stopped
logging: *default-logging
volumes:
- ./docker/data/website/.cache:/usr/app/.cache
- ./docker/data/website/.public:/usr/app/public
env_file:
- .env
networks:
@ -124,11 +97,18 @@ services:
- 9000
handshake:
image: handshakeorg/hsd:4.0.2
command: --chain-migrate=3 --no-wallet --no-auth --compact-tree-on-init --network=main --http-host=0.0.0.0
image: skynetlabs/hsd:3.0.1
command: --chain-migrate=2 --wallet-migrate=1
container_name: handshake
restart: unless-stopped
logging: *default-logging
environment:
- HSD_LOG_CONSOLE=false
- HSD_HTTP_HOST=0.0.0.0
- HSD_NETWORK=main
- HSD_PORT=12037
env_file:
- .env
volumes:
- ./docker/data/handshake/.hsd:/root/.hsd
networks:
@ -138,11 +118,9 @@ services:
- 12037
handshake-api:
# uncomment "build" and comment out "image" to build from sources
# build:
# context: https://github.com/SkynetLabs/webportal-handshake-api.git#main
# dockerfile: Dockerfile
image: skynetlabs/webportal-handshake-api:0.1.3
build:
context: ./packages/handshake-api
dockerfile: Dockerfile
container_name: handshake-api
restart: unless-stopped
logging: *default-logging
@ -162,11 +140,9 @@ services:
- handshake
dnslink-api:
# uncomment "build" and comment out "image" to build from sources
# build:
# context: https://github.com/SkynetLabs/webportal-dnslink-api.git#main
# dockerfile: Dockerfile
image: skynetlabs/webportal-dnslink-api:0.2.1
build:
context: ./packages/dnslink-api
dockerfile: Dockerfile
container_name: dnslink-api
restart: unless-stopped
logging: *default-logging
@ -177,11 +153,9 @@ services:
- 3100
health-check:
# uncomment "build" and comment out "image" to build from sources
# build:
# context: https://github.com/SkynetLabs/webportal-health-check.git#main
# dockerfile: Dockerfile
image: skynetlabs/webportal-health-check:1.0.0
build:
context: ./packages/health-check
dockerfile: Dockerfile
container_name: health-check
restart: unless-stopped
logging: *default-logging

26
docker/nginx/Dockerfile Normal file
View File

@ -0,0 +1,26 @@
FROM openresty/openresty:1.19.9.1-focal
WORKDIR /
RUN luarocks install lua-resty-http && \
luarocks install hasher && \
openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 \
-subj '/CN=local-certificate' \
-keyout /etc/ssl/local-certificate.key \
-out /etc/ssl/local-certificate.crt
COPY mo ./
COPY libs /etc/nginx/libs
COPY conf.d /etc/nginx/conf.d
COPY conf.d.templates /etc/nginx/conf.d.templates
COPY nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
CMD [ "bash", "-c", \
"./mo < /etc/nginx/conf.d.templates/server.account.conf > /etc/nginx/conf.d/server.account.conf ; \
./mo < /etc/nginx/conf.d.templates/server.api.conf > /etc/nginx/conf.d/server.api.conf; \
./mo < /etc/nginx/conf.d.templates/server.dnslink.conf > /etc/nginx/conf.d/server.dnslink.conf; \
./mo < /etc/nginx/conf.d.templates/server.hns.conf > /etc/nginx/conf.d/server.hns.conf; \
./mo < /etc/nginx/conf.d.templates/server.skylink.conf > /etc/nginx/conf.d/server.skylink.conf ; \
while :; do sleep 6h & wait ${!}; /usr/local/openresty/bin/openresty -s reload; done & \
/usr/local/openresty/bin/openresty '-g daemon off;'" \
]

View File

@ -0,0 +1,45 @@
{{#ACCOUNTS_ENABLED}}
{{#PORTAL_DOMAIN}}
server {
server_name account.{{PORTAL_DOMAIN}}; # example: account.siasky.net
include /etc/nginx/conf.d/server/server.http;
}
server {
server_name account.{{PORTAL_DOMAIN}}; # example: account.siasky.net
set_by_lua_block $skynet_portal_domain { return "{{PORTAL_DOMAIN}}" }
set_by_lua_block $skynet_server_domain {
-- fall back to portal domain if server domain is not defined
if "{{SERVER_DOMAIN}}" == "" then
return "{{PORTAL_DOMAIN}}"
end
return "{{SERVER_DOMAIN}}"
}
include /etc/nginx/conf.d/server/server.account;
}
{{/PORTAL_DOMAIN}}
{{#SERVER_DOMAIN}}
server {
server_name account.{{SERVER_DOMAIN}}; # example: account.eu-ger-1.siasky.net
include /etc/nginx/conf.d/server/server.http;
set_by_lua_block $server_alias { return string.match("{{SERVER_DOMAIN}}", "^([^.]+)") }
}
server {
server_name account.{{SERVER_DOMAIN}}; # example: account.eu-ger-1.siasky.net
set_by_lua_block $skynet_portal_domain { return "{{SERVER_DOMAIN}}" }
set_by_lua_block $skynet_server_domain { return "{{SERVER_DOMAIN}}" }
include /etc/nginx/conf.d/server/server.account;
set_by_lua_block $server_alias { return string.match("{{SERVER_DOMAIN}}", "^([^.]+)") }
}
{{/SERVER_DOMAIN}}
{{/ACCOUNTS_ENABLED}}

View File

@ -0,0 +1,43 @@
{{#PORTAL_DOMAIN}}
server {
server_name {{PORTAL_DOMAIN}}; # example: siasky.net
include /etc/nginx/conf.d/server/server.http;
}
server {
server_name {{PORTAL_DOMAIN}}; # example: siasky.net
set_by_lua_block $skynet_portal_domain { return "{{PORTAL_DOMAIN}}" }
set_by_lua_block $skynet_server_domain {
-- fall back to portal domain if server domain is not defined
if "{{SERVER_DOMAIN}}" == "" then
return "{{PORTAL_DOMAIN}}"
end
return "{{SERVER_DOMAIN}}"
}
include /etc/nginx/conf.d/server/server.api;
}
{{/PORTAL_DOMAIN}}
{{#SERVER_DOMAIN}}
server {
server_name {{SERVER_DOMAIN}}; # example: eu-ger-1.siasky.net
include /etc/nginx/conf.d/server/server.http;
set_by_lua_block $server_alias { return string.match("{{SERVER_DOMAIN}}", "^([^.]+)") }
}
server {
server_name {{SERVER_DOMAIN}}; # example: eu-ger-1.siasky.net
set_by_lua_block $skynet_portal_domain { return "{{SERVER_DOMAIN}}" }
set_by_lua_block $skynet_server_domain { return "{{SERVER_DOMAIN}}" }
include /etc/nginx/conf.d/server/server.api;
set_by_lua_block $server_alias { return string.match("{{SERVER_DOMAIN}}", "^([^.]+)") }
}
{{/SERVER_DOMAIN}}

View File

@ -0,0 +1,25 @@
lua_shared_dict dnslink 10m;
server {
listen 80 default_server;
include /etc/nginx/conf.d/server/server.dnslink;
}
server {
listen 443 default_server;
ssl_certificate /etc/ssl/local-certificate.crt;
ssl_certificate_key /etc/ssl/local-certificate.key;
set_by_lua_block $skynet_portal_domain { return "{{PORTAL_DOMAIN}}" }
set_by_lua_block $skynet_server_domain {
-- fall back to portal domain if server domain is not defined
if "{{SERVER_DOMAIN}}" == "" then
return "{{PORTAL_DOMAIN}}"
end
return "{{SERVER_DOMAIN}}"
}
include /etc/nginx/conf.d/server/server.dnslink;
}

View File

@ -0,0 +1,45 @@
{{#PORTAL_DOMAIN}}
server {
server_name *.hns.{{PORTAL_DOMAIN}}; # example: *.hns.siasky.net
include /etc/nginx/conf.d/server/server.http;
}
server {
server_name *.hns.{{PORTAL_DOMAIN}}; # example: *.hns.siasky.net
set_by_lua_block $skynet_portal_domain { return "{{PORTAL_DOMAIN}}" }
set_by_lua_block $skynet_server_domain {
-- fall back to portal domain if server domain is not defined
if "{{SERVER_DOMAIN}}" == "" then
return "{{PORTAL_DOMAIN}}"
end
return "{{SERVER_DOMAIN}}"
}
proxy_set_header Host {{PORTAL_DOMAIN}};
include /etc/nginx/conf.d/server/server.hns;
}
{{/PORTAL_DOMAIN}}
{{#SERVER_DOMAIN}}
server {
server_name *.hns.{{SERVER_DOMAIN}}; # example: *.hns.eu-ger-1.siasky.net
include /etc/nginx/conf.d/server/server.http;
set_by_lua_block $server_alias { return string.match("{{SERVER_DOMAIN}}", "^([^.]+)") }
}
server {
server_name *.hns.{{SERVER_DOMAIN}}; # example: *.hns.eu-ger-1.siasky.net
set_by_lua_block $skynet_portal_domain { return "{{SERVER_DOMAIN}}" }
set_by_lua_block $skynet_server_domain { return "{{SERVER_DOMAIN}}" }
proxy_set_header Host {{SERVER_DOMAIN}};
include /etc/nginx/conf.d/server/server.hns;
set_by_lua_block $server_alias { return string.match("{{SERVER_DOMAIN}}", "^([^.]+)") }
}
{{/SERVER_DOMAIN}}

View File

@ -0,0 +1,43 @@
{{#PORTAL_DOMAIN}}
server {
server_name *.{{PORTAL_DOMAIN}}; # example: *.siasky.net
include /etc/nginx/conf.d/server/server.http;
}
server {
server_name *.{{PORTAL_DOMAIN}}; # example: *.siasky.net
set_by_lua_block $skynet_portal_domain { return "{{PORTAL_DOMAIN}}" }
set_by_lua_block $skynet_server_domain {
-- fall back to portal domain if server domain is not defined
if "{{SERVER_DOMAIN}}" == "" then
return "{{PORTAL_DOMAIN}}"
end
return "{{SERVER_DOMAIN}}"
}
include /etc/nginx/conf.d/server/server.skylink;
}
{{/PORTAL_DOMAIN}}
{{#SERVER_DOMAIN}}
server {
server_name *.{{SERVER_DOMAIN}}; # example: *.eu-ger-1.siasky.net
include /etc/nginx/conf.d/server/server.http;
set_by_lua_block $server_alias { return string.match("{{SERVER_DOMAIN}}", "^([^.]+)") }
}
server {
server_name *.{{SERVER_DOMAIN}}; # example: *.eu-ger-1.siasky.net
set_by_lua_block $skynet_portal_domain { return "{{SERVER_DOMAIN}}" }
set_by_lua_block $skynet_server_domain { return "{{SERVER_DOMAIN}}" }
include /etc/nginx/conf.d/server/server.skylink;
set_by_lua_block $server_alias { return string.match("{{SERVER_DOMAIN}}", "^([^.]+)") }
}
{{/SERVER_DOMAIN}}

View File

@ -0,0 +1,8 @@
-----BEGIN DH PARAMETERS-----
MIIBCAKCAQEA//////////+t+FRYortKmq/cViAnPTzx2LnFg84tNpWp4TZBFGQz
+8yTnc4kmz75fS/jY2MMddj2gbICrsRhetPfHtXV/WVhJDP1H18GbtCFY2VVPe0a
87VXE15/V8k1mE8McODmi3fipona8+/och3xWKE2rec1MKzKT0g6eXq8CrGCsyT7
YdEIqUuyyOP7uWrat2DX9GgdT0Kj3jlN9K5W7edjcrsZCwenyO4KbXCeAvzhzffi
7MA0BM0oNC9hkXL+nOmFg/+OTxIy7vKBg8P+OxtMb61zO7X8vC7CIAXFjvGDfRaD
ssbzSibBsu/6iGtCOGEoXJf//////////wIBAg==
-----END DH PARAMETERS-----

View File

@ -0,0 +1,18 @@
# enables gzip compression
gzip on;
# set the gzip compression level (1-9)
gzip_comp_level 6;
# tells proxies to cache both gzipped and regular versions of a resource
gzip_vary on;
# informs NGINX to not compress anything smaller than the defined size
gzip_min_length 256;
# compress data even for clients that are connecting via proxies if a response header includes
# the "expired", "no-cache", "no-store", "private", and "Authorization" parameters
gzip_proxied expired no-cache no-store private auth;
# enables the types of files that can be compressed
gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype image/svg+xml image/x-icon;

View File

@ -0,0 +1,9 @@
if ($request_method = 'OPTIONS') {
include /etc/nginx/conf.d/include/cors-headers;
more_set_headers 'Access-Control-Max-Age: 1728000';
more_set_headers 'Content-Type: text/plain; charset=utf-8';
more_set_headers 'Content-Length: 0';
return 204;
}
include /etc/nginx/conf.d/include/cors-headers;

View File

@ -0,0 +1,5 @@
more_set_headers 'Access-Control-Allow-Origin: $http_origin';
more_set_headers 'Access-Control-Allow-Credentials: true';
more_set_headers 'Access-Control-Allow-Methods: GET, POST, HEAD, OPTIONS, PUT, PATCH, DELETE';
more_set_headers 'Access-Control-Allow-Headers: DNT,User-Agent,X-Requested-With,If-Modified-Since,If-None-Match,Cache-Control,Content-Type,Range,X-HTTP-Method-Override,upload-offset,upload-metadata,upload-length,tus-version,tus-resumable,tus-extension,tus-max-size,upload-concat,location,Skynet-API-Key';
more_set_headers 'Access-Control-Expose-Headers: Content-Length,Content-Range,ETag,Accept-Ranges,Skynet-File-Metadata,Skynet-Skylink,Skynet-Proof,Skynet-Portal-Api,Skynet-Server-Api,upload-offset,upload-metadata,upload-length,tus-version,tus-resumable,tus-extension,tus-max-size,upload-concat,location';

View File

@ -0,0 +1,11 @@
# Extract 2 sets of 2 characters from $request_id and assign to $dir1, $dir2
# respectfully. The rest of the $request_id is going to be assigned to $dir3.
# We use those variables to automatically generate a unique path for the uploaded file.
# This ensures that not all uploaded files end up in the same directory, which is something
# that causes performance issues in the renter.
# Example path result: /af/24/9bc5ec894920ccc45634dc9a8065
if ($request_id ~* "(\w{2})(\w{2})(\w+)") {
set $dir1 $1;
set $dir2 $2;
set $dir3 $3;
}

View File

@ -0,0 +1,15 @@
# optional variables initialisation - those variables are used in log_format
# but are not set on every route so we need to initialise them with empty value
# because otherwise logger with throw error
# set only on hns routes
set $hns_domain "";
# set only if server has been access through SERVER_DOMAIN
set $server_alias "";
# expose skylink variable so we can use it in access log
set $skylink "";
# cached account limits (json string) - applies only if accounts are enabled
set $account_limits "";

View File

@ -0,0 +1,3 @@
allow 127.0.0.1/32; # localhost
allow 10.10.10.0/24; # docker network
deny all;

View File

@ -0,0 +1,94 @@
include /etc/nginx/conf.d/include/proxy-pass-internal;
include /etc/nginx/conf.d/include/portal-access-check;
# 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
# resolve handshake domain by requesting to /hnsres endpoint and assign correct values to $skylink and $rest
rewrite_by_lua_block {
local json = require('cjson')
local httpc = require("resty.http").new()
-- make a get request to /hnsres endpoint with the domain name from request_uri
-- 10.10.10.50 points to handshake-api service (alias not available when using resty-http)
local hnsres_res, hnsres_err = httpc:request_uri("http://10.10.10.50:3100/hnsres/" .. ngx.var.hns_domain)
-- print error and exit with 500 or exit with response if status is not 200
if hnsres_err or (hnsres_res and hnsres_res.status ~= ngx.HTTP_OK) then
ngx.status = (hnsres_err and ngx.HTTP_INTERNAL_SERVER_ERROR) or hnsres_res.status
ngx.header["content-type"] = "text/plain"
ngx.say(hnsres_err or hnsres_res.body)
return ngx.exit(ngx.status)
end
-- since /hnsres endpoint response is a json, we need to decode it before we access it
-- example response: '{"skylink":"sia://XABvi7JtJbQSMAcDwnUnmp2FKDPjg8_tTTFP4BwMSxVdEg"}'
local hnsres_json = json.decode(hnsres_res.body)
-- define local variable containing rest of the skylink if provided
local skylink_rest
if hnsres_json.skylink then
-- try to match the skylink with sia:// prefix
skylink, skylink_rest = string.match(hnsres_json.skylink, "sia://([^/?]+)(.*)")
-- in case the skylink did not match, assume that there is no sia:// prefix and try to match again
if skylink == nil then
skylink, skylink_rest = string.match(hnsres_json.skylink, "/?([^/?]+)(.*)")
end
elseif hnsres_json.registry then
local publickey = hnsres_json.registry.publickey
local datakey = hnsres_json.registry.datakey
-- make a get request to /skynet/registry endpoint with the credentials from text record
-- 10.10.10.10 points to sia service (alias not available when using resty-http)
local registry_res, registry_err = httpc:request_uri("http://10.10.10.10:9980/skynet/registry?publickey=" .. publickey .. "&datakey=" .. datakey, {
headers = { ["User-Agent"] = "Sia-Agent" }
})
-- print error and exit with 500 or exit with response if status is not 200
if registry_err or (registry_res and registry_res.status ~= ngx.HTTP_OK) then
ngx.status = (registry_err and ngx.HTTP_INTERNAL_SERVER_ERROR) or registry_res.status
ngx.header["content-type"] = "text/plain"
ngx.say(registry_err or registry_res.body)
return ngx.exit(ngx.status)
end
-- since /skynet/registry endpoint response is a json, we need to decode it before we access it
local registry_json = json.decode(registry_res.body)
-- response will contain a hex encoded skylink, we need to decode it
local data = (registry_json.data:gsub('..', function (cc)
return string.char(tonumber(cc, 16))
end))
skylink = data
end
-- fail with a generic 404 if skylink has not been extracted from a valid /hnsres response for some reason
if not skylink then
return ngx.exit(ngx.HTTP_NOT_FOUND)
end
ngx.var.skylink = skylink
if ngx.var.path == "/" and skylink_rest ~= nil and skylink_rest ~= "" and skylink_rest ~= "/" then
ngx.var.path = skylink_rest
end
}
# we proxy to another nginx location rather than directly to siad because we do not want to deal with caching here
proxy_pass https://127.0.0.1/$skylink$path$is_args$args;
# in case siad returns location header, we need to replace the skylink with the domain name
header_filter_by_lua_block {
ngx.header["Skynet-Portal-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_portal_domain
ngx.header["Skynet-Server-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_server_domain
if ngx.header.location then
-- match location redirect part after the skylink
local path = string.match(ngx.header.location, "[^/?]+(.*)");
-- because siad will set the location header to ie. XABvi7JtJbQSMAcDwnUnmp2FKDPjg8_tTTFP4BwMSxVdEg/index.html
-- we need to replace the skylink with the domain_name so we are not redirected to skylink
ngx.header.location = ngx.var.hns_domain .. path
end
}

View File

@ -0,0 +1,39 @@
include /etc/nginx/conf.d/include/cors;
include /etc/nginx/conf.d/include/track-download;
limit_conn downloads_by_ip 100; # ddos protection: max 100 downloads at a time
# ensure that skylink that we pass around is base64 encoded (transform base32 encoded ones)
# this is important because we want only one format in cache keys and logs
set_by_lua_block $skylink { return require("skynet.skylink").parse(ngx.var.skylink) }
# default download rate to unlimited
set $limit_rate 0;
access_by_lua_block {
if require("skynet.account").accounts_enabled() then
-- check if portal is in authenticated only mode
if require("skynet.account").is_access_unauthorized() then
return require("skynet.account").exit_access_unauthorized()
end
-- check if portal is in subscription only mode
if require("skynet.account").is_access_forbidden() then
return require("skynet.account").exit_access_forbidden()
end
-- get account limits of currently authenticated user
local limits = require("skynet.account").get_account_limits()
-- apply download speed limit
ngx.var.limit_rate = limits.download
end
}
limit_rate_after 512k;
limit_rate $limit_rate;
proxy_read_timeout 600;
proxy_set_header User-Agent: Sia-Agent;
proxy_pass http://sia:9980/skynet/skylink/$skylink$path$is_args$args;

View File

@ -0,0 +1,32 @@
include /etc/nginx/conf.d/include/cors;
include /etc/nginx/conf.d/include/sia-auth;
include /etc/nginx/conf.d/include/track-registry;
limit_req zone=registry_access_by_ip burst=600 nodelay;
limit_req zone=registry_access_by_ip_throttled burst=200 nodelay;
proxy_set_header User-Agent: Sia-Agent;
proxy_read_timeout 600; # siad should timeout with 404 after 5 minutes
proxy_pass http://sia:9980/skynet/registry;
access_by_lua_block {
if require("skynet.account").accounts_enabled() then
-- check if portal is in authenticated only mode
if require("skynet.account").is_access_unauthorized() then
return require("skynet.account").exit_access_unauthorized()
end
-- check if portal is in subscription only mode
if require("skynet.account").is_access_forbidden() then
return require("skynet.account").exit_access_forbidden()
end
-- get account limits of currently authenticated user
local limits = require("skynet.account").get_account_limits()
-- apply registry rate limits (forced delay)
if limits.registry > 0 then
ngx.sleep(limits.registry / 1000)
end
end
}

View File

@ -0,0 +1,11 @@
access_by_lua_block {
-- check portal access rules and exit if access is restricted
if require("skynet.account").is_access_unauthorized() then
return require("skynet.account").exit_access_unauthorized()
end
-- check if portal is in subscription only mode
if require("skynet.account").is_access_forbidden() then
return require("skynet.account").exit_access_forbidden()
end
}

View File

@ -0,0 +1,10 @@
# ----------------------------------------------------------------
# this file should be included on all locations that proxy_pass to
# another nginx location - internal nginx traffic
# ----------------------------------------------------------------
# increase the timeout on internal nginx proxy_pass locations to a
# value that is significantly higher than expected and let the end
# location handle correct timeout
proxy_read_timeout 30m;
proxy_send_timeout 30m;

View File

@ -0,0 +1,6 @@
# Add a list of IPs here that should be severely rate limited on upload.
# Note that it is possible to add IP ranges as well as the full IP address.
#
# Examples:
# 192.168.0.0/24 1;
# 79.85.222.247 1;

View File

@ -0,0 +1,4 @@
rewrite_by_lua_block {
-- set basic authorization header with base64 encoded apipassword
ngx.req.set_header("Authorization", require("skynet.utils").authorization_header())
}

View File

@ -0,0 +1,26 @@
# https://ssl-config.mozilla.org/#server=nginx&version=1.17.7&config=intermediate&openssl=1.1.1d&hsts=false&ocsp=false&guideline=5.6
ssl_certificate /etc/letsencrypt/live/skynet-portal/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/skynet-portal/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m; # about 40000 sessions
ssl_session_tickets off;
# curl https://ssl-config.mozilla.org/ffdhe2048.txt > /path/to/dhparam
ssl_dhparam /etc/nginx/conf.d/dhparam.pem;
# intermediate configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# HSTS (ngx_http_headers_module is required) (63072000 seconds)
add_header Strict-Transport-Security "max-age=63072000" always;
# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;
# verify chain of trust of OCSP response using Root CA and Intermediate certs
ssl_trusted_certificate /etc/letsencrypt/live/skynet-portal/chain.pem;

View File

@ -0,0 +1,55 @@
log_by_lua_block {
local skynet_account = require("skynet.account")
-- tracking runs only when request comes from authenticated user
if skynet_account.is_authenticated() then
local function track(premature, skylink, status, body_bytes_sent, auth_headers)
if premature then return end
local httpc = require("resty.http").new()
local query = table.concat({ "status=" .. status, "bytes=" .. body_bytes_sent }, "&")
-- 10.10.10.70 points to accounts service (alias not available when using resty-http)
local res, err = httpc:request_uri("http://10.10.10.70:3000/track/download/" .. skylink .. "?" .. query, {
method = "POST",
headers = auth_headers,
})
if err or (res and res.status ~= ngx.HTTP_NO_CONTENT) then
local error_response = err or ("[HTTP " .. res.status .. "] " .. res.body)
ngx.log(ngx.ERR, "Failed accounts service request /track/download/" .. skylink .. ": ", error_response)
end
end
if ngx.header["Skynet-Skylink"] and ngx.status >= ngx.HTTP_OK and ngx.status < ngx.HTTP_SPECIAL_RESPONSE then
local auth_headers = skynet_account.get_auth_headers()
local ok, err = ngx.timer.at(0, track, ngx.header["Skynet-Skylink"], ngx.status, ngx.var.body_bytes_sent, auth_headers)
if err then ngx.log(ngx.ERR, "Failed to create timer: ", err) end
end
end
-- this block runs only when scanner module is enabled
if os.getenv("PORTAL_MODULES"):match("s") then
local function scan(premature, skylink)
if premature then return end
local httpc = require("resty.http").new()
-- 10.10.10.101 points to malware-scanner service (alias not available when using resty-http)
local res, err = httpc:request_uri("http://10.10.10.101:4000/scan/" .. skylink, {
method = "POST",
})
if err or (res and res.status ~= ngx.HTTP_OK) then
local error_response = err or ("[HTTP " .. res.status .. "] " .. res.body)
ngx.log(ngx.ERR, "Failed malware-scanner request /scan/" .. skylink .. ": ", error_response)
end
end
-- scan all skylinks but make sure to only run if skylink is present (empty if request failed)
if ngx.header["Skynet-Skylink"] then
local ok, err = ngx.timer.at(0, scan, ngx.header["Skynet-Skylink"])
if err then ngx.log(ngx.ERR, "Failed to create timer: ", err) end
end
end
}

View File

@ -0,0 +1,33 @@
log_by_lua_block {
local skynet_account = require("skynet.account")
-- tracking runs only when request comes from authenticated user
if skynet_account.is_authenticated() then
local function track(premature, request_method, auth_headers)
if premature then return end
local httpc = require("resty.http").new()
-- based on request method we assign a registry action string used
-- in track endpoint namely "read" for GET and "write" for POST
local registry_action = request_method == "GET" and "read" or "write"
-- 10.10.10.70 points to accounts service (alias not available when using resty-http)
local res, err = httpc:request_uri("http://10.10.10.70:3000/track/registry/" .. registry_action, {
method = "POST",
headers = auth_headers,
})
if err or (res and res.status ~= ngx.HTTP_NO_CONTENT) then
local error_response = err or ("[HTTP " .. res.status .. "] " .. res.body)
ngx.log(ngx.ERR, "Failed accounts service request /track/registry/" .. registry_action .. ": ", error_response)
end
end
if ngx.status == ngx.HTTP_OK or ngx.status == ngx.HTTP_NOT_FOUND then
local auth_headers = skynet_account.get_auth_headers()
local ok, err = ngx.timer.at(0, track, ngx.req.get_method(), auth_headers)
if err then ngx.log(ngx.ERR, "Failed to create timer: ", err) end
end
end
}

View File

@ -0,0 +1,55 @@
log_by_lua_block {
local skynet_account = require("skynet.account")
-- tracking runs only when request comes from authenticated user
if skynet_account.is_authenticated() then
local function track(premature, skylink, auth_headers)
if premature then return end
local httpc = require("resty.http").new()
-- 10.10.10.70 points to accounts service (alias not available when using resty-http)
local res, err = httpc:request_uri("http://10.10.10.70:3000/track/upload/" .. skylink, {
method = "POST",
headers = auth_headers,
})
if err or (res and res.status ~= ngx.HTTP_NO_CONTENT) then
local error_response = err or ("[HTTP " .. res.status .. "] " .. res.body)
ngx.log(ngx.ERR, "Failed accounts service request /track/upload/" .. skylink .. ": ", error_response)
end
end
-- report all skylinks (header empty if request failed) but only if jwt is preset (user is authenticated)
if ngx.header["Skynet-Skylink"] then
local auth_headers = skynet_account.get_auth_headers()
local ok, err = ngx.timer.at(0, track, ngx.header["Skynet-Skylink"], auth_headers)
if err then ngx.log(ngx.ERR, "Failed to create timer: ", err) end
end
end
-- this block runs only when scanner module is enabled
if os.getenv("PORTAL_MODULES"):match("s") then
local function scan(premature, skylink)
if premature then return end
local httpc = require("resty.http").new()
-- 10.10.10.101 points to malware-scanner service (alias not available when using resty-http)
local res, err = httpc:request_uri("http://10.10.10.101:4000/scan/" .. skylink, {
method = "POST",
})
if err or (res and res.status ~= ngx.HTTP_OK) then
local error_response = err or ("[HTTP " .. res.status .. "] " .. res.body)
ngx.log(ngx.ERR, "Failed malware-scanner request /scan/" .. skylink .. ": ", error_response)
end
end
-- scan all skylinks but make sure to only run if skylink is present (empty if request failed)
if ngx.header["Skynet-Skylink"] then
local ok, err = ngx.timer.at(0, scan, ngx.header["Skynet-Skylink"])
if err then ngx.log(ngx.ERR, "Failed to create timer: ", err) end
end
end
}

View File

@ -0,0 +1,7 @@
# Every file from within this directory will be included in the server block
# of the nginx configuration, at the very end. See client.conf.
#
# Example:
# location /blog {
# root /var/www/blog;
# }

View File

@ -0,0 +1,57 @@
listen 443 ssl http2;
include /etc/nginx/conf.d/include/ssl-settings;
include /etc/nginx/conf.d/include/init-optional-variables;
location / {
proxy_pass http://dashboard-v2:9000;
}
location /health {
proxy_pass http://accounts:3000;
}
location /stripe/webhook {
proxy_pass http://accounts:3000;
}
location /api/stripe/billing {
proxy_pass http://dashboard:3000;
}
location /api/stripe/checkout {
proxy_pass http://dashboard:3000;
}
location /api {
rewrite /api/(.*) /$1 break;
proxy_pass http://accounts:3000;
}
location /api/register {
include /etc/nginx/conf.d/include/cors;
rewrite /api/(.*) /$1 break;
proxy_pass http://accounts:3000;
}
location /api/user/pubkey/register {
include /etc/nginx/conf.d/include/cors;
rewrite /api/(.*) /$1 break;
proxy_pass http://accounts:3000;
}
location /api/login {
include /etc/nginx/conf.d/include/cors;
rewrite /api/(.*) /$1 break;
proxy_pass http://accounts:3000;
}
location /api/logout {
include /etc/nginx/conf.d/include/cors;
rewrite /api/(.*) /$1 break;
proxy_pass http://accounts:3000;
}

View File

@ -0,0 +1,424 @@
listen 443 ssl http2;
include /etc/nginx/conf.d/include/ssl-settings;
include /etc/nginx/conf.d/include/init-optional-variables;
# ddos protection: closing slow connections
client_body_timeout 1h;
client_header_timeout 1h;
send_timeout 1h;
proxy_connect_timeout 1h;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
# Increase the body buffer size, to ensure the internal POSTs can always
# parse the full POST contents into memory.
client_body_buffer_size 128k;
client_max_body_size 128k;
# legacy endpoint rewrite
rewrite ^/portals /skynet/portals permanent;
rewrite ^/stats /skynet/stats permanent;
rewrite ^/skynet/blacklist /skynet/blocklist permanent;
rewrite ^/docs(?:/(.*))?$ https://sdk.skynetlabs.com/$1 permanent;
location / {
include /etc/nginx/conf.d/include/cors;
proxy_pass http://website:9000;
}
location /skynet/blocklist {
include /etc/nginx/conf.d/include/cors;
add_header X-Proxy-Cache $upstream_cache_status;
proxy_cache skynet;
proxy_cache_valid any 1m; # cache blocklist for 1 minute
proxy_set_header User-Agent: Sia-Agent;
proxy_pass http://sia:9980/skynet/blocklist;
}
location /skynet/portal/blocklist {
include /etc/nginx/conf.d/include/cors;
add_header X-Proxy-Cache $upstream_cache_status;
proxy_cache skynet;
proxy_cache_valid 200 204 15m; # cache portal blocklist for 15 minutes
# 10.10.10.110 points to blocker service
proxy_pass http://10.10.10.110:4000/blocklist;
}
location /skynet/portals {
include /etc/nginx/conf.d/include/cors;
add_header X-Proxy-Cache $upstream_cache_status;
proxy_cache skynet;
proxy_cache_valid any 1m; # cache portals for 1 minute
proxy_set_header User-Agent: Sia-Agent;
proxy_pass http://sia:9980/skynet/portals;
}
location /skynet/stats {
include /etc/nginx/conf.d/include/cors;
add_header X-Proxy-Cache $upstream_cache_status;
proxy_cache skynet;
proxy_cache_valid any 1m; # cache stats for 1 minute
proxy_set_header User-Agent: Sia-Agent;
proxy_read_timeout 5m; # extend the read timeout
proxy_pass http://sia:9980/skynet/stats;
}
# Define path for server load endpoint
location /serverload {
# Define root directory in the nginx container to load file from
root /usr/local/share;
# including this because of peer pressure from the other routes
include /etc/nginx/conf.d/include/cors;
# tell nginx to expect json
default_type 'application/json';
# Allow for /serverload to load /serverload.json file
try_files $uri $uri.json =404;
}
location /skynet/health {
include /etc/nginx/conf.d/include/cors;
add_header X-Proxy-Cache $upstream_cache_status;
proxy_cache skynet;
proxy_cache_key $request_uri; # use whole request uri (uri + args) as cache key
proxy_cache_valid any 1m; # cache responses for 1 minute
proxy_set_header User-Agent: Sia-Agent;
proxy_read_timeout 5m; # extend the read timeout
proxy_pass http://sia:9980;
}
location /health-check {
include /etc/nginx/conf.d/include/cors;
access_log off; # do not log traffic to health-check endpoint
proxy_pass http://10.10.10.60:3100; # hardcoded ip because health-check waits for nginx
}
location /abuse {
return 308 /0404guluqu38oaqapku91ed11kbhkge55smh9lhjukmlrj37lfpm8no/;
}
location /abuse/report {
include /etc/nginx/conf.d/include/cors;
# 10.10.10.110 points to blocker service
proxy_pass http://10.10.10.110:4000/powblock;
}
location /hns {
include /etc/nginx/conf.d/include/cors;
# match the request_uri and extract the hns domain and anything that is passed in the uri after it
# example: /hns/something/foo/bar matches:
# > hns_domain: something
# > path: /foo/bar/
set_by_lua_block $hns_domain { return string.match(ngx.var.uri, "/hns/([^/?]+)") }
set_by_lua_block $path { return string.match(ngx.var.uri, "/hns/[^/?]+(.*)") }
proxy_set_header Host $host;
include /etc/nginx/conf.d/include/location-hns;
}
location /hnsres {
include /etc/nginx/conf.d/include/cors;
include /etc/nginx/conf.d/include/portal-access-check;
proxy_pass http://handshake-api:3100;
}
location /skynet/registry {
include /etc/nginx/conf.d/include/location-skynet-registry;
}
location /skynet/restore {
include /etc/nginx/conf.d/include/cors;
include /etc/nginx/conf.d/include/sia-auth;
include /etc/nginx/conf.d/include/portal-access-check;
client_max_body_size 5M;
# increase request timeouts
proxy_read_timeout 600;
proxy_send_timeout 600;
proxy_request_buffering off; # stream uploaded files through the proxy as it comes in
proxy_set_header Expect $http_expect;
proxy_set_header User-Agent: Sia-Agent;
# proxy this call to siad endpoint (make sure the ip is correct)
proxy_pass http://sia:9980;
}
location /skynet/registry/subscription {
include /etc/nginx/conf.d/include/cors;
# default to unlimited bandwidth and no delay
set $bandwidthlimit "0";
set $notificationdelay "0";
rewrite_by_lua_block {
local skynet_account = require("skynet.account")
if skynet_account.accounts_enabled() then
-- check if portal is in authenticated only mode
if skynet_account.is_access_unauthorized() then
return skynet_account.exit_access_unauthorized()
end
-- check if portal is in subscription only mode
if skynet_account.is_access_forbidden() then
return skynet_account.exit_access_forbidden()
end
-- get account limits of currently authenticated user
local limits = skynet_account.get_account_limits()
-- apply bandwidth limit and notification delay
ngx.var.bandwidthlimit = limits.download
ngx.var.notificationdelay = limits.registry
end
}
proxy_set_header User-Agent: Sia-Agent;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_pass http://sia:9980/skynet/registry/subscription?bandwidthlimit=$bandwidthlimit&notificationdelay=$notificationdelay;
}
location /skynet/skyfile {
include /etc/nginx/conf.d/include/cors;
include /etc/nginx/conf.d/include/sia-auth;
include /etc/nginx/conf.d/include/track-upload;
include /etc/nginx/conf.d/include/generate-siapath;
include /etc/nginx/conf.d/include/portal-access-check;
limit_req zone=uploads_by_ip burst=10 nodelay;
limit_req zone=uploads_by_ip_throttled;
limit_conn upload_conn 5;
limit_conn upload_conn_rl 1;
client_max_body_size 5000M; # make sure to limit the size of upload to a sane value
# increase request timeouts
proxy_read_timeout 600;
proxy_send_timeout 600;
proxy_request_buffering off; # stream uploaded files through the proxy as it comes in
proxy_set_header Expect $http_expect;
proxy_set_header User-Agent: Sia-Agent;
# proxy this call to siad endpoint (make sure the ip is correct)
proxy_pass http://sia:9980/skynet/skyfile/$dir1/$dir2/$dir3$is_args$args;
}
# endpoint implementing resumable file uploads open protocol https://tus.io
location /skynet/tus {
include /etc/nginx/conf.d/include/cors-headers; # include cors headers but do not overwrite OPTIONS response
include /etc/nginx/conf.d/include/track-upload;
limit_req zone=uploads_by_ip burst=10 nodelay;
limit_req zone=uploads_by_ip_throttled;
limit_conn upload_conn 5;
limit_conn upload_conn_rl 1;
# TUS chunks size is 40M + leaving 10M of breathing room
client_max_body_size 50M;
# Those timeouts need to be elevated since skyd can stall reading
# data for a while when overloaded which would terminate connection
client_body_timeout 1h;
proxy_send_timeout 1h;
# Add X-Forwarded-* headers
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
# rewrite proxy request to use correct host uri from env variable (required to return correct location header)
proxy_redirect $scheme://$host $scheme://$skynet_server_domain;
# proxy /skynet/tus requests to siad endpoint with all arguments
proxy_pass http://sia:9980;
access_by_lua_block {
local skynet_account = require("skynet.account")
if skynet_account.accounts_enabled() then
-- check if portal is in authenticated only mode
if skynet_account.is_access_unauthorized() then
return skynet_account.exit_access_unauthorized()
end
-- check if portal is in subscription only mode
if skynet_account.is_access_forbidden() then
return skynet_account.exit_access_forbidden()
end
-- get account limits of currently authenticated user
local limits = skynet_account.get_account_limits()
-- apply upload size limits
ngx.req.set_header("SkynetMaxUploadSize", limits.maxUploadSize)
end
}
# extract skylink from base64 encoded upload metadata and assign to a proper header
header_filter_by_lua_block {
ngx.header["Skynet-Portal-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_portal_domain
ngx.header["Skynet-Server-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_server_domain
if ngx.header["Upload-Metadata"] then
local encodedSkylink = string.match(ngx.header["Upload-Metadata"], "Skylink ([^,?]+)")
if encodedSkylink then
ngx.header["Skynet-Skylink"] = ngx.decode_base64(encodedSkylink)
end
end
}
}
location /skynet/pin {
include /etc/nginx/conf.d/include/cors;
include /etc/nginx/conf.d/include/sia-auth;
include /etc/nginx/conf.d/include/track-upload;
include /etc/nginx/conf.d/include/generate-siapath;
include /etc/nginx/conf.d/include/portal-access-check;
limit_req zone=uploads_by_ip burst=10 nodelay;
limit_req zone=uploads_by_ip_throttled;
limit_conn upload_conn 5;
limit_conn upload_conn_rl 1;
proxy_set_header User-Agent: Sia-Agent;
proxy_pass http://sia:9980$uri?siapath=$dir1/$dir2/$dir3&$args;
}
location /skynet/metadata {
include /etc/nginx/conf.d/include/cors;
include /etc/nginx/conf.d/include/portal-access-check;
header_filter_by_lua_block {
ngx.header["Skynet-Portal-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_portal_domain
ngx.header["Skynet-Server-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_server_domain
}
proxy_set_header User-Agent: Sia-Agent;
proxy_pass http://sia:9980;
}
location /skynet/resolve {
include /etc/nginx/conf.d/include/cors;
include /etc/nginx/conf.d/include/portal-access-check;
header_filter_by_lua_block {
ngx.header["Skynet-Portal-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_portal_domain
ngx.header["Skynet-Server-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_server_domain
}
proxy_set_header User-Agent: Sia-Agent;
proxy_pass http://sia:9980;
}
location ~ "^/(([a-zA-Z0-9-_]{46}|[a-z0-9]{55})(/.*)?)$" {
set $skylink $2;
set $path $3;
include /etc/nginx/conf.d/include/location-skylink;
}
location ~ "^/file/(([a-zA-Z0-9-_]{46}|[a-z0-9]{55})(/.*)?)$" {
set $skylink $2;
set $path $3;
set $args attachment=true&$args;
#set $is_args ?;
include /etc/nginx/conf.d/include/location-skylink;
}
location /skynet/trustless/basesector {
include /etc/nginx/conf.d/include/cors;
include /etc/nginx/conf.d/include/track-download;
limit_conn downloads_by_ip 100; # ddos protection: max 100 downloads at a time
# default download rate to unlimited
set $limit_rate 0;
access_by_lua_block {
local skynet_account = require("skynet.account")
if skynet_account.accounts_enabled() then
-- check if portal is in authenticated only mode
if skynet_account.is_access_unauthorized() then
return skynet_account.exit_access_unauthorized()
end
-- check if portal is in subscription only mode
if skynet_account.is_access_forbidden() then
return skynet_account.exit_access_forbidden()
end
-- get account limits of currently authenticated user
local limits = skynet_account.get_account_limits()
-- apply download speed limit
ngx.var.limit_rate = limits.download
end
}
limit_rate_after 512k;
limit_rate $limit_rate;
proxy_set_header User-Agent: Sia-Agent;
proxy_pass http://sia:9980;
}
location /__internal/do/not/use/accounts {
include /etc/nginx/conf.d/include/cors;
charset utf-8;
charset_types application/json;
default_type application/json;
content_by_lua_block {
local json = require('cjson')
local skynet_account = require("skynet.account")
local accounts_enabled = skynet_account.accounts_enabled()
local is_auth_required = skynet_account.is_auth_required()
local is_subscription_required = skynet_account.is_subscription_required()
local is_authenticated = skynet_account.is_authenticated()
local has_subscription = skynet_account.has_subscription()
ngx.say(json.encode{
enabled = accounts_enabled,
auth_required = is_auth_required,
subscription_required = is_subscription_required,
authenticated = is_authenticated,
subscription = has_subscription,
})
return ngx.exit(ngx.HTTP_OK)
}
}
include /etc/nginx/conf.d/server-override/*;

View File

@ -0,0 +1,44 @@
include /etc/nginx/conf.d/include/init-optional-variables;
location / {
set $skylink "";
set $path $uri;
rewrite_by_lua_block {
local cache = ngx.shared.dnslink
local cache_value = cache:get(ngx.var.host)
if cache_value == nil then
local httpc = require("resty.http").new()
-- 10.10.10.55 points to dnslink-api service (alias not available when using resty-http)
local res, err = httpc:request_uri("http://10.10.10.55:3100/dnslink/" .. ngx.var.host)
if err or (res and res.status ~= ngx.HTTP_OK) then
-- check whether we can fallback to regular skylink request
local match_skylink = ngx.re.match(ngx.var.uri, "^/([a-zA-Z0-9-_]{46}|[a-z0-9]{55})(/.*)?")
if match_skylink then
ngx.var.skylink = match_skylink[1]
ngx.var.path = match_skylink[2] or "/"
else
ngx.status = (err and ngx.HTTP_INTERNAL_SERVER_ERROR) or res.status
ngx.header["content-type"] = "text/plain"
ngx.say(err or res.body)
ngx.exit(ngx.status)
end
else
ngx.var.skylink = res.body
local cache_ttl = 300 -- 5 minutes cache expire time
cache:set(ngx.var.host, ngx.var.skylink, cache_ttl)
end
else
ngx.var.skylink = cache_value
end
ngx.var.skylink = require("skynet.skylink").parse(ngx.var.skylink)
}
include /etc/nginx/conf.d/include/location-skylink;
}

View File

@ -0,0 +1,11 @@
listen 443 ssl http2;
include /etc/nginx/conf.d/include/ssl-settings;
include /etc/nginx/conf.d/include/init-optional-variables;
location / {
set_by_lua_block $hns_domain { return string.match(ngx.var.host, "[^%.]+") }
set $path $uri;
include /etc/nginx/conf.d/include/location-hns;
}

View File

@ -0,0 +1,7 @@
listen 80;
include /etc/nginx/conf.d/include/init-optional-variables;
location / {
return 301 https://$host$request_uri;
}

View File

@ -0,0 +1,16 @@
listen 443 ssl http2;
include /etc/nginx/conf.d/include/ssl-settings;
include /etc/nginx/conf.d/include/init-optional-variables;
location / {
set_by_lua_block $skylink { return string.match(ngx.var.host, "%w+") }
set_by_lua_block $path {
-- strip ngx.var.request_uri from query params - this is basically the same as ngx.var.uri but
-- do not use ngx.var.uri because it will already be unescaped and we need to use escaped path
-- examples: escaped uri "/b%20r56+7" and unescaped uri "/b r56 7"
return string.gsub(ngx.var.request_uri, "?.*", "")
}
include /etc/nginx/conf.d/include/location-skylink;
}

View File

@ -0,0 +1,301 @@
-- source: https://github.com/aiq/basexx
-- license: MIT
-- modified: exposed from_basexx and to_basexx generic functions
--------------------------------------------------------------------------------
-- util functions
--------------------------------------------------------------------------------
local function divide_string( str, max )
local result = {}
local start = 1
for i = 1, #str do
if i % max == 0 then
table.insert( result, str:sub( start, i ) )
start = i + 1
elseif i == #str then
table.insert( result, str:sub( start, i ) )
end
end
return result
end
local function number_to_bit( num, length )
local bits = {}
while num > 0 do
local rest = math.floor( math.fmod( num, 2 ) )
table.insert( bits, rest )
num = ( num - rest ) / 2
end
while #bits < length do
table.insert( bits, "0" )
end
return string.reverse( table.concat( bits ) )
end
local function ignore_set( str, set )
if set then
str = str:gsub( "["..set.."]", "" )
end
return str
end
local function pure_from_bit( str )
return ( str:gsub( '........', function ( cc )
return string.char( tonumber( cc, 2 ) )
end ) )
end
local function unexpected_char_error( str, pos )
local c = string.sub( str, pos, pos )
return string.format( "unexpected character at position %d: '%s'", pos, c )
end
--------------------------------------------------------------------------------
local basexx = {}
--------------------------------------------------------------------------------
-- base2(bitfield) decode and encode function
--------------------------------------------------------------------------------
local bitMap = { o = "0", i = "1", l = "1" }
function basexx.from_bit( str, ignore )
str = ignore_set( str, ignore )
str = string.lower( str )
str = str:gsub( '[ilo]', function( c ) return bitMap[ c ] end )
local pos = string.find( str, "[^01]" )
if pos then return nil, unexpected_char_error( str, pos ) end
return pure_from_bit( str )
end
function basexx.to_bit( str )
return ( str:gsub( '.', function ( c )
local byte = string.byte( c )
local bits = {}
for _ = 1,8 do
table.insert( bits, byte % 2 )
byte = math.floor( byte / 2 )
end
return table.concat( bits ):reverse()
end ) )
end
--------------------------------------------------------------------------------
-- base16(hex) decode and encode function
--------------------------------------------------------------------------------
function basexx.from_hex( str, ignore )
str = ignore_set( str, ignore )
local pos = string.find( str, "[^%x]" )
if pos then return nil, unexpected_char_error( str, pos ) end
return ( str:gsub( '..', function ( cc )
return string.char( tonumber( cc, 16 ) )
end ) )
end
function basexx.to_hex( str )
return ( str:gsub( '.', function ( c )
return string.format('%02X', string.byte( c ) )
end ) )
end
--------------------------------------------------------------------------------
-- generic function to decode and encode base32/base64
--------------------------------------------------------------------------------
function basexx.from_basexx( str, alphabet, bits )
local result = {}
for i = 1, #str do
local c = string.sub( str, i, i )
if c ~= '=' then
local index = string.find( alphabet, c, 1, true )
if not index then
return nil, unexpected_char_error( str, i )
end
table.insert( result, number_to_bit( index - 1, bits ) )
end
end
local value = table.concat( result )
local pad = #value % 8
return pure_from_bit( string.sub( value, 1, #value - pad ) )
end
function basexx.to_basexx( str, alphabet, bits, pad )
local bitString = basexx.to_bit( str )
local chunks = divide_string( bitString, bits )
local result = {}
for _,value in ipairs( chunks ) do
if ( #value < bits ) then
value = value .. string.rep( '0', bits - #value )
end
local pos = tonumber( value, 2 ) + 1
table.insert( result, alphabet:sub( pos, pos ) )
end
table.insert( result, pad )
return table.concat( result )
end
--------------------------------------------------------------------------------
-- rfc 3548: http://www.rfc-editor.org/rfc/rfc3548.txt
--------------------------------------------------------------------------------
local base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
local base32PadMap = { "", "======", "====", "===", "=" }
function basexx.from_base32( str, ignore )
str = ignore_set( str, ignore )
return basexx.from_basexx( string.upper( str ), base32Alphabet, 5 )
end
function basexx.to_base32( str )
return basexx.to_basexx( str, base32Alphabet, 5, base32PadMap[ #str % 5 + 1 ] )
end
--------------------------------------------------------------------------------
-- crockford: http://www.crockford.com/wrmg/base32.html
--------------------------------------------------------------------------------
local crockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
local crockfordMap = { O = "0", I = "1", L = "1" }
function basexx.from_crockford( str, ignore )
str = ignore_set( str, ignore )
str = string.upper( str )
str = str:gsub( '[ILOU]', function( c ) return crockfordMap[ c ] end )
return basexx.from_basexx( str, crockfordAlphabet, 5 )
end
function basexx.to_crockford( str )
return basexx.to_basexx( str, crockfordAlphabet, 5, "" )
end
--------------------------------------------------------------------------------
-- base64 decode and encode function
--------------------------------------------------------------------------------
local base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"..
"abcdefghijklmnopqrstuvwxyz"..
"0123456789+/"
local base64PadMap = { "", "==", "=" }
function basexx.from_base64( str, ignore )
str = ignore_set( str, ignore )
return basexx.from_basexx( str, base64Alphabet, 6 )
end
function basexx.to_base64( str )
return basexx.to_basexx( str, base64Alphabet, 6, base64PadMap[ #str % 3 + 1 ] )
end
--------------------------------------------------------------------------------
-- URL safe base64 decode and encode function
--------------------------------------------------------------------------------
local url64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"..
"abcdefghijklmnopqrstuvwxyz"..
"0123456789-_"
function basexx.from_url64( str, ignore )
str = ignore_set( str, ignore )
return basexx.from_basexx( str, url64Alphabet, 6 )
end
function basexx.to_url64( str )
return basexx.to_basexx( str, url64Alphabet, 6, "" )
end
--------------------------------------------------------------------------------
--
--------------------------------------------------------------------------------
local function length_error( len, d )
return string.format( "invalid length: %d - must be a multiple of %d", len, d )
end
local z85Decoder = { 0x00, 0x44, 0x00, 0x54, 0x53, 0x52, 0x48, 0x00,
0x4B, 0x4C, 0x46, 0x41, 0x00, 0x3F, 0x3E, 0x45,
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x40, 0x00, 0x49, 0x42, 0x4A, 0x47,
0x51, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A,
0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32,
0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A,
0x3B, 0x3C, 0x3D, 0x4D, 0x00, 0x4E, 0x43, 0x00,
0x00, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10,
0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20,
0x21, 0x22, 0x23, 0x4F, 0x00, 0x50, 0x00, 0x00 }
function basexx.from_z85( str, ignore )
str = ignore_set( str, ignore )
if ( #str % 5 ) ~= 0 then
return nil, length_error( #str, 5 )
end
local result = {}
local value = 0
for i = 1, #str do
local index = string.byte( str, i ) - 31
if index < 1 or index >= #z85Decoder then
return nil, unexpected_char_error( str, i )
end
value = ( value * 85 ) + z85Decoder[ index ]
if ( i % 5 ) == 0 then
local divisor = 256 * 256 * 256
while divisor ~= 0 do
local b = math.floor( value / divisor ) % 256
table.insert( result, string.char( b ) )
divisor = math.floor( divisor / 256 )
end
value = 0
end
end
return table.concat( result )
end
local z85Encoder = "0123456789"..
"abcdefghijklmnopqrstuvwxyz"..
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"..
".-:+=^!/*?&<>()[]{}@%$#"
function basexx.to_z85( str )
if ( #str % 4 ) ~= 0 then
return nil, length_error( #str, 4 )
end
local result = {}
local value = 0
for i = 1, #str do
local b = string.byte( str, i )
value = ( value * 256 ) + b
if ( i % 4 ) == 0 then
local divisor = 85 * 85 * 85 * 85
while divisor ~= 0 do
local index = ( math.floor( value / divisor ) % 85 ) + 1
table.insert( result, z85Encoder:sub( index, index ) )
divisor = math.floor( divisor / 85 )
end
value = 0
end
end
return table.concat( result )
end
--------------------------------------------------------------------------------
return basexx

View File

@ -0,0 +1,144 @@
local _M = {}
-- constant tier ids
local tier_id_anonymous = 0
local tier_id_free = 1
-- fallback - remember to keep those updated
local anon_limits = {
["tierID"] = tier_id_anonymous,
["tierName"] = "anonymous",
["upload"] = 655360,
["download"] = 655360,
["maxUploadSize"] = 1073741824,
["registry"] = 250
}
-- get all non empty authentication headers from request, we want to return
-- all of them and let accounts service deal with validation and prioritisation
function _M.get_auth_headers()
local utils = require("utils")
local request_headers = ngx.req.get_headers()
local headers = {}
-- try to extract skynet-jwt cookie from cookie header
local skynet_jwt_cookie = utils.extract_cookie(request_headers["Cookie"], "skynet[-]jwt")
-- if skynet-jwt cookie is present, pass it as is
if skynet_jwt_cookie then
headers["Cookie"] = skynet_jwt_cookie
end
-- if authorization header is set, pass it as is
if request_headers["Authorization"] then
headers["Authorization"] = request_headers["Authorization"]
end
-- if skynet api key header is set, pass it as is
if request_headers["Skynet-Api-Key"] then
headers["Skynet-Api-Key"] = request_headers["Skynet-Api-Key"]
end
return headers
end
-- handle request exit when access to portal should be restricted to authenticated users only
function _M.exit_access_unauthorized(message)
ngx.status = ngx.HTTP_UNAUTHORIZED
ngx.header["content-type"] = "text/plain"
ngx.say(message or "Portal operator restricted access to authenticated users only")
return ngx.exit(ngx.status)
end
-- handle request exit when access to portal should be restricted to subscription users only
function _M.exit_access_forbidden(message)
ngx.status = ngx.HTTP_FORBIDDEN
ngx.header["content-type"] = "text/plain"
ngx.say(message or "Portal operator restricted access to users with active subscription only")
return ngx.exit(ngx.status)
end
function _M.accounts_enabled()
return os.getenv("PORTAL_MODULES"):match("a") ~= nil
end
function _M.get_account_limits()
local cjson = require('cjson')
local utils = require('utils')
local auth_headers = _M.get_auth_headers()
-- simple case of anonymous request - none of available auth headers exist
if utils.is_table_empty(auth_headers) then
return anon_limits
end
if ngx.var.account_limits == "" then
local httpc = require("resty.http").new()
-- 10.10.10.70 points to accounts service (alias not available when using resty-http)
local res, err = httpc:request_uri("http://10.10.10.70:3000/user/limits?unit=byte", {
headers = auth_headers,
})
-- fail gracefully in case /user/limits failed
if err or (res and res.status ~= ngx.HTTP_OK) then
local error_response = err or ("[HTTP " .. res.status .. "] " .. res.body)
ngx.log(ngx.ERR, "Failed accounts service request /user/limits?unit=byte: ", error_response)
ngx.var.account_limits = cjson.encode(anon_limits)
elseif res and res.status == ngx.HTTP_OK then
ngx.var.account_limits = res.body
end
end
return cjson.decode(ngx.var.account_limits)
end
-- detect whether current user is authenticated
function _M.is_authenticated()
if not _M.accounts_enabled() then return false end
local limits = _M.get_account_limits()
return limits.tierID > tier_id_anonymous
end
-- detect whether current user has active subscription
function _M.has_subscription()
local limits = _M.get_account_limits()
return limits.tierID > tier_id_free
end
function _M.is_auth_required()
-- authentication is required if mode is set to "authenticated"
-- or "subscription" (require active subscription to a premium plan)
return os.getenv("ACCOUNTS_LIMIT_ACCESS") == "authenticated" or _M.is_subscription_required()
end
function _M.is_subscription_required()
return os.getenv("ACCOUNTS_LIMIT_ACCESS") == "subscription"
end
local is_access_always_allowed = function ()
-- options requests do not attach cookies - should always be available
-- requests should not be limited based on accounts if accounts are not enabled
return ngx.req.get_method() == "OPTIONS" or not _M.accounts_enabled()
end
-- check whether access is restricted if portal requires authorization
function _M.is_access_unauthorized()
if is_access_always_allowed() then return false end
-- check if authentication is required and request is not authenticated
return _M.is_auth_required() and not _M.is_authenticated()
end
-- check whether user is authenticated but does not have access to given resources
function _M.is_access_forbidden()
if is_access_always_allowed() then return false end
-- check if active subscription is required and request is from user without it
return _M.is_subscription_required() and not _M.has_subscription()
end
return _M

View File

@ -0,0 +1,40 @@
local _M = {}
local basexx = require("basexx")
local hasher = require("hasher")
-- parse any skylink and return base64 version
function _M.parse(skylink)
if string.len(skylink) == 55 then
local decoded = basexx.from_basexx(string.upper(skylink), "0123456789ABCDEFGHIJKLMNOPQRSTUV", 5)
return basexx.to_url64(decoded)
end
return skylink
end
-- hash skylink into 32 bytes hash used in blocklist
function _M.hash(skylink)
-- ensure that the skylink is base64 encoded
local base64Skylink = _M.parse(skylink)
-- decode skylink from base64 encoding
local rawSkylink = basexx.from_url64(base64Skylink)
-- drop first two bytes and leave just merkle root
local rawMerkleRoot = string.sub(rawSkylink, 3)
-- parse with blake2b with key length of 32
local blake2bHashed = hasher.blake2b(rawMerkleRoot, 32)
-- hex encode the blake hash
local hexHashed = basexx.to_hex(blake2bHashed)
-- lowercase the hex encoded hash
local lowerHexHashed = string.lower(hexHashed)
return lowerHexHashed
end
return _M

View File

@ -0,0 +1,23 @@
local skynet_skylink = require("skynet.skylink")
describe("parse", function()
local base32 = "0404dsjvti046fsua4ktor9grrpe76erq9jot9cvopbhsvsu76r4r30"
local base64 = "AQBG8n_sgEM_nlEp3G0w3vLjmdvSZ46ln8ZXHn-eObZNjA"
it("should return unchanged base64 skylink", function()
assert.is.same(skynet_skylink.parse(base64), base64)
end)
it("should transform base32 skylink into base64", function()
assert.is.same(skynet_skylink.parse(base32), base64)
end)
end)
describe("hash", function()
local base64 = "EADi4QZWt87sSDCSjVTcmyI5tE_YAsuC90BcCi_jEmG5NA"
local hash = "6cfb9996ad74e5614bbb8e7228e72f1c1bc14dd9ce8a83b3ccabdb6d8d70f330"
it("should hash skylink", function()
assert.is.same(hash, skynet_skylink.hash(base64))
end)
end)

View File

@ -0,0 +1,23 @@
local _M = {}
function _M.authorization_header()
-- read api password from env variable
local apipassword = os.getenv("SIA_API_PASSWORD")
-- if api password is not available as env variable, read it from disk
if apipassword == nil or apipassword == "" then
-- open apipassword file for reading (b flag is required for some reason)
-- (file /etc/.sia/apipassword has to be mounted from the host system)
local apipassword_file = io.open("/data/sia/apipassword", "rb")
-- read apipassword file contents and trim newline (important)
apipassword = apipassword_file:read("*all"):gsub("%s+", "")
-- make sure to close file after reading the password
apipassword_file.close()
end
-- encode the user:password authorization string
-- (in our case user is empty so it is just :password)
local content = require("ngx.base64").encode_base64url(":" .. apipassword)
-- set authorization header with proper base64 encoded string
return "Basic " .. content
end
return _M

View File

@ -0,0 +1,45 @@
local _M = {}
-- utility function for checking if table is empty
function _M.is_table_empty(check)
-- bind next to local variable to achieve ultimate efficiency
-- https://stackoverflow.com/a/1252776
local next = next
return next(check) == nil
end
-- extract full cookie name and value by its name from cookie string
-- note: name matcher argument is a pattern so you will need to escape
-- any special characters, read more https://www.lua.org/pil/20.2.html
function _M.extract_cookie(cookie_string, name_matcher)
-- nil cookie string safeguard
if cookie_string == nil then
return nil
end
local start, stop = string.find(cookie_string, name_matcher .. "=[^;]+")
if start then
return string.sub(cookie_string, start, stop)
end
return nil
end
-- extract just the cookie value by its name from cookie string
-- note: name matcher argument is a pattern so you will need to escape
-- any special characters, read more https://www.lua.org/pil/20.2.html
function _M.extract_cookie_value(cookie_string, name_matcher)
local cookie = _M.extract_cookie(cookie_string, name_matcher)
if cookie == nil then
return nil
end
local value_start = string.find(cookie, "=") + 1
return string.sub(cookie, value_start)
end
return _M

View File

@ -0,0 +1,79 @@
local utils = require('utils')
describe("is_table_empty", function()
it("should return true for empty table", function()
assert.is_true(utils.is_table_empty({}))
end)
it("should return false for not empty table", function()
assert.is_false(utils.is_table_empty({ ["foo"] = "bar" }))
end)
end)
describe("extract_cookie", function()
local cookie_string = "aaa=bbb; skynet-jwt=MTY0NzUyr8jD-ytiWtspm0tGabKfooxeIDuWcXhJ3lnY0eEw==; xxx=yyy"
it("should return nil if cookie string is nil", function()
local cookie = utils.extract_cookie_value(nil, "aaa")
assert.is_nil(cookie)
end)
it("should return nil if cookie name is not found", function()
local cookie = utils.extract_cookie(cookie_string, "foo")
assert.is_nil(cookie)
end)
it("should return cookie if cookie_string starts with that cookie name", function()
local cookie = utils.extract_cookie(cookie_string, "aaa")
assert.are.equals(cookie, "aaa=bbb")
end)
it("should return cookie if cookie_string ends with that cookie name", function()
local cookie = utils.extract_cookie(cookie_string, "xxx")
assert.are.equals(cookie, "xxx=yyy")
end)
it("should return cookie with custom matcher", function()
local cookie = utils.extract_cookie(cookie_string, "skynet[-]jwt")
assert.are.equals(cookie, "skynet-jwt=MTY0NzUyr8jD-ytiWtspm0tGabKfooxeIDuWcXhJ3lnY0eEw==")
end)
end)
describe("extract_cookie_value", function()
local cookie_string = "aaa=bbb; skynet-jwt=MTY0NzUyr8jD-ytiWtspm0tGabKfooxeIDuWcXhJ3lnY0eEw==; xxx=yyy"
it("should return nil if cookie string is nil", function()
local value = utils.extract_cookie_value(nil, "aaa")
assert.is_nil(value)
end)
it("should return nil if cookie name is not found", function()
local value = utils.extract_cookie_value(cookie_string, "foo")
assert.is_nil(value)
end)
it("should return value if cookie_string starts with that cookie name", function()
local value = utils.extract_cookie_value(cookie_string, "aaa")
assert.are.equals(value, "bbb")
end)
it("should return cookie if cookie_string ends with that cookie name", function()
local value = utils.extract_cookie_value(cookie_string, "xxx")
assert.are.equals(value, "yyy")
end)
it("should return cookie with custom matcher", function()
local value = utils.extract_cookie_value(cookie_string, "skynet[-]jwt")
assert.are.equals(value, "MTY0NzUyr8jD-ytiWtspm0tGabKfooxeIDuWcXhJ3lnY0eEw==")
end)
end)

1106
docker/nginx/mo Executable file

File diff suppressed because it is too large Load Diff

122
docker/nginx/nginx.conf Normal file
View File

@ -0,0 +1,122 @@
# nginx.conf -- docker-openresty
#
# This file is installed to:
# `/usr/local/openresty/nginx/conf/nginx.conf`
# and is the file loaded by nginx at startup,
# unless the user specifies otherwise.
#
# It tracks the upstream OpenResty's `nginx.conf`, but removes the `server`
# section and adds this directive:
# `include /etc/nginx/conf.d/*.conf;`
#
# The `docker-openresty` file `nginx.vh.default.conf` is copied to
# `/etc/nginx/conf.d/default.conf`. It contains the `server section
# of the upstream `nginx.conf`.
#
# See https://github.com/openresty/docker-openresty/blob/master/README.md#nginx-config-files
#
user root;
worker_processes auto;
#error_log logs/error.log;
#error_log logs/error.log notice;
#error_log logs/error.log info;
#pid logs/nginx.pid;
# declare env variables to use it in config
env PORTAL_DOMAIN;
env SERVER_DOMAIN;
env PORTAL_MODULES;
env ACCOUNTS_LIMIT_ACCESS;
env SIA_API_PASSWORD;
events {
worker_connections 8192;
}
http {
include mime.types;
default_type application/octet-stream;
lua_package_path "/etc/nginx/libs/?.lua;;";
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" $upstream_response_time '
'$upstream_bytes_sent $upstream_bytes_received '
'"$upstream_http_content_type" "$upstream_cache_status" '
'"$server_alias" "$sent_http_skynet_skylink" '
'$upstream_connect_time $upstream_header_time '
'$request_time "$hns_domain" "$skylink" $upstream_http_skynet_cache_ratio';
access_log logs/access.log main;
# See Move default writable paths to a dedicated directory (#119)
# https://github.com/openresty/docker-openresty/issues/119
client_body_temp_path /var/run/openresty/nginx-client-body;
proxy_temp_path /var/run/openresty/nginx-proxy;
fastcgi_temp_path /var/run/openresty/nginx-fastcgi;
uwsgi_temp_path /var/run/openresty/nginx-uwsgi;
scgi_temp_path /var/run/openresty/nginx-scgi;
sendfile on;
#tcp_nopush on;
#keepalive_timeout 0;
keepalive_timeout 65;
# globally enable http 1.1 on all proxied requests
# http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_http_version
proxy_http_version 1.1;
# proxy cache definition
proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=skynet:10m max_size=50g min_free=100g inactive=48h use_temp_path=off;
# this runs before forking out nginx worker processes
init_by_lua_block {
require "cjson"
require "resty.http"
require "skynet.skylink"
require "skynet.utils"
}
# include skynet-portal-api and skynet-server-api header on every request
header_filter_by_lua_block {
ngx.header["Skynet-Portal-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_portal_domain
ngx.header["Skynet-Server-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_server_domain
}
# ratelimit specified IPs
geo $limit {
default 0;
include /etc/nginx/conf.d/include/ratelimited;
}
map $limit $limit_key {
0 "";
1 $binary_remote_addr;
}
limit_req_zone $binary_remote_addr zone=uploads_by_ip:10m rate=10r/s;
limit_req_zone $limit_key zone=uploads_by_ip_throttled:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=registry_access_by_ip:10m rate=60r/m;
limit_req_zone $limit_key zone=registry_access_by_ip_throttled:10m rate=20r/m;
limit_conn_zone $binary_remote_addr zone=upload_conn:10m;
limit_conn_zone $limit_key zone=upload_conn_rl:10m;
limit_conn_zone $binary_remote_addr zone=downloads_by_ip:10m;
limit_req_status 429;
limit_conn_status 429;
# Add X-Forwarded-* headers
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/conf.extra.d/*.conf;
}

16
docker/sia/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM golang:1.16.7 AS sia-builder
ENV GOOS linux
ENV GOARCH amd64
ARG branch=portal-latest
RUN git clone https://gitlab.com/SkynetLabs/skyd.git Sia --single-branch --branch ${branch} && \
make release --directory Sia
FROM nebulouslabs/sia:1.5.6
COPY --from=sia-builder /go/bin/ /usr/bin/
RUN if [ -f "/usr/bin/skyd" ]; then mv /usr/bin/skyd /usr/bin/siad; fi && \
if [ -f "/usr/bin/skyc" ]; then mv /usr/bin/skyc /usr/bin/siac; fi

View File

@ -1,2 +0,0 @@
Purpose of this file is that `logs` dir can be commited to git and it will be
present on portal servers. The rest of files in `logs` dir are git ignored.

View File

@ -0,0 +1,4 @@
node_modules/
.cache/
public/
storybook-build/

View File

@ -0,0 +1,6 @@
module.exports = {
globals: {
__PATH_PREFIX__: true,
},
extends: ["react-app", "plugin:storybook/recommended"],
};

4
packages/dashboard-v2/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
.cache/
public/
storybook-build/

View File

@ -0,0 +1,4 @@
node_modules/
.cache/
public/
storybook-build/

View File

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

View File

@ -0,0 +1,19 @@
module.exports = {
stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"storybook-addon-gatsby",
{
name: "@storybook/addon-postcss",
options: {
postcssLoaderOptions: {
implementation: require("postcss"),
},
},
},
],
core: {
builder: "webpack5",
},
};

View File

@ -0,0 +1,20 @@
import "tailwindcss/tailwind.css";
import "@fontsource/sora/300.css"; // light
import "@fontsource/sora/400.css"; // normal
import "@fontsource/sora/500.css"; // medium
import "@fontsource/sora/600.css"; // semibold
import "@fontsource/source-sans-pro/400.css"; // normal
import "@fontsource/source-sans-pro/600.css"; // semibold
import "../src/styles/global.css";
export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
layout: "fullscreen",
};

View File

@ -0,0 +1,14 @@
FROM node:16.14.2-alpine
WORKDIR /usr/app
COPY package.json yarn.lock ./
RUN yarn --frozen-lockfile
COPY static ./static
COPY src ./src
COPY gatsby*.js ./
COPY postcss.config.js tailwind.config.js ./
CMD ["sh", "-c", "yarn build && yarn serve --host 0.0.0.0 -p 9000"]

View File

@ -0,0 +1,25 @@
# Skynet Account Dashboard
Code behind [account.skynetpro.net](https://account.skynetpro.net/)
## Development
This is a Gatsby application. To run it locally, all you need is:
- `yarn install`
- `yarn start`
## Accessing remote APIs
To be able to log in on a local environment with your production credentials, you'll need to make the browser believe you're actually on the same domain, otherwise the browser will block the session cookie.
To do the trick, edit your `/etc/hosts` file and add a record like this:
```
127.0.0.1 local.skynetpro.net
```
then run `yarn develop:secure` -- it will run `gatsby develop` with `--https --host=local.skynetpro.net -p=443` options.
If you're on macOS, you may need to `sudo` the command to successfully bind to port `443`.
> **NOTE:** This should become easier once we have a docker image for the new dashboard.

View File

@ -0,0 +1,22 @@
import * as React from "react";
import "@fontsource/sora/300.css"; // light
import "@fontsource/sora/400.css"; // normal
import "@fontsource/sora/500.css"; // medium
import "@fontsource/sora/600.css"; // semibold
import "@fontsource/source-sans-pro/400.css"; // normal
import "@fontsource/source-sans-pro/600.css"; // semibold
import "./src/styles/global.css";
import { MODAL_ROOT_ID } from "./src/components/Modal";
import { PortalSettingsProvider } from "./src/contexts/portal-settings";
export function wrapPageElement({ element, props }) {
const Layout = element.type.Layout ?? React.Fragment;
return (
<PortalSettingsProvider>
<Layout {...props}>
{element}
<div id={MODAL_ROOT_ID} />
</Layout>
</PortalSettingsProvider>
);
}

View File

@ -0,0 +1,36 @@
const { createProxyMiddleware } = require("http-proxy-middleware");
module.exports = {
siteMetadata: {
title: "Skynet Account",
siteUrl: `https://account.${process.env.GATSBY_PORTAL_DOMAIN}/`,
},
trailingSlash: "never",
plugins: [
"gatsby-plugin-image",
"gatsby-plugin-provide-react",
"gatsby-plugin-react-helmet",
"gatsby-plugin-sharp",
"gatsby-transformer-sharp",
"gatsby-plugin-styled-components",
"gatsby-plugin-postcss",
{
resolve: "gatsby-source-filesystem",
options: {
name: "images",
path: "./static/images/",
},
__key: "images",
},
],
developMiddleware: (app) => {
app.use(
"/api/",
createProxyMiddleware({
target: "https://account.skynetpro.net",
secure: false, // Do not reject self-signed certificates.
changeOrigin: true,
})
);
},
};

View File

@ -0,0 +1,22 @@
import * as React from "react";
import "@fontsource/sora/300.css"; // light
import "@fontsource/sora/400.css"; // normal
import "@fontsource/sora/500.css"; // medium
import "@fontsource/sora/600.css"; // semibold
import "@fontsource/source-sans-pro/400.css"; // normal
import "@fontsource/source-sans-pro/600.css"; // semibold
import "./src/styles/global.css";
import { MODAL_ROOT_ID } from "./src/components/Modal";
import { PortalSettingsProvider } from "./src/contexts/portal-settings";
export function wrapPageElement({ element, props }) {
const Layout = element.type.Layout ?? React.Fragment;
return (
<PortalSettingsProvider>
<Layout {...props}>
{element}
<div id={MODAL_ROOT_ID} />
</Layout>
</PortalSettingsProvider>
);
}

View File

@ -0,0 +1,81 @@
{
"name": "accounts-dashboard",
"version": "1.0.0",
"private": true,
"description": "Accounts Dashboard",
"author": "Skynet Labs",
"keywords": [
"gatsby"
],
"scripts": {
"develop": "gatsby develop",
"develop:secure": "gatsby develop --https --host=local.skynetpro.net -p=443",
"start": "gatsby develop",
"build": "gatsby build",
"serve": "gatsby serve",
"clean": "gatsby clean",
"lint": "eslint .",
"prettier": "prettier .",
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook -o storybook-build"
},
"dependencies": {
"@fontsource/sora": "^4.5.3",
"@fontsource/source-sans-pro": "^4.5.3",
"classnames": "^2.3.1",
"copy-text-to-clipboard": "^3.0.1",
"dayjs": "^1.10.8",
"formik": "^2.2.9",
"gatsby": "^4.6.2",
"gatsby-plugin-postcss": "^5.7.0",
"http-status-codes": "^2.2.0",
"ky": "^0.30.0",
"nanoid": "^3.3.1",
"path-browserify": "^1.0.1",
"postcss": "^8.4.6",
"pretty-bytes": "^6.0.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-dropzone": "^12.0.4",
"react-helmet": "^6.1.0",
"react-use": "^17.3.2",
"skynet-js": "4.0.27-beta",
"swr": "^1.2.2",
"tailwindcss": "^3.0.23",
"yup": "^0.32.11"
},
"devDependencies": {
"@babel/core": "^7.17.4",
"@storybook/addon-actions": "^6.4.19",
"@storybook/addon-essentials": "^6.4.19",
"@storybook/addon-interactions": "^6.4.19",
"@storybook/addon-links": "^6.4.19",
"@storybook/addon-postcss": "^2.0.0",
"@storybook/builder-webpack5": "^6.4.19",
"@storybook/manager-webpack5": "^6.4.19",
"@storybook/react": "^6.4.19",
"@storybook/testing-library": "^0.0.9",
"autoprefixer": "^10.4.2",
"babel-eslint": "^10.1.0",
"babel-loader": "^8.2.3",
"babel-plugin-preval": "^5.1.0",
"babel-plugin-styled-components": "^2.0.2",
"eslint": "^8.9.0",
"eslint-config-react-app": "^7.0.0",
"eslint-plugin-storybook": "^0.5.6",
"gatsby-plugin-alias-imports": "^1.0.5",
"gatsby-plugin-image": "^2.6.0",
"gatsby-plugin-preval": "^1.0.0",
"gatsby-plugin-provide-react": "^1.0.2",
"gatsby-plugin-react-helmet": "^5.6.0",
"gatsby-plugin-sharp": "^4.6.0",
"gatsby-plugin-styled-components": "^5.8.0",
"gatsby-source-filesystem": "^4.6.0",
"gatsby-transformer-sharp": "^4.6.0",
"http-proxy-middleware": "^1.3.1",
"prettier": "2.5.1",
"react-is": "^17.0.2",
"storybook-addon-gatsby": "^0.0.5",
"styled-components": "^5.3.3"
}
}

View File

@ -0,0 +1,3 @@
module.exports = {
plugins: [require("tailwindcss/nesting"), require("tailwindcss"), require("autoprefixer")],
};

View File

@ -0,0 +1,155 @@
import dayjs from "dayjs";
import cn from "classnames";
import { useCallback, useState } from "react";
import { Alert } from "../Alert";
import { Button } from "../Button";
import { AddSkylinkToAPIKeyForm } from "../forms/AddSkylinkToAPIKeyForm";
import { CogIcon, TrashIcon } from "../Icons";
import { Modal } from "../Modal";
import { useAPIKeyEdit } from "./useAPIKeyEdit";
import { useAPIKeyRemoval } from "./useAPIKeyRemoval";
export const APIKey = ({ apiKey, onRemoved, onEdited, onRemovalError }) => {
const { id, name, createdAt, skylinks } = apiKey;
const isPublic = apiKey.public === "true";
const [error, setError] = useState(null);
const onSkylinkListEdited = useCallback(() => {
setError(null);
onEdited();
}, [onEdited]);
const onSkylinkListEditFailure = (errorMessage) => setError(errorMessage);
const {
removalError,
removalInitiated,
prompt: promptRemoval,
abort: abortRemoval,
confirm: confirmRemoval,
} = useAPIKeyRemoval({
key: apiKey,
onSuccess: onRemoved,
onFailure: onRemovalError,
});
const {
editInitiated,
prompt: promptEdit,
abort: abortEdit,
addSkylink,
removeSkylink,
} = useAPIKeyEdit({
key: apiKey,
onSkylinkListUpdate: onSkylinkListEdited,
onSkylinkListUpdateFailure: onSkylinkListEditFailure,
});
const closeEditModal = useCallback(() => {
setError(null);
abortEdit();
}, [abortEdit]);
const skylinksNumber = skylinks?.length ?? 0;
const isNotConfigured = isPublic && skylinksNumber === 0;
const skylinksPhrasePrefix = skylinksNumber === 0 ? "No" : skylinksNumber;
const skylinksPhrase = `${skylinksPhrasePrefix} ${skylinksNumber === 1 ? "skylink" : "skylinks"} configured`;
return (
<li
className={cn(
"grid grid-cols-2 sm:grid-cols-[1fr_repeat(2,_max-content)] py-3 px-4 gap-x-8 items-center bg-white odd:bg-palette-100/50"
)}
>
<span className="col-span-2 sm:col-span-1 flex items-center">
<span className="flex flex-col">
<span className={cn("truncate", { "text-palette-300": !name })}>{name || "unnamed key"}</span>
<button
onClick={promptEdit}
className={cn("text-xs hover:underline decoration-dotted", {
"text-error": isNotConfigured,
"text-palette-400": !isNotConfigured,
})}
>
{skylinksPhrase}
</button>
</span>
</span>
<span className="col-span-2 my-4 border-t border-t-palette-200/50 sm:hidden" />
<span className="text-palette-400">{dayjs(createdAt).format("MMM DD, YYYY")}</span>
<span className="flex items-center justify-end">
{isPublic && (
<button
title="Add or remove skylinks"
aria-label="Add or remove skylinks"
className="p-1 transition-colors hover:text-primary"
onClick={promptEdit}
>
<CogIcon size={22} />
</button>
)}
<button
title="Delete this API key"
aria-label="Delete this API key"
className="p-1 transition-colors hover:text-error"
onClick={promptRemoval}
>
<TrashIcon size={16} />
</button>
</span>
{removalInitiated && (
<Modal onClose={abortRemoval} className="flex flex-col gap-4 text-center">
<h4>Delete API key</h4>
<div>
<p>Are you sure you want to delete the following API key?</p>
<p className="font-semibold">{name || id}</p>
</div>
{removalError && <Alert $variant="error">{removalError}</Alert>}
<div className="flex gap-4 justify-center mt-4">
<Button $primary onClick={abortRemoval}>
Cancel
</Button>
<Button onClick={confirmRemoval}>Delete</Button>
</div>
</Modal>
)}
{editInitiated && (
<Modal onClose={closeEditModal} className="flex flex-col gap-4 text-center sm:px-8 sm:py-6">
<h4>Covered skylinks</h4>
{skylinks?.length > 0 ? (
<ul className="text-xs flex flex-col gap-2">
{skylinks.map((skylink) => (
<li key={skylink} className="grid grid-cols-[1fr_min-content] w-full gap-4 items-center">
<code className="whitespace-nowrap select-all truncate bg-palette-100 odd:bg-white p-1">
{skylink}
</code>
<button
className="p-1 transition-colors hover:text-error"
onClick={() => removeSkylink(skylink)}
aria-label="Remove skylink"
>
<TrashIcon size={16} />
</button>
</li>
))}
</ul>
) : (
<Alert $variant="info">No skylinks here yet. You can add the first one below 🙃</Alert>
)}
<div className="flex flex-col gap-4">
{error && <Alert $variant="error">{error}</Alert>}
<AddSkylinkToAPIKeyForm addSkylink={addSkylink} />
</div>
<div className="flex gap-4 justify-center mt-4">
<Button onClick={closeEditModal}>Close</Button>
</div>
</Modal>
)}
</li>
);
};

View File

@ -0,0 +1,14 @@
import { APIKey } from "./APIKey";
export const APIKeyList = ({ keys, reloadKeys, title }) => {
return (
<>
<h6 className="text-palette-300 mb-4">{title}</h6>
<ul className="mt-4">
{keys.map((key) => (
<APIKey key={key.id} apiKey={key} onEdited={reloadKeys} onRemoved={reloadKeys} />
))}
</ul>
</>
);
};

View File

@ -0,0 +1 @@
export * from "./APIKeyList";

View File

@ -0,0 +1,43 @@
import { useCallback, useState } from "react";
import accountsService from "../../services/accountsService";
export const useAPIKeyEdit = ({ key, onSkylinkListUpdate, onSkylinkListUpdateFailure }) => {
const [editInitiated, setEditInitiated] = useState(false);
const prompt = () => setEditInitiated(true);
const abort = () => setEditInitiated(false);
const updateSkylinkList = useCallback(
async (action, skylink) => {
try {
await accountsService.patch(`user/apikeys/${key.id}`, {
json: {
[action]: [skylink],
},
});
onSkylinkListUpdate();
return true;
} catch (err) {
if (err.response) {
const { message } = await err.response.json();
onSkylinkListUpdateFailure(message);
} else {
onSkylinkListUpdateFailure("Unknown error occured, please try again.");
}
return false;
}
},
[onSkylinkListUpdate, onSkylinkListUpdateFailure, key]
);
const addSkylink = (skylink) => updateSkylinkList("add", skylink);
const removeSkylink = (skylink) => updateSkylinkList("remove", skylink);
return {
editInitiated,
prompt,
abort,
addSkylink,
removeSkylink,
};
};

View File

@ -0,0 +1,41 @@
import { useCallback, useState } from "react";
import accountsService from "../../services/accountsService";
export const useAPIKeyRemoval = ({ key, onSuccess }) => {
const [removalInitiated, setRemovalInitiated] = useState(false);
const [removalError, setRemovalError] = useState(null);
const prompt = () => {
setRemovalError(null);
setRemovalInitiated(true);
};
const abort = () => setRemovalInitiated(false);
const confirm = useCallback(async () => {
setRemovalError(null);
try {
await accountsService.delete(`user/apikeys/${key.id}`);
setRemovalInitiated(false);
onSuccess();
} catch (err) {
let message = "There was an error processing your request. Please try again later.";
if (err.response) {
const response = await err.response.json();
if (response.message) {
message = response.message;
}
}
setRemovalError(message);
}
}, [onSuccess, key]);
return {
removalInitiated,
removalError,
prompt,
abort,
confirm,
};
};

View File

@ -0,0 +1,10 @@
import styled from "styled-components";
import cn from "classnames";
export const Alert = styled.div.attrs(({ $variant }) => ({
className: cn("px-3 py-2 sm:px-6 sm:py-4 rounded border", {
"bg-blue-100 border-blue-200 text-palette-400": $variant === "info",
"bg-red-100 border-red-200 text-error": $variant === "error",
"bg-green-100 border-green-200 text-palette-400": $variant === "success",
}),
}))``;

View File

@ -0,0 +1 @@
export * from "./Alert";

View File

@ -0,0 +1,35 @@
import { useEffect, useState } from "react";
import { useUser } from "../../contexts/user";
import { SimpleUploadIcon } from "../Icons";
const AVATAR_PLACEHOLDER = "/images/avatar-placeholder.svg";
export const AvatarUploader = (props) => {
const { user } = useUser();
const [imageUrl, setImageUrl] = useState(AVATAR_PLACEHOLDER);
useEffect(() => {
setImageUrl(user.avatarUrl ?? AVATAR_PLACEHOLDER);
}, [user]);
return (
<div {...props}>
<div
className={`flex justify-center items-center xl:w-[245px] xl:h-[245px] bg-contain bg-none xl:bg-[url(/images/avatar-bg.svg)]`}
>
<img src={imageUrl} className="w-[160px]" alt="" />
</div>
<div className="flex justify-center">
<button
className="flex items-center gap-4 hover:underline decoration-1 decoration-dashed underline-offset-2 decoration-gray-400"
type="button"
onClick={console.info.bind(console)}
>
<SimpleUploadIcon size={20} className="shrink-0" /> Upload profile picture
</button>
{/* TODO: actual uploading */}
</div>
</div>
);
};

View File

@ -0,0 +1 @@
export * from "./AvatarUploader";

View File

@ -0,0 +1,37 @@
import cn from "classnames";
import PropTypes from "prop-types";
import styled from "styled-components";
/**
* Primary UI component for user interaction
*/
export const Button = styled.button.attrs(({ disabled, $primary, type }) => ({
type,
className: cn("px-6 py-2.5 rounded-full font-sans uppercase text-xs tracking-wide transition-[opacity_filter]", {
"bg-primary text-palette-600": $primary,
"bg-white border-2 border-black text-palette-600": !$primary,
"cursor-not-allowed opacity-60": disabled,
"hover:brightness-90": !disabled,
}),
}))``;
Button.propTypes = {
/**
* Is this the principal call to action on the page?
*/
$primary: PropTypes.bool,
/**
* Prevent interaction on the button
*/
disabled: PropTypes.bool,
/**
* Type of button (button / submit)
*/
type: PropTypes.oneOf(["button", "submit"]),
};
Button.defaultProps = {
$primary: false,
disabled: false,
type: "button",
};

View File

@ -0,0 +1,38 @@
import { Button } from "./Button";
// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
export default {
title: "SkynetLibrary/Button",
component: Button,
// More on argTypes: https://storybook.js.org/docs/react/api/argtypes
argTypes: {
backgroundColor: { control: "color" },
},
};
// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
const Template = (args) => <Button {...args} />;
export const Primary = Template.bind({});
// More on args: https://storybook.js.org/docs/react/writing-stories/args
Primary.args = {
primary: true,
label: "Button",
};
export const Secondary = Template.bind({});
Secondary.args = {
label: "Button",
};
export const Large = Template.bind({});
Large.args = {
size: "large",
label: "Button",
};
export const Small = Template.bind({});
Small.args = {
size: "small",
label: "Button",
};

View File

@ -0,0 +1 @@
export * from "./Button";

View File

@ -0,0 +1,50 @@
import { useCallback, useRef, useState } from "react";
import copy from "copy-text-to-clipboard";
import styled from "styled-components";
import { useClickAway } from "react-use";
import { CopyIcon } from "./Icons";
const Button = styled.button.attrs({
className: "relative inline-flex items-center hover:text-primary",
})``;
const TooltipContainer = styled.div.attrs(({ $visible }) => ({
className: `absolute left-full top-1/2 z-10
bg-white rounded border border-primary/30 shadow-md
pointer-events-none transition-opacity duration-150 ease-in-out
${$visible ? "opacity-100" : "opacity-0"}`,
}))`
transform: translateY(-50%);
`;
const TooltipContent = styled.div.attrs({
className: "bg-primary-light/10 text-palette-600 py-2 px-4 ",
})``;
export const CopyButton = ({ value, className, ariaLabel = "Copy" }) => {
const containerRef = useRef();
const [copied, setCopied] = useState(false);
const [timer, setTimer] = useState(null);
const handleCopy = useCallback(() => {
clearTimeout(timer);
copy(value);
setCopied(true);
setTimer(setTimeout(() => setCopied(false), 1500));
}, [value, timer]);
useClickAway(containerRef, () => setCopied(false));
return (
<div ref={containerRef} className={`inline-flex relative overflow-visible pr-2 ${className ?? ""}`}>
<Button onClick={handleCopy} className={copied ? "text-primary" : ""} aria-label={ariaLabel}>
<CopyIcon size={16} />
</Button>
<TooltipContainer $visible={copied}>
<TooltipContent>Copied to clipboard</TooltipContent>
</TooltipContainer>
</div>
);
};

View File

@ -0,0 +1,48 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { useUser } from "../../contexts/user";
import useActivePlan from "../../hooks/useActivePlan";
import { ContainerLoadingIndicator } from "../LoadingIndicator";
import LatestPayment from "./LatestPayment";
import SuggestedPlan from "./SuggestedPlan";
dayjs.extend(relativeTime);
const CurrentPlan = () => {
const { user, error: userError } = useUser();
const { plans, activePlan, error: plansError } = useActivePlan(user);
if (!user || !activePlan) {
return <ContainerLoadingIndicator />;
}
if (userError || plansError) {
return (
<div className="flex text-palette-300 flex-col space-y-4 h-full justify-center items-center">
<p>An error occurred while loading this data.</p>
<p>We'll retry automatically.</p>
</div>
);
}
return (
<div>
<h4>{activePlan.name}</h4>
<div className="text-palette-400">
{activePlan.price === 0 && <p>100GB without paying a dime! 🎉</p>}
{activePlan.price !== 0 &&
(user.subscriptionCancelAtPeriodEnd ? (
<p>Your subscription expires {dayjs(user.subscribedUntil).fromNow()}</p>
) : (
<p className="first-letter:uppercase">{dayjs(user.subscribedUntil).fromNow(true)} until the next payment</p>
))}
<LatestPayment user={user} />
<SuggestedPlan plans={plans} activePlan={activePlan} />
</div>
</div>
);
};
export default CurrentPlan;

View File

@ -0,0 +1,18 @@
import dayjs from "dayjs";
// TODO: this is not an accurate information, we need this data from the backend
const LatestPayment = ({ user }) => (
<div className="flex mt-6 justify-between items-center bg-palette-100/50 py-4 px-6 border-l-2 border-primary">
<div className="flex flex-col lg:flex-row">
<span>Latest payment</span>
<span className="lg:before:content-['-'] lg:before:px-2 text-xs lg:text-base">
{dayjs(user.subscribedUntil).subtract(1, "month").format("MM/DD/YYYY")}
</span>
</div>
<div>
<span className="rounded py-1 px-2 bg-primary/10 font-sans text-primary uppercase text-xs">Success</span>
</div>
</div>
);
export default LatestPayment;

View File

@ -0,0 +1,24 @@
import { Link } from "gatsby";
import { useMemo } from "react";
import { Button } from "../Button";
const SuggestedPlan = ({ plans, activePlan }) => {
const nextPlan = useMemo(() => plans.find(({ tier }) => tier > activePlan.tier), [plans, activePlan]);
if (!nextPlan) {
return null;
}
return (
<div className="mt-7">
<p className="font-sans font-semibold text-xs uppercase text-primary">Discover {nextPlan.name}</p>
<p className="pt-1 text-xs sm:text-base">{nextPlan.description}</p>
<Button $primary as={Link} to={`/upgrade?selectedPlan=${nextPlan.id}`} className="mt-6">
Upgrade
</Button>
</div>
);
};
export default SuggestedPlan;

Some files were not shown because too many files have changed in this diff Show More