diff --git a/docker-compose.yml b/docker-compose.yml index 9c0d59d5..af9b53a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -64,8 +64,6 @@ services: logging: *default-logging env_file: - .env - environment: - - SKYD_DISK_CACHE_ENABLED=${SKYD_DISK_CACHE_ENABLED:-true} volumes: - ./docker/data/nginx/cache:/data/nginx/cache - ./docker/data/nginx/blocker:/data/nginx/blocker diff --git a/docker/nginx/conf.d/include/cors-headers b/docker/nginx/conf.d/include/cors-headers index 0f0bb328..f6a303cd 100644 --- a/docker/nginx/conf.d/include/cors-headers +++ b/docker/nginx/conf.d/include/cors-headers @@ -2,4 +2,4 @@ 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,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'; +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'; diff --git a/docker/nginx/conf.d/include/location-hns b/docker/nginx/conf.d/include/location-hns index f41ac303..0ddb62d7 100644 --- a/docker/nginx/conf.d/include/location-hns +++ b/docker/nginx/conf.d/include/location-hns @@ -1,10 +1,8 @@ -include /etc/nginx/conf.d/include/proxy-buffer; 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 base64 skylink -set $skylink_base32 ''; # placeholder for the base32 skylink +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 { @@ -75,16 +73,10 @@ rewrite_by_lua_block { if ngx.var.path == "/" and skylink_rest ~= nil and skylink_rest ~= "" and skylink_rest ~= "/" then ngx.var.path = skylink_rest end - - -- assign base32 skylink to be used in proxy_pass - ngx.var.skylink_base32 = require("skynet.skylink").base32(ngx.var.skylink) } -# host header has to be adjusted to properly match server name -proxy_set_header Host $skylink_base32.$skynet_portal_domain; - -# pass the skylink request to subdomain skylink server -proxy_pass $scheme://$server_addr$path$is_args$args; +# 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 { diff --git a/docker/nginx/conf.d/include/location-skylink b/docker/nginx/conf.d/include/location-skylink index c613fe29..995a6e2d 100644 --- a/docker/nginx/conf.d/include/location-skylink +++ b/docker/nginx/conf.d/include/location-skylink @@ -1,70 +1,16 @@ include /etc/nginx/conf.d/include/cors; -include /etc/nginx/conf.d/include/proxy-buffer; -include /etc/nginx/conf.d/include/proxy-cache-downloads; 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").base64(ngx.var.skylink) } - -# $skylink_v1 and $skylink_v2 variables default to the same value but in case the requested skylink was: -# a) skylink v1 - it would not matter, no additional logic is executed -# b) skylink v2 - in a lua block below we will resolve the skylink v2 into skylink v1 and update -# $skylink_v1 variable so then the proxy request to skyd can be cached in nginx (proxy_cache_key -# in proxy-cache-downloads includes $skylink_v1 as a part of the cache key) -set $skylink_v1 $skylink; -set $skylink_v2 $skylink; - -# variable for Skynet-Proof header that we need to inject -# into a response if the request was for skylink v2 -set $skynet_proof ''; +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 { - -- the block below only makes sense if we are using nginx cache - if not ngx.var.skyd_disk_cache_enabled then - local httpc = require("resty.http").new() - - -- detect whether requested skylink is v2 - local isBase32v2 = string.len(ngx.var.skylink) == 55 and string.sub(ngx.var.skylink, 0, 2) == "04" - local isBase64v2 = string.len(ngx.var.skylink) == 46 and string.sub(ngx.var.skylink, 0, 2) == "AQ" - - if isBase32v2 or isBase64v2 then - -- 10.10.10.10 points to sia service (alias not available when using resty-http) - local res, err = httpc:request_uri("http://10.10.10.10:9980/skynet/resolve/" .. ngx.var.skylink_v2, { - headers = { ["User-Agent"] = "Sia-Agent" } - }) - - -- print error and exit with 500 or exit with response if status is not 200 - if err or (res and res.status ~= ngx.HTTP_OK) then - ngx.status = (err and ngx.HTTP_INTERNAL_SERVER_ERROR) or res.status - ngx.header["content-type"] = "text/plain" - ngx.say(err or res.body) - return ngx.exit(ngx.status) - end - - local json = require('cjson') - local resolve = json.decode(res.body) - ngx.var.skylink_v1 = resolve.skylink - ngx.var.skynet_proof = res.headers["Skynet-Proof"] - end - - -- check if skylink v1 is present on blocklist (compare hashes) - if require("skynet.blocklist").is_blocked(ngx.var.skylink_v1) then - return require("skynet.blocklist").exit_illegal() - end - - -- if skylink is found on nocache list then set internal nocache variable - -- to tell nginx that it should not try and cache this file (too large) - if ngx.shared.nocache:get(ngx.var.skylink_v1) then - ngx.var.nocache = "1" - end - end - if require("skynet.account").accounts_enabled() then -- check if portal is in authenticated only mode if require("skynet.account").is_access_unauthorized() then @@ -84,36 +30,10 @@ access_by_lua_block { end } -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 - - -- the block below only makes sense if we are using nginx cache - if not ngx.var.skyd_disk_cache_enabled then - -- not empty skynet_proof means this is a skylink v2 request - -- so we should replace the Skynet-Proof header with the one - -- we got from /skynet/resolve/ endpoint, otherwise we would - -- be serving cached empty v1 skylink Skynet-Proof header - if ngx.var.skynet_proof and ngx.var.skynet_proof ~= "" then - ngx.header["Skynet-Proof"] = ngx.var.skynet_proof - end - - -- add skylink to nocache list if it exceeds 1GB (1e+9 bytes) threshold - -- (content length can be nil for already cached files - we can ignore them) - if ngx.header["Content-Length"] and tonumber(ngx.header["Content-Length"]) > 1e+9 then - ngx.shared.nocache:set(ngx.var.skylink_v1, ngx.header["Content-Length"]) - end - end -} - limit_rate_after 512k; limit_rate $limit_rate; proxy_read_timeout 600; proxy_set_header User-Agent: Sia-Agent; -# in case the requested skylink was v2 and we already resolved it to skylink v1, we are going to pass resolved -# skylink v1 to skyd to save that extra skylink v2 lookup in skyd but in turn, in case skyd returns a redirect -# we need to rewrite the skylink v1 to skylink v2 in the location header with proxy_redirect -proxy_redirect $skylink_v1 $skylink_v2; -proxy_pass http://sia:9980/skynet/skylink/$skylink_v1$path$is_args$args; +proxy_pass http://sia:9980/skynet/skylink/$skylink$path$is_args$args; diff --git a/docker/nginx/conf.d/include/proxy-buffer b/docker/nginx/conf.d/include/proxy-buffer deleted file mode 100644 index 0fafbade..00000000 --- a/docker/nginx/conf.d/include/proxy-buffer +++ /dev/null @@ -1,5 +0,0 @@ -# if you are expecting large headers (ie. Skynet-Skyfile-Metadata), tune these values to your needs -# read more: https://www.getpagespeed.com/server-setup/nginx/tuning-proxy_buffer_size-in-nginx -proxy_buffer_size 4096k; -proxy_buffers 64 256k; -proxy_busy_buffers_size 4096k; # at least as high as proxy_buffer_size diff --git a/docker/nginx/conf.d/include/proxy-cache-downloads b/docker/nginx/conf.d/include/proxy-cache-downloads deleted file mode 100644 index 85aeeb9e..00000000 --- a/docker/nginx/conf.d/include/proxy-cache-downloads +++ /dev/null @@ -1,21 +0,0 @@ -proxy_cache skynet; # cache name -proxy_cache_key $skylink_v1$path$arg_format$arg_attachment$arg_start$arg_end$http_range; # unique cache key -proxy_cache_min_uses 3; # cache after 3 uses -proxy_cache_valid 200 206 307 308 48h; # keep 200, 206, 307 and 308 responses valid for up to 2 days -add_header X-Proxy-Cache $upstream_cache_status; # add response header to indicate cache hits and misses - -# map skyd env variable value to "1" for true and "0" for false (expected by proxy_no_cache) -set_by_lua_block $skyd_disk_cache_enabled { - return os.getenv("SKYD_DISK_CACHE_ENABLED") == "true" and "1" or "0" -} - -# bypass - this will bypass cache hit on request (status BYPASS) -# but still stores file in cache if cache conditions are met -proxy_cache_bypass $cookie_nocache $arg_nocache $skyd_disk_cache_enabled; - -# no cache - this will ignore cache on request (status MISS) -# and does not store file in cache under no condition -set_if_empty $nocache "0"; - -# disable cache when nocache is set or skyd cache is enabled -proxy_no_cache $nocache $skyd_disk_cache_enabled; diff --git a/docker/nginx/conf.d/server.local.conf b/docker/nginx/conf.d/server.local.conf deleted file mode 100644 index 8a487a53..00000000 --- a/docker/nginx/conf.d/server.local.conf +++ /dev/null @@ -1,9 +0,0 @@ -server { - # local server - do not expose this port externally - listen 8000; - - # secure traffic by limiting to only local networks - include /etc/nginx/conf.d/include/local-network-only; - - include /etc/nginx/conf.d/server/server.local; -} diff --git a/docker/nginx/conf.d/server/server.api b/docker/nginx/conf.d/server/server.api index 4f8f2512..7f2e714a 100644 --- a/docker/nginx/conf.d/server/server.api +++ b/docker/nginx/conf.d/server/server.api @@ -47,7 +47,9 @@ location /skynet/portal/blocklist { proxy_cache skynet; proxy_cache_valid 200 204 15m; # cache portal blocklist for 15 minutes - proxy_pass http://blocker:4000/blocklist; + + # 10.10.10.110 points to blocker service + proxy_pass http://10.10.10.110:4000/blocklist; } location /skynet/portals { @@ -123,14 +125,15 @@ location /abuse/report { location /hns { include /etc/nginx/conf.d/include/cors; - rewrite_by_lua_block { - local hns_domain = string.match(ngx.var.uri, "/hns/([^/?]+)") - local path = string.match(ngx.var.uri, "/hns/[^/?]+(.*)") - local args = ngx.var.args and ngx.var.is_args .. ngx.var.args or "" - local hns_subdomain_url = ngx.var.scheme .. "://" .. hns_domain .. ".hns." .. ngx.var.skynet_portal_domain .. path .. args + # 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/[^/?]+(.*)") } - return ngx.redirect(hns_subdomain_url, ngx.HTTP_MOVED_PERMANENTLY) - } + proxy_set_header Host $host; + include /etc/nginx/conf.d/include/location-hns; } location /hnsres { @@ -176,13 +179,13 @@ location /skynet/registry/subscription { local httpc = require("resty.http").new() -- fetch account limits and set download bandwidth and registry delays accordingly - local res, err = httpc:request_uri("http://10.10.10.70:3000/user/limits", { + local res, err = httpc:request_uri("http://10.10.10.70:3000/user/limits?unit=byte", { headers = { ["Cookie"] = "skynet-jwt=" .. ngx.var.skynet_jwt } }) -- fail gracefully in case /user/limits failed if err or (res and res.status ~= ngx.HTTP_OK) then - ngx.log(ngx.ERR, "Failed accounts service request /user/limits: ", err or ("[HTTP " .. res.status .. "] " .. res.body)) + ngx.log(ngx.ERR, "Failed accounts service request /user/limits?unit=byte: ", err or ("[HTTP " .. res.status .. "] " .. res.body)) elseif res and res.status == ngx.HTTP_OK then local json = require('cjson') local limits = json.decode(res.body) @@ -266,10 +269,10 @@ location /skynet/tus { 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 upload size limits ngx.req.set_header("SkynetMaxUploadSize", limits.maxUploadSize) end @@ -337,14 +340,7 @@ location ~ "^/(([a-zA-Z0-9-_]{46}|[a-z0-9]{55})(/.*)?)$" { set $skylink $2; set $path $3; - rewrite_by_lua_block { - local skynet_skylink = require("skynet.skylink") - local base32_skylink = skynet_skylink.base32(ngx.var.skylink) - local args = ngx.var.args and ngx.var.is_args .. ngx.var.args or "" - local base32_url = ngx.var.scheme .. "://" .. base32_skylink .. "." .. ngx.var.skynet_portal_domain .. ngx.var.path .. args - - return ngx.redirect(base32_url, ngx.HTTP_MOVED_PERMANENTLY) - } + include /etc/nginx/conf.d/include/location-skylink; } location ~ "^/file/(([a-zA-Z0-9-_]{46}|[a-z0-9]{55})(/.*)?)$" { @@ -358,7 +354,6 @@ location ~ "^/file/(([a-zA-Z0-9-_]{46}|[a-z0-9]{55})(/.*)?)$" { location /skynet/trustless/basesector { include /etc/nginx/conf.d/include/cors; - include /etc/nginx/conf.d/include/proxy-buffer; include /etc/nginx/conf.d/include/track-download; limit_conn downloads_by_ip 100; # ddos protection: max 100 downloads at a time diff --git a/docker/nginx/conf.d/server/server.dnslink b/docker/nginx/conf.d/server/server.dnslink index 22ce75a3..cf385a1d 100644 --- a/docker/nginx/conf.d/server/server.dnslink +++ b/docker/nginx/conf.d/server/server.dnslink @@ -37,9 +37,7 @@ location / { ngx.var.skylink = cache_value end - ngx.var.skylink = require("skynet.skylink").base64(ngx.var.skylink) - ngx.var.skylink_v1 = ngx.var.skylink - ngx.var.skylink_v2 = ngx.var.skylink + ngx.var.skylink = require("skynet.skylink").parse(ngx.var.skylink) } include /etc/nginx/conf.d/include/location-skylink; diff --git a/docker/nginx/conf.d/server/server.local b/docker/nginx/conf.d/server/server.local deleted file mode 100644 index 3a8ac118..00000000 --- a/docker/nginx/conf.d/server/server.local +++ /dev/null @@ -1,37 +0,0 @@ -include /etc/nginx/conf.d/include/init-optional-variables; - -location /skynet/blocklist { - client_max_body_size 10m; # increase max body size to account for large lists - client_body_buffer_size 10m; # force whole body to memory so we can read it - - content_by_lua_block { - local httpc = require("resty.http").new() - - ngx.req.read_body() -- ensure the post body data is read before using get_body_data - - -- proxy blocklist update request - -- 10.10.10.10 points to sia service (alias not available when using resty-http) - local res, err = httpc:request_uri("http://10.10.10.10:9980/skynet/blocklist", { - method = "POST", - body = ngx.req.get_body_data(), - headers = { - ["Content-Type"] = "application/x-www-form-urlencoded", - ["Authorization"] = require("skynet.utils").authorization_header(), - ["User-Agent"] = "Sia-Agent", - } - }) - - -- print error and exit with 500 or exit with response if status is not 204 - if err or (res and res.status ~= ngx.HTTP_NO_CONTENT) then - ngx.status = (err and ngx.HTTP_INTERNAL_SERVER_ERROR) or res.status - ngx.header["content-type"] = "text/plain" - ngx.say(err or res.body) - return ngx.exit(ngx.status) - end - - require("skynet.blocklist").reload() - - ngx.status = ngx.HTTP_NO_CONTENT - return ngx.exit(ngx.status) - } -} diff --git a/docker/nginx/libs/skynet/account.lua b/docker/nginx/libs/skynet/account.lua index e7ff9033..7f000d6e 100644 --- a/docker/nginx/libs/skynet/account.lua +++ b/docker/nginx/libs/skynet/account.lua @@ -43,15 +43,15 @@ function _M.get_account_limits() 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", { + local res, err = httpc:request_uri("http://10.10.10.70:3000/user/limits?unit=byte", { headers = { ["Cookie"] = "skynet-jwt=" .. ngx.var.skynet_jwt } }) - + -- fail gracefully in case /user/limits failed if err or (res and res.status ~= ngx.HTTP_OK) then - ngx.log(ngx.ERR, "Failed accounts service request /user/limits: ", err or ("[HTTP " .. res.status .. "] " .. res.body)) + ngx.log(ngx.ERR, "Failed accounts service request /user/limits?unit=byte: ", err or ("[HTTP " .. res.status .. "] " .. res.body)) ngx.var.account_limits = cjson.encode(anon_limits) elseif res and res.status == ngx.HTTP_OK then ngx.var.account_limits = res.body diff --git a/docker/nginx/libs/skynet/blocklist.lua b/docker/nginx/libs/skynet/blocklist.lua deleted file mode 100644 index 29f53032..00000000 --- a/docker/nginx/libs/skynet/blocklist.lua +++ /dev/null @@ -1,66 +0,0 @@ -local _M = {} - -function _M.reload() - local httpc = require("resty.http").new() - - -- fetch blocklist records (all blocked skylink hashes) - -- 10.10.10.10 points to sia service (alias not available when using resty-http) - local res, err = httpc:request_uri("http://10.10.10.10:9980/skynet/blocklist", { - headers = { - ["User-Agent"] = "Sia-Agent", - } - }) - - -- fail whole request in case this request failed, we want to make sure - -- the blocklist is pre cached before serving first skylink - if err or (res and res.status ~= ngx.HTTP_OK) then - ngx.log(ngx.ERR, "Failed skyd service request /skynet/blocklist: ", err or ("[HTTP " .. res.status .. "] " .. res.body)) - ngx.status = (err and ngx.HTTP_INTERNAL_SERVER_ERROR) or res.status - ngx.header["content-type"] = "text/plain" - ngx.say(err or res.body) - return ngx.exit(ngx.status) - elseif res and res.status == ngx.HTTP_OK then - local json = require('cjson') - local data = json.decode(res.body) - - -- mark all existing entries as expired - ngx.shared.blocklist:flush_all() - - -- check if blocklist is table (it is null when empty) - if type(data.blocklist) == "table" then - -- set all cache entries one by one (resets expiration) - for i, hash in ipairs(data.blocklist) do - ngx.shared.blocklist:set(hash, true) - end - end - - -- ensure that init flag is persisted - ngx.shared.blocklist:set("__init", true) - - -- remove all leftover expired entries - ngx.shared.blocklist:flush_expired() - end -end - -function _M.is_blocked(skylink) - -- make sure that blocklist has been preloaded - if not ngx.shared.blocklist:get("__init") then _M.reload() end - - -- hash skylink before comparing it with blocklist - local hash = require("skynet.skylink").hash(skylink) - - -- we need to use get_stale because we are expiring previous - -- entries when the blocklist is reloading and we still want - -- to block them until the reloading is finished - return ngx.shared.blocklist:get_stale(hash) == true -end - --- exit with 416 illegal content status code -function _M.exit_illegal() - ngx.status = ngx.HTTP_ILLEGAL - ngx.header["content-type"] = "text/plain" - ngx.say("Unavailable For Legal Reasons") - return ngx.exit(ngx.status) -end - -return _M diff --git a/docker/nginx/libs/skynet/skylink.lua b/docker/nginx/libs/skynet/skylink.lua index c6372a41..adcf0b70 100644 --- a/docker/nginx/libs/skynet/skylink.lua +++ b/docker/nginx/libs/skynet/skylink.lua @@ -3,13 +3,10 @@ local _M = {} local basexx = require("basexx") local hasher = require("hasher") --- use lowercase alphabet since our skylinks are part of urls -local base32_alphabet = "0123456789abcdefghijklmnopqrstuv" - -- parse any skylink and return base64 version -function _M.base64(skylink) +function _M.parse(skylink) if string.len(skylink) == 55 then - local decoded = basexx.from_basexx(string.lower(skylink), base32_alphabet, 5) + local decoded = basexx.from_basexx(string.upper(skylink), "0123456789ABCDEFGHIJKLMNOPQRSTUV", 5) return basexx.to_url64(decoded) end @@ -17,21 +14,10 @@ function _M.base64(skylink) return skylink end --- parse any skylink and return base32 version -function _M.base32(skylink) - if string.len(skylink) == 46 then - local decoded = basexx.from_url64(skylink) - - return basexx.to_basexx(decoded, base32_alphabet, 5) - 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.base64(skylink) + local base64Skylink = _M.parse(skylink) -- decode skylink from base64 encoding local rawSkylink = basexx.from_url64(base64Skylink) diff --git a/docker/nginx/libs/skynet/skylink.spec.lua b/docker/nginx/libs/skynet/skylink.spec.lua index 9949e534..0502a833 100644 --- a/docker/nginx/libs/skynet/skylink.spec.lua +++ b/docker/nginx/libs/skynet/skylink.spec.lua @@ -1,28 +1,15 @@ local skynet_skylink = require("skynet.skylink") -describe("base64", function() +describe("parse", function() local base32 = "0404dsjvti046fsua4ktor9grrpe76erq9jot9cvopbhsvsu76r4r30" local base64 = "AQBG8n_sgEM_nlEp3G0w3vLjmdvSZ46ln8ZXHn-eObZNjA" it("should return unchanged base64 skylink", function() - assert.is.same(skynet_skylink.base64(base64), base64) + assert.is.same(skynet_skylink.parse(base64), base64) end) it("should transform base32 skylink into base64", function() - assert.is.same(skynet_skylink.base64(base32), base64) - end) -end) - -describe("base32", function() - local base32 = "0404dsjvti046fsua4ktor9grrpe76erq9jot9cvopbhsvsu76r4r30" - local base64 = "AQBG8n_sgEM_nlEp3G0w3vLjmdvSZ46ln8ZXHn-eObZNjA" - - it("should return unchanged base32 skylink", function() - assert.is.same(skynet_skylink.base32(base32), base32) - end) - - it("should transform base64 skylink into base32", function() - assert.is.same(skynet_skylink.base32(base64), base32) + assert.is.same(skynet_skylink.parse(base32), base64) end) end) diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index 7f43d192..95bb61ac 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -31,7 +31,6 @@ env SERVER_DOMAIN; env PORTAL_MODULES; env ACCOUNTS_LIMIT_ACCESS; env SIA_API_PASSWORD; -env SKYD_DISK_CACHE_ENABLED; events { worker_connections 8192; @@ -75,20 +74,10 @@ http { # 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; - # create a shared blocklist dictionary with size of 30 megabytes - # estimated capacity of 1 megabyte dictionary is 3500 blocklist entries - # that gives us capacity of around 100k entries in 30 megabyte dictionary - lua_shared_dict blocklist 30m; - - # create a shared dictionary to fill with skylinks that should not - # be cached due to the large size or some other reasons - lua_shared_dict nocache 10m; - # this runs before forking out nginx worker processes init_by_lua_block { require "cjson" require "resty.http" - require "skynet.blocklist" require "skynet.skylink" require "skynet.utils" } diff --git a/packages/dashboard-v2/src/components/AvatarUploader/AvatarUploader.js b/packages/dashboard-v2/src/components/AvatarUploader/AvatarUploader.js new file mode 100644 index 00000000..9f5bbc82 --- /dev/null +++ b/packages/dashboard-v2/src/components/AvatarUploader/AvatarUploader.js @@ -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 ( +
+
+ +
+
+ + {/* TODO: actual uploading */} +
+
+ ); +}; diff --git a/packages/dashboard-v2/src/components/AvatarUploader/index.js b/packages/dashboard-v2/src/components/AvatarUploader/index.js new file mode 100644 index 00000000..74358cdc --- /dev/null +++ b/packages/dashboard-v2/src/components/AvatarUploader/index.js @@ -0,0 +1 @@ +export * from "./AvatarUploader"; diff --git a/packages/dashboard-v2/src/components/Button/Button.js b/packages/dashboard-v2/src/components/Button/Button.js index 95ecbede..40ec1ad9 100644 --- a/packages/dashboard-v2/src/components/Button/Button.js +++ b/packages/dashboard-v2/src/components/Button/Button.js @@ -6,7 +6,7 @@ import styled from "styled-components"; */ export const Button = styled.button.attrs(({ $primary }) => ({ type: "button", - className: `px-6 py-3 rounded-full font-sans uppercase text-xs tracking-wide text-palette-600 transition-[filter] hover:brightness-90 + className: `px-6 py-2.5 rounded-full font-sans uppercase text-xs tracking-wide text-palette-600 transition-[filter] hover:brightness-90 ${$primary ? "bg-primary" : "bg-white border-2 border-black"}`, }))``; Button.propTypes = { diff --git a/packages/dashboard-v2/src/components/CopyButton.js b/packages/dashboard-v2/src/components/CopyButton.js new file mode 100644 index 00000000..479352d2 --- /dev/null +++ b/packages/dashboard-v2/src/components/CopyButton.js @@ -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 }) => { + 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 ( +
+ + + Copied to clipboard + +
+ ); +}; diff --git a/packages/dashboard-v2/src/components/CurrentPlan/CurrentPlan.js b/packages/dashboard-v2/src/components/CurrentPlan/CurrentPlan.js index c5cdee36..f8a5cf9e 100644 --- a/packages/dashboard-v2/src/components/CurrentPlan/CurrentPlan.js +++ b/packages/dashboard-v2/src/components/CurrentPlan/CurrentPlan.js @@ -2,7 +2,8 @@ import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import { useUser } from "../../contexts/user"; -import useSubscriptionPlans from "../../hooks/useSubscriptionPlans"; +import useActivePlan from "../../hooks/useActivePlan"; +import { ContainerLoadingIndicator } from "../LoadingIndicator"; import LatestPayment from "./LatestPayment"; import SuggestedPlan from "./SuggestedPlan"; @@ -11,13 +12,10 @@ dayjs.extend(relativeTime); const CurrentPlan = () => { const { user, error: userError } = useUser(); - const { activePlan, plans, error: plansError } = useSubscriptionPlans(user); + const { plans, activePlan, error: plansError } = useActivePlan(user); if (!user || !activePlan) { - return ( - // TODO: a nicer loading indicator -
Loading...
- ); + return ; } if (userError || plansError) { diff --git a/packages/dashboard-v2/src/components/CurrentUsage/CurrentUsage.js b/packages/dashboard-v2/src/components/CurrentUsage/CurrentUsage.js index b467e1ea..44be79ed 100644 --- a/packages/dashboard-v2/src/components/CurrentUsage/CurrentUsage.js +++ b/packages/dashboard-v2/src/components/CurrentUsage/CurrentUsage.js @@ -1,24 +1,50 @@ -import * as React from "react"; +import { useEffect, useMemo, useState } from "react"; import fileSize from "pretty-bytes"; import { Link } from "gatsby"; +import useSWR from "swr"; + +import { useUser } from "../../contexts/user"; +import useActivePlan from "../../hooks/useActivePlan"; +import { ContainerLoadingIndicator } from "../LoadingIndicator"; import { GraphBar } from "./GraphBar"; import { UsageGraph } from "./UsageGraph"; -// TODO: get real data -const useUsageData = () => ({ - files: { - used: 19_521, - limit: 20_000, - }, - storage: { - used: 23_000_000_000, - limit: 1_000_000_000_000, - }, -}); +const useUsageData = () => { + const { user } = useUser(); + const { activePlan, error } = useActivePlan(user); + const { data: stats, error: statsError } = useSWR("user/stats"); + + const [loading, setLoading] = useState(true); + const [usage, setUsage] = useState({}); + + const hasError = error || statsError; + const hasData = activePlan && stats; + + useEffect(() => { + if (hasData || hasError) { + setLoading(false); + } + + if (hasData && !hasError) { + setUsage({ + filesUsed: stats?.numUploads, + filesLimit: activePlan?.limits?.maxNumberUploads, + storageUsed: stats?.totalUploadsSize, + storageLimit: activePlan?.limits?.storageLimit, + }); + } + }, [hasData, hasError, stats, activePlan]); + + return { + error: error || statsError, + loading, + usage, + }; +}; const size = (bytes) => { - const text = fileSize(bytes, { maximumFractionDigits: 1 }); + const text = fileSize(bytes ?? 0, { maximumFractionDigits: 0 }); const [value, unit] = text.split(" "); return { @@ -28,12 +54,26 @@ const size = (bytes) => { }; }; -export default function CurrentUsage() { - const { files, storage } = useUsageData(); +const ErrorMessage = () => ( +
+

We were not able to fetch the current usage data.

+

We'll try again automatically.

+
+); - const storageUsage = size(storage.used); - const storageLimit = size(storage.limit); - const filesUsedLabel = React.useMemo(() => ({ value: files.used, unit: "files" }), [files.used]); +export default function CurrentUsage() { + const { usage, error, loading } = useUsageData(); + const storageUsage = size(usage.storageUsed); + const storageLimit = size(usage.storageLimit); + const filesUsedLabel = useMemo(() => ({ value: usage.filesUsed, unit: "files" }), [usage.filesUsed]); + + if (loading) { + return ; + } + + if (error) { + return ; + } return ( <> @@ -41,7 +81,7 @@ export default function CurrentUsage() { {storageUsage.text} of {storageLimit.text}

- {files.used} of {files.limit} files + {usage.filesUsed} of {usage.filesLimit} files

@@ -49,8 +89,8 @@ export default function CurrentUsage() { {storageLimit.text}
- - + +
Files @@ -62,7 +102,7 @@ export default function CurrentUsage() { UPGRADE {" "} {/* TODO: proper URL */} - {files.limit} + {usage.filesLimit}
diff --git a/packages/dashboard-v2/src/components/DropdownMenu/DropdownMenu.js b/packages/dashboard-v2/src/components/DropdownMenu/DropdownMenu.js index 461a9b7e..86cbad5f 100644 --- a/packages/dashboard-v2/src/components/DropdownMenu/DropdownMenu.js +++ b/packages/dashboard-v2/src/components/DropdownMenu/DropdownMenu.js @@ -30,7 +30,7 @@ const TriggerIcon = styled(ChevronDownIcon).attrs({ `; const Flyout = styled.div.attrs(({ open }) => ({ - className: `absolute top-full right-0 p-0 + className: `absolute top-full right-0 p-0 z-10 border rounded border-palette-100 bg-white shadow-md shadow-palette-200/50 ${open ? "visible" : "invisible"}`, diff --git a/packages/dashboard-v2/src/components/DropdownMenu/DropdownMenuLink.js b/packages/dashboard-v2/src/components/DropdownMenu/DropdownMenuLink.js index 45a86483..426501c4 100644 --- a/packages/dashboard-v2/src/components/DropdownMenu/DropdownMenuLink.js +++ b/packages/dashboard-v2/src/components/DropdownMenu/DropdownMenuLink.js @@ -3,18 +3,16 @@ import PropTypes from "prop-types"; const DropdownLink = styled.a.attrs({ className: `m-0 border-t border-palette-200/50 h-[60px] - whitespace-nowrap transition-colors text-current + whitespace-nowrap transition-colors hover:bg-palette-100/50 flex items-center pr-8 pl-6 py-4 gap-4 first:border-0`, })``; export const DropdownMenuLink = ({ active, icon: Icon, label, ...props }) => ( - <> - - {Icon ? : null} - {label} - - + + {Icon ? : null} + {label} + ); DropdownMenuLink.propTypes = { diff --git a/packages/dashboard-v2/src/components/FileList/FileList.js b/packages/dashboard-v2/src/components/FileList/FileList.js new file mode 100644 index 00000000..6342b970 --- /dev/null +++ b/packages/dashboard-v2/src/components/FileList/FileList.js @@ -0,0 +1,74 @@ +import * as React from "react"; +import useSWR from "swr"; +import { useMedia } from "react-use"; + +import theme from "../../lib/theme"; + +import { ContainerLoadingIndicator } from "../LoadingIndicator"; +import { Select, SelectOption } from "../Select"; +import { Switch } from "../Switch"; +import { TextInputIcon } from "../TextInputIcon/TextInputIcon"; +import { SearchIcon } from "../Icons"; + +import FileTable from "./FileTable"; +import useFormattedFilesData from "./useFormattedFilesData"; + +const FileList = ({ type }) => { + const isMediumScreenOrLarger = useMedia(`(min-width: ${theme.screens.md})`); + const { data, error } = useSWR(`user/${type}?pageSize=10`); + const items = useFormattedFilesData(data?.items || []); + + const setFilter = (name, value) => console.log("filter", name, "set to", value); + + if (!items.length) { + return ( +
+ {/* TODO: proper error message */} + {!data && !error && } + {!data && error &&

An error occurred while loading this data.

} + {data &&

No {type} found.

} +
+ ); + } + + return ( +
+
+ } + onChange={console.log.bind(console)} + /> +
+ setFilter("showSmallFiles", value)} className="mr-8"> + + Show small files + + +
+ File type: + +
+
+ Sort: + +
+
+
+ {/* TODO: mobile view (it's not tabular) */} + {isMediumScreenOrLarger ? : "Mobile view"} +
+ ); +}; + +export default FileList; diff --git a/packages/dashboard-v2/src/components/FileList/FileTable.js b/packages/dashboard-v2/src/components/FileList/FileTable.js new file mode 100644 index 00000000..90c9600f --- /dev/null +++ b/packages/dashboard-v2/src/components/FileList/FileTable.js @@ -0,0 +1,111 @@ +import { CogIcon, ShareIcon } from "../Icons"; +import { PopoverMenu } from "../PopoverMenu/PopoverMenu"; +import { Table, TableBody, TableCell, TableHead, TableHeadCell, TableRow } from "../Table"; +import { CopyButton } from "../CopyButton"; + +const buildShareMenu = (item) => { + return [ + { + label: "Facebook", + callback: () => { + console.info("share to Facebook", item); + }, + }, + { + label: "Twitter", + callback: () => { + console.info("share to Twitter", item); + }, + }, + { + label: "Discord", + callback: () => { + console.info("share to Discord", item); + }, + }, + ]; +}; + +const buildOptionsMenu = (item) => { + return [ + { + label: "Preview", + callback: () => { + console.info("preview", item); + }, + }, + { + label: "Download", + callback: () => { + console.info("download", item); + }, + }, + { + label: "Unpin", + callback: () => { + console.info("unpin", item); + }, + }, + { + label: "Report", + callback: () => { + console.info("report", item); + }, + }, + ]; +}; + +export default function FileTable({ items }) { + return ( + + + + Name + Type + + Size + + Uploaded + Skylink + Activity + + + + {items.map((item) => { + const { id, name, type, size, date, skylink } = item; + + return ( + + {name} + {type} + + {size} + + {date} + +
+ + {skylink} +
+
+ +
+ + + + + + +
+
+
+ ); + })} +
+
+ ); +} diff --git a/packages/dashboard-v2/src/components/FileList/index.js b/packages/dashboard-v2/src/components/FileList/index.js new file mode 100644 index 00000000..93296508 --- /dev/null +++ b/packages/dashboard-v2/src/components/FileList/index.js @@ -0,0 +1 @@ +export * from "./FileList"; diff --git a/packages/dashboard-v2/src/components/FileList/useFormattedFilesData.js b/packages/dashboard-v2/src/components/FileList/useFormattedFilesData.js new file mode 100644 index 00000000..82d95090 --- /dev/null +++ b/packages/dashboard-v2/src/components/FileList/useFormattedFilesData.js @@ -0,0 +1,26 @@ +import { useMemo } from "react"; +import prettyBytes from "pretty-bytes"; +import dayjs from "dayjs"; + +const parseFileName = (fileName) => { + const lastDotIndex = Math.max(0, fileName.lastIndexOf(".")) || Infinity; + + return [fileName.substr(0, lastDotIndex), fileName.substr(lastDotIndex)]; +}; + +const formatItem = ({ size, name: rawFileName, uploadedOn, downloadedOn, ...rest }) => { + const [name, type] = parseFileName(rawFileName); + const date = dayjs(uploadedOn || downloadedOn).format("MM/DD/YYYY; HH:MM"); + + return { + ...rest, + date, + size: prettyBytes(size), + type, + name, + }; +}; + +const useFormattedFilesData = (items) => useMemo(() => items.map(formatItem), [items]); + +export default useFormattedFilesData; diff --git a/packages/dashboard-v2/src/components/Icons/icons/CopyIcon.js b/packages/dashboard-v2/src/components/Icons/icons/CopyIcon.js new file mode 100644 index 00000000..c3ceb9ac --- /dev/null +++ b/packages/dashboard-v2/src/components/Icons/icons/CopyIcon.js @@ -0,0 +1,10 @@ +import { withIconProps } from "../withIconProps"; + +export const CopyIcon = withIconProps(({ size, ...props }) => ( + + + +)); diff --git a/packages/dashboard-v2/src/components/Icons/icons/SearchIcon.js b/packages/dashboard-v2/src/components/Icons/icons/SearchIcon.js new file mode 100644 index 00000000..f551dea6 --- /dev/null +++ b/packages/dashboard-v2/src/components/Icons/icons/SearchIcon.js @@ -0,0 +1,10 @@ +import { withIconProps } from "../withIconProps"; + +export const SearchIcon = withIconProps(({ size, ...props }) => ( + + + +)); diff --git a/packages/dashboard-v2/src/components/Icons/icons/ShareIcon.js b/packages/dashboard-v2/src/components/Icons/icons/ShareIcon.js new file mode 100644 index 00000000..f25afeaf --- /dev/null +++ b/packages/dashboard-v2/src/components/Icons/icons/ShareIcon.js @@ -0,0 +1,18 @@ +import { withIconProps } from "../withIconProps"; + +export const ShareIcon = withIconProps(({ size, ...props }) => ( + + + +)); diff --git a/packages/dashboard-v2/src/components/Icons/icons/SimpleUploadIcon.js b/packages/dashboard-v2/src/components/Icons/icons/SimpleUploadIcon.js new file mode 100644 index 00000000..0406f012 --- /dev/null +++ b/packages/dashboard-v2/src/components/Icons/icons/SimpleUploadIcon.js @@ -0,0 +1,10 @@ +import { withIconProps } from "../withIconProps"; + +export const SimpleUploadIcon = withIconProps(({ size, ...props }) => ( + + + +)); diff --git a/packages/dashboard-v2/src/components/Icons/index.js b/packages/dashboard-v2/src/components/Icons/index.js index 41552e34..611acef4 100644 --- a/packages/dashboard-v2/src/components/Icons/index.js +++ b/packages/dashboard-v2/src/components/Icons/index.js @@ -9,3 +9,7 @@ export * from "./icons/CircledErrorIcon"; export * from "./icons/CircledProgressIcon"; export * from "./icons/CircledArrowUpIcon"; export * from "./icons/PlusIcon"; +export * from "./icons/SearchIcon"; +export * from "./icons/CopyIcon"; +export * from "./icons/ShareIcon"; +export * from "./icons/SimpleUploadIcon"; diff --git a/packages/dashboard-v2/src/components/Icons/withIconProps.js b/packages/dashboard-v2/src/components/Icons/withIconProps.js index d4267318..5da47331 100644 --- a/packages/dashboard-v2/src/components/Icons/withIconProps.js +++ b/packages/dashboard-v2/src/components/Icons/withIconProps.js @@ -4,7 +4,7 @@ const propTypes = { /** * Size of the icon's bounding box. */ - size: PropTypes.number, + size: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), }; const defaultProps = { diff --git a/packages/dashboard-v2/src/components/LatestActivity/ActivityTable.js b/packages/dashboard-v2/src/components/LatestActivity/ActivityTable.js index 647f9bf8..345a2daa 100644 --- a/packages/dashboard-v2/src/components/LatestActivity/ActivityTable.js +++ b/packages/dashboard-v2/src/components/LatestActivity/ActivityTable.js @@ -2,6 +2,7 @@ import * as React from "react"; import useSWR from "swr"; import { Table, TableBody, TableCell, TableRow } from "../Table"; +import { ContainerLoadingIndicator } from "../LoadingIndicator"; import useFormattedActivityData from "./useFormattedActivityData"; @@ -12,10 +13,10 @@ export default function ActivityTable({ type }) { if (!items.length) { return (
- {/* TODO: proper loading indicator / error message */} - {!data && !error &&

Loading...

} + {/* TODO: proper error message */} + {!data && !error && } {!data && error &&

An error occurred while loading this data.

} - {data &&

No files found.

} + {data && !error &&

No files found.

}
); } diff --git a/packages/dashboard-v2/src/components/LatestActivity/LatestActivity.js b/packages/dashboard-v2/src/components/LatestActivity/LatestActivity.js index 9c53554a..87825661 100644 --- a/packages/dashboard-v2/src/components/LatestActivity/LatestActivity.js +++ b/packages/dashboard-v2/src/components/LatestActivity/LatestActivity.js @@ -23,7 +23,7 @@ export default function LatestActivity() { - + diff --git a/packages/dashboard-v2/src/components/LoadingIndicator/ContainerLoadingIndicator.js b/packages/dashboard-v2/src/components/LoadingIndicator/ContainerLoadingIndicator.js new file mode 100644 index 00000000..de86a849 --- /dev/null +++ b/packages/dashboard-v2/src/components/LoadingIndicator/ContainerLoadingIndicator.js @@ -0,0 +1,18 @@ +import styled from "styled-components"; +import { CircledProgressIcon } from "../Icons"; + +/** + * This loading indicator is designed to be replace entire blocks (i.e. components) + * while they are fetching required data. + * + * It will take 50% of the parent's height, but won't get bigger than 150x150 px. + */ +const Wrapper = styled.div.attrs({ + className: "flex w-full h-full justify-center items-center p-8 text-palette-100", +})``; + +export const ContainerLoadingIndicator = (props) => ( + + + +); diff --git a/packages/dashboard-v2/src/components/LoadingIndicator/index.js b/packages/dashboard-v2/src/components/LoadingIndicator/index.js new file mode 100644 index 00000000..df7c2a88 --- /dev/null +++ b/packages/dashboard-v2/src/components/LoadingIndicator/index.js @@ -0,0 +1 @@ +export * from "./ContainerLoadingIndicator"; diff --git a/packages/dashboard-v2/src/components/NavBar/NavBar.js b/packages/dashboard-v2/src/components/NavBar/NavBar.js index 2b53f2be..1db72dda 100644 --- a/packages/dashboard-v2/src/components/NavBar/NavBar.js +++ b/packages/dashboard-v2/src/components/NavBar/NavBar.js @@ -9,7 +9,7 @@ import { PageContainer } from "../PageContainer"; import { NavBarLink, NavBarSection } from "."; const NavBarContainer = styled.div.attrs({ - className: `grid sticky top-0 bg-white`, + className: `grid sticky top-0 bg-white z-10 shadow-sm`, })``; const NavBarBody = styled.nav.attrs({ @@ -68,8 +68,21 @@ export const NavBar = () => ( - - + + diff --git a/packages/dashboard-v2/src/components/PopoverMenu/PopoverMenu.js b/packages/dashboard-v2/src/components/PopoverMenu/PopoverMenu.js index e69de29b..1826cd92 100644 --- a/packages/dashboard-v2/src/components/PopoverMenu/PopoverMenu.js +++ b/packages/dashboard-v2/src/components/PopoverMenu/PopoverMenu.js @@ -0,0 +1,90 @@ +import { Children, cloneElement, useRef, useState } from "react"; +import PropTypes from "prop-types"; +import { useClickAway } from "react-use"; +import styled, { css, keyframes } from "styled-components"; + +const dropDown = keyframes` + 0% { + transform: scaleY(0); + } + 80% { + transform: scaleY(1.1); + } + 100% { + transform: scaleY(1); + } +`; + +const Container = styled.div.attrs({ className: "relative inline-flex" })``; + +const Flyout = styled.ul.attrs({ + className: `absolute right-0 z-10 py-2 + border rounded bg-white + overflow-hidden pointer-events-none + shadow-md shadow-palette-200/50 + pointer-events-auto h-auto overflow-visible border-primary`, +})` + top: calc(100% + 2px); + animation: ${css` + ${dropDown} 0.1s ease-in-out + `}; +`; + +const Option = styled.li.attrs({ + className: `font-sans text-xs uppercase + relative pl-3 pr-5 py-1 + text-palette-600 cursor-pointer + hover:text-primary hover:font-normal + active:text-primary active:font-normal + + before:content-[initial] before:absolute before:left-0 before:h-3 before:w-0.5 before:bg-primary before:top-1.5 + hover:before:content-['']`, +})``; + +export const PopoverMenu = ({ options, children, openClassName, ...props }) => { + const containerRef = useRef(); + const [open, setOpen] = useState(false); + + useClickAway(containerRef, () => setOpen(false)); + + const handleChoice = (callback) => () => { + setOpen(false); + callback(); + }; + + return ( + + {Children.only( + cloneElement(children, { + onClick: () => setOpen((open) => !open), + className: `${children.props.className ?? ""} ${open ? openClassName : ""}`, + }) + )} + {open && ( + + {options.map(({ label, callback }) => ( + + ))} + + )} + + ); +}; + +PopoverMenu.propTypes = { + /** + * Accepts a single child node that will become a menu toggle. + */ + children: PropTypes.element.isRequired, + /** + * Positions in the menu + */ + options: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + callback: PropTypes.func.isRequired, + }) + ).isRequired, +}; diff --git a/packages/dashboard-v2/src/components/Select/Select.js b/packages/dashboard-v2/src/components/Select/Select.js index 97d3d73b..0a59a581 100644 --- a/packages/dashboard-v2/src/components/Select/Select.js +++ b/packages/dashboard-v2/src/components/Select/Select.js @@ -21,9 +21,11 @@ const dropDown = keyframes` const Container = styled.div.attrs({ className: "relative inline-flex" })``; -const Trigger = styled.button.attrs(({ placeholder }) => ({ - className: `flex items-center cursor-pointer ${placeholder ? "text-palette-300" : ""}`, -}))``; +const Trigger = styled.button.attrs(({ $placeholder }) => ({ + className: `flex items-center cursor-pointer font-bold ${$placeholder ? "text-palette-300" : ""}`, +}))` + text-transform: inherit; +`; const TriggerIcon = styled(ChevronDownIcon).attrs({ className: "transition-transform text-primary", @@ -32,13 +34,14 @@ const TriggerIcon = styled(ChevronDownIcon).attrs({ `; const Flyout = styled.ul.attrs(({ open }) => ({ - className: `absolute top-[20px] right-0 - p-0 h-0 border rounded bg-white + className: `absolute right-0 z-10 + p-0 border rounded bg-white overflow-hidden pointer-events-none shadow-md shadow-palette-200/50 ${open ? "pointer-events-auto h-auto overflow-visible border-primary" : ""} ${open ? "visible" : "invisible"}`, }))` + top: calc(100% + 2px); animation: ${({ open }) => open ? css` @@ -47,7 +50,7 @@ const Flyout = styled.ul.attrs(({ open }) => ({ : "none"}; `; -export const Select = ({ defaultValue, children, onChange, placeholder }) => { +export const Select = ({ defaultValue, children, onChange, placeholder, ...props }) => { const selectRef = useRef(); const options = useMemo(() => Children.toArray(children).filter(({ type }) => type === SelectOption), [children]); const [state, dispatch] = useSelectReducer({ defaultValue, placeholder, options }); @@ -65,8 +68,8 @@ export const Select = ({ defaultValue, children, onChange, placeholder }) => { const activeLabel = activeOption?.props?.label ?? null; return ( - - + + {activeLabel ?? placeholder} diff --git a/packages/dashboard-v2/src/components/Switch/Switch.css b/packages/dashboard-v2/src/components/Switch/Switch.css deleted file mode 100644 index 4bd07cf2..00000000 --- a/packages/dashboard-v2/src/components/Switch/Switch.css +++ /dev/null @@ -1,40 +0,0 @@ -.react-switch-checkbox { - height: 0; - width: 0; - visibility: hidden; -} - -.react-switch-label { - display: flex; - align-items: center; - justify-content: space-between; - cursor: pointer; - width: 44px; - height: 22px; - background: white; - border-radius: 11px; - @apply border-palette-200; - border-width: 1px; - position: relative; - transition: background-color 0.2s; -} - -.react-switch-label .react-switch-button { - content: ""; - position: absolute; - top: 2px; - left: 2px; - width: 16px; - height: 16px; - border-radius: 8px; - transition: 0.2s; -} - -.react-switch-checkbox:checked + .react-switch-label .react-switch-button { - left: calc(100% - 2px); - transform: translateX(-100%); -} - -.react-switch-label:active .react-switch-button { - width: 20px; -} diff --git a/packages/dashboard-v2/src/components/Switch/Switch.js b/packages/dashboard-v2/src/components/Switch/Switch.js index 7709412b..892692be 100644 --- a/packages/dashboard-v2/src/components/Switch/Switch.js +++ b/packages/dashboard-v2/src/components/Switch/Switch.js @@ -1,37 +1,91 @@ import PropTypes from "prop-types"; -import "./Switch.css"; +import { useEffect, useMemo, useState } from "react"; +import styled from "styled-components"; +import { nanoid } from "nanoid"; + +const Container = styled.div.attrs({ + className: "inline-flex items-center gap-1 cursor-pointer select-none", +})``; + +const Checkbox = styled.input.attrs({ + type: "checkbox", + className: `h-0 w-0 hidden`, +})``; + +const Label = styled.label.attrs({ + className: "cursor-pointer inline-flex items-center gap-2", +})` + &:active .toggle-pin { + width: 20px; + } +`; + +const Toggle = styled.span.attrs({ + className: `flex flex-row items-center justify-between shrink-0 + w-[44px] h-[22px] bg-white rounded-full + border border-palette-200 relative cursor-pointer`, +})` + &:active .toggle-pin { + width: 20px; + } +`; + +const TogglePin = styled.span.attrs(({ $checked }) => ({ + className: `toggle-pin + absolute top-[2px] w-4 h-4 rounded-full + transition-[width_left] active:w-5 + + ${$checked ? "checked bg-primary" : "bg-palette-200"}`, +}))` + left: 2px; + + &.checked { + left: calc(100% - 2px); + transform: translateX(-100%); + } +`; + +export const Switch = ({ children, defaultChecked, labelClassName, onChange, ...props }) => { + const id = useMemo(nanoid, [onChange]); + const [checked, setChecked] = useState(defaultChecked); + + useEffect(() => { + onChange(checked); + }, [checked, onChange]); -/** - * Primary UI component for user interaction - */ -export const Switch = ({ isOn, handleToggle }) => { return ( - <> - - - + + setChecked(ev.target.checked)} id={id} /> + + ); }; Switch.propTypes = { /** - * Switch's current value + * Should the checkbox be checked by default? */ - isOn: PropTypes.bool, + defaultChecked: PropTypes.bool, + /** + * Element to be rendered as the switch label + */ + children: PropTypes.oneOfType([PropTypes.element, PropTypes.string]), + /** + * Pass additional CSS classes to the `label` element. + */ + labelClassName: PropTypes.string, /** * Function to execute on change */ - handleToggle: PropTypes.func, + onChange: PropTypes.func.isRequired, }; Switch.defaultProps = { - isOn: false, + defaultChecked: false, + labelClassName: "", }; diff --git a/packages/dashboard-v2/src/components/Switch/Switch.stories.js b/packages/dashboard-v2/src/components/Switch/Switch.stories.js index 41d64f6b..8e0cba7d 100644 --- a/packages/dashboard-v2/src/components/Switch/Switch.stories.js +++ b/packages/dashboard-v2/src/components/Switch/Switch.stories.js @@ -13,10 +13,10 @@ const Template = (args) => ; export const SwitchTrue = Template.bind({}); // More on args: https://storybook.js.org/docs/react/writing-stories/args SwitchTrue.args = { - isOn: true, + defaultChecked: true, }; export const SwitchFalse = Template.bind({}); // More on args: https://storybook.js.org/docs/react/writing-stories/args SwitchFalse.args = { - isOn: false, + defaultChecked: false, }; diff --git a/packages/dashboard-v2/src/components/Table/Table.js b/packages/dashboard-v2/src/components/Table/Table.js index 8d55e119..e741da20 100644 --- a/packages/dashboard-v2/src/components/Table/Table.js +++ b/packages/dashboard-v2/src/components/Table/Table.js @@ -1,7 +1,7 @@ import styled from "styled-components"; const Container = styled.div.attrs({ - className: "p-1 max-w-full overflow-x-auto", + className: "p-1 max-w-full", })``; const StyledTable = styled.table.attrs({ diff --git a/packages/dashboard-v2/src/components/Table/TableHeadCell.js b/packages/dashboard-v2/src/components/Table/TableHeadCell.js index aeb65670..f16530f0 100644 --- a/packages/dashboard-v2/src/components/Table/TableHeadCell.js +++ b/packages/dashboard-v2/src/components/Table/TableHeadCell.js @@ -4,7 +4,8 @@ import styled from "styled-components"; * Accepts all HMTL attributes a `` element does. */ export const TableHeadCell = styled.th.attrs({ - className: `px-6 py-2.5 truncate h-tableRow + className: `first:pl-6 last:pr-6 px-2 py-4 + truncate h-tableRow text-palette-600 font-sans font-light text-xs first:rounded-l-sm last:rounded-r-sm`, })` diff --git a/packages/dashboard-v2/src/components/Tabs/Tabs.js b/packages/dashboard-v2/src/components/Tabs/Tabs.js index 4bf20ccf..eae51a65 100644 --- a/packages/dashboard-v2/src/components/Tabs/Tabs.js +++ b/packages/dashboard-v2/src/components/Tabs/Tabs.js @@ -34,7 +34,11 @@ const Body = styled.div.attrs({ className: "grow min-h-0" })``; export const Tabs = ({ defaultTab, children, variant }) => { const getTabId = usePrefixedTabIds(); const { tabs, panels, tabsRefs } = useTabsChildren(children, getTabId); - const defaultTabId = useMemo(() => getTabId(defaultTab || tabs[0].props.id), [getTabId, defaultTab, tabs]); + const defaultTabId = useMemo(() => { + const requestedTabIsPresent = tabs.find(({ props }) => props.id === defaultTab); + + return getTabId(requestedTabIsPresent ? defaultTab : tabs[0].props.id); + }, [getTabId, defaultTab, tabs]); const [activeTabId, setActiveTabId] = useState(defaultTabId); const [activeTabRef, setActiveTabRef] = useState(tabsRefs[activeTabId]); const isActive = (id) => id === activeTabId; diff --git a/packages/dashboard-v2/src/components/TextInputBasic/TextInputBasic.js b/packages/dashboard-v2/src/components/TextInputBasic/TextInputBasic.js index 9fa7f670..6b9531b4 100644 --- a/packages/dashboard-v2/src/components/TextInputBasic/TextInputBasic.js +++ b/packages/dashboard-v2/src/components/TextInputBasic/TextInputBasic.js @@ -1,18 +1,20 @@ +import { nanoid } from "nanoid"; import PropTypes from "prop-types"; +import { useMemo } from "react"; + +export const TextInputBasic = ({ label, placeholder, ...props }) => { + const id = useMemo(() => `input-${nanoid()}`, []); -/** - * Primary UI component for user interaction - */ -export const TextInputBasic = ({ label, placeholder }) => { return ( -
-

{label}

+
+
); diff --git a/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.js b/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.js index 892da996..506ee1fc 100644 --- a/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.js +++ b/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.js @@ -1,20 +1,45 @@ import PropTypes from "prop-types"; +import cn from "classnames"; +import { useEffect, useRef, useState } from "react"; +import { PlusIcon } from "../Icons"; + +export const TextInputIcon = ({ className, icon, placeholder, onChange }) => { + const inputRef = useRef(); + const [focused, setFocused] = useState(false); + const [value, setValue] = useState(""); + + useEffect(() => { + onChange(value); + }, [value, onChange]); -/** - * Primary UI component for user interaction - */ -export const TextInputIcon = ({ icon, position, placeholder }) => { return ( -
- {position === "left" ?
{icon}
: null} - +
{icon}
+ setFocused(true)} + onBlur={() => setFocused(false)} + onChange={(event) => setValue(event.target.value)} + placeholder={placeholder} + className="focus:outline-none bg-transparent placeholder:text-palette-400" /> - {position === "right" ?
{icon}
: null} + {value && ( + setValue("")} + /> + )}
); }; @@ -23,13 +48,13 @@ TextInputIcon.propTypes = { /** * Icon to place in text input */ - icon: PropTypes.element, - /** - * Side to place icon - */ - position: PropTypes.oneOf(["left", "right"]), + icon: PropTypes.element.isRequired, /** * Input placeholder */ placeholder: PropTypes.string, + /** + * Function to be called whenever value changes + */ + onChange: PropTypes.func.isRequired, }; diff --git a/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.stories.js b/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.stories.js index 676ca9cf..521b90df 100644 --- a/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.stories.js +++ b/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.stories.js @@ -1,5 +1,6 @@ import { TextInputIcon } from "./TextInputIcon"; -import { CogIcon } from "../Icons"; +import { SearchIcon } from "../Icons"; +import { Panel } from "../Panel"; // More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export export default { @@ -9,19 +10,21 @@ export default { }; // More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args -const Template = (args) => ; +const Template = (args) => ( + + + +); export const IconLeft = Template.bind({}); // More on args: https://storybook.js.org/docs/react/writing-stories/args IconLeft.args = { - icon: , - position: "left", + icon: , placeholder: "Search", }; export const IconRight = Template.bind({}); IconRight.args = { - icon: , - position: "right", + icon: , placeholder: "Search", }; diff --git a/packages/dashboard-v2/src/contexts/plans/PlansContext.js b/packages/dashboard-v2/src/contexts/plans/PlansContext.js new file mode 100644 index 00000000..ff35b45e --- /dev/null +++ b/packages/dashboard-v2/src/contexts/plans/PlansContext.js @@ -0,0 +1,7 @@ +import { createContext } from "react"; + +export const PlansContext = createContext({ + plans: [], + limits: [], + error: null, +}); diff --git a/packages/dashboard-v2/src/contexts/plans/PlansProvider.js b/packages/dashboard-v2/src/contexts/plans/PlansProvider.js new file mode 100644 index 00000000..c481e296 --- /dev/null +++ b/packages/dashboard-v2/src/contexts/plans/PlansProvider.js @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; +import useSWR from "swr"; + +import freePlan from "../../lib/tiers"; + +import { PlansContext } from "./PlansContext"; + +/** + * NOTE: this function heavily relies on the fact that each Plan's `tier` + * property corresponds to the plan's index in UserLimits array in + * skynet-accounts code. + * + * @see https://github.com/SkynetLabs/skynet-accounts/blob/7337e740b71b77e6d08016db801e293b8ad81abc/database/user.go#L53-L101 + */ +const aggregatePlansAndLimits = (plans, limits) => { + const sortedPlans = [freePlan, ...plans].sort((planA, planB) => planA.tier - planB.tier); + + // Decorate each plan with its corresponding limits data, if available. + if (limits?.length) { + return sortedPlans.map((plan) => ({ ...plan, limits: limits[plan.tier] || null })); + } + + // If we don't have the limits data yet, set just return the plans. + + return sortedPlans; +}; + +export const PlansProvider = ({ children }) => { + const { data: rawPlans, error: plansError } = useSWR("stripe/prices"); + const { data: limits, error: limitsError } = useSWR("limits"); + + const [plans, setPlans] = useState([freePlan]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (plansError || limitsError) { + setLoading(false); + setError(plansError || limitsError); + } else if (rawPlans) { + setLoading(false); + setPlans(aggregatePlansAndLimits(rawPlans, limits?.userLimits)); + } + }, [rawPlans, limits, plansError, limitsError]); + + return {children}; +}; diff --git a/packages/dashboard-v2/src/contexts/plans/index.js b/packages/dashboard-v2/src/contexts/plans/index.js new file mode 100644 index 00000000..84dd790f --- /dev/null +++ b/packages/dashboard-v2/src/contexts/plans/index.js @@ -0,0 +1,2 @@ +export * from "./PlansProvider"; +export * from "./usePlans"; diff --git a/packages/dashboard-v2/src/contexts/plans/usePlans.js b/packages/dashboard-v2/src/contexts/plans/usePlans.js new file mode 100644 index 00000000..f36e8595 --- /dev/null +++ b/packages/dashboard-v2/src/contexts/plans/usePlans.js @@ -0,0 +1,5 @@ +import { useContext } from "react"; + +import { PlansContext } from "./PlansContext"; + +export const usePlans = () => useContext(PlansContext); diff --git a/packages/dashboard-v2/src/hooks/useActivePlan.js b/packages/dashboard-v2/src/hooks/useActivePlan.js new file mode 100644 index 00000000..53e28b63 --- /dev/null +++ b/packages/dashboard-v2/src/hooks/useActivePlan.js @@ -0,0 +1,22 @@ +import { useEffect, useState } from "react"; + +import freeTier from "../lib/tiers"; +import { usePlans } from "../contexts/plans"; + +export default function useActivePlan(user) { + const { plans, error } = usePlans(); + + const [activePlan, setActivePlan] = useState(freeTier); + + useEffect(() => { + if (user) { + setActivePlan(plans.find((plan) => plan.tier === user.tier)); + } + }, [plans, user]); + + return { + error, + plans, + activePlan, + }; +} diff --git a/packages/dashboard-v2/src/hooks/useSubscriptionPlans.js b/packages/dashboard-v2/src/hooks/useSubscriptionPlans.js deleted file mode 100644 index 26658df8..00000000 --- a/packages/dashboard-v2/src/hooks/useSubscriptionPlans.js +++ /dev/null @@ -1,28 +0,0 @@ -import { useEffect, useState } from "react"; -import useSWR from "swr"; -import freeTier from "../lib/tiers"; - -export default function useSubscriptionPlans(user) { - const { data: paidPlans, error, mutate } = useSWR("stripe/prices"); - const [plans, setPlans] = useState([freeTier]); - const [activePlan, setActivePlan] = useState(freeTier); - - useEffect(() => { - if (paidPlans) { - setPlans((plans) => [...plans, ...paidPlans].sort((planA, planB) => planA.tier - planB.tier)); - } - }, [paidPlans]); - - useEffect(() => { - if (user) { - setActivePlan(plans.find((plan) => plan.tier === user.tier)); - } - }, [plans, user]); - - return { - error, - mutate, - plans, - activePlan, - }; -} diff --git a/packages/dashboard-v2/src/layouts/DashboardLayout.js b/packages/dashboard-v2/src/layouts/DashboardLayout.js index 07f4eabf..b369ece3 100644 --- a/packages/dashboard-v2/src/layouts/DashboardLayout.js +++ b/packages/dashboard-v2/src/layouts/DashboardLayout.js @@ -8,6 +8,7 @@ import { PageContainer } from "../components/PageContainer"; import { NavBar } from "../components/Navbar"; import { Footer } from "../components/Footer"; import { UserProvider, useUser } from "../contexts/user"; +import { ContainerLoadingIndicator } from "../components/LoadingIndicator"; const Wrapper = styled.div.attrs({ className: "min-h-screen overflow-hidden", @@ -25,7 +26,7 @@ const Layout = ({ children }) => { {!user && (
-

Loading...

{/* TODO: Do something nicer here */} +
)} {user && <>{children}} diff --git a/packages/dashboard-v2/src/layouts/UserSettingsLayout.js b/packages/dashboard-v2/src/layouts/UserSettingsLayout.js new file mode 100644 index 00000000..9fda613d --- /dev/null +++ b/packages/dashboard-v2/src/layouts/UserSettingsLayout.js @@ -0,0 +1,87 @@ +import * as React from "react"; +import { Link } from "gatsby"; +import styled from "styled-components"; +import { SWRConfig } from "swr"; + +import { authenticatedOnly } from "../lib/swrConfig"; + +import { PageContainer } from "../components/PageContainer"; +import { NavBar } from "../components/Navbar"; +import { Footer } from "../components/Footer"; +import { UserProvider, useUser } from "../contexts/user"; +import { ContainerLoadingIndicator } from "../components/LoadingIndicator"; + +const Wrapper = styled.div.attrs({ + className: "min-h-screen overflow-hidden", +})` + background-image: url(/images/dashboard-bg.svg); + background-position: center -280px; + background-repeat: no-repeat; +`; + +const Layout = ({ children }) => { + const { user } = useUser(); + + // Prevent from flashing the dashboard screen to unauthenticated users. + return ( + + {!user && ( +
+ +
+ )} + {user && <>{children}} +
+ ); +}; + +const Sidebar = () => ( + +); + +const SidebarLink = styled(Link).attrs({ + className: `h-12 py-3 px-6 h-full w-full flex + border-l-2 border-l-palette-200 + border-b border-b-palette-100 last:border-b-transparent`, +})``; + +const Content = styled.main.attrs({ + className: "relative bg-white rounded px-6 py-6 sm:px-16 sm:py-14 mt-6 lg:mt-0 bg-none xl:bg-corner-circle", +})` + background-repeat: no-repeat; +`; + +const UserSettingsLayout = ({ children }) => ( + + + + + +
Settings
+
+ + {children} +
+
+