├── www ├── src │ ├── components │ │ ├── user_dashboard.css │ │ ├── node_countries_chart.css │ │ ├── check_sanctions.css │ │ ├── pings_chart.css │ │ ├── storj_tx_summary.css │ │ ├── nodes_location_summary.css │ │ ├── auth.css │ │ ├── nodes_subnet_summary.css │ │ ├── help.css │ │ ├── user_dashboard.js │ │ ├── ping_my_node.css │ │ ├── rewind_control.js │ │ ├── help.js │ │ ├── auth.js │ │ ├── user_nodes.css │ │ ├── check_sanctions.js │ │ ├── search_neighbors.js │ │ ├── node_countries_chart.js │ │ ├── ping_my_node.js │ │ └── nodes_subnet_summary.js │ ├── utils │ │ ├── htm.js │ │ ├── arrays.js │ │ ├── types.js │ │ ├── preact_compat.js │ │ ├── store.js │ │ ├── elems.js │ │ ├── time.js │ │ ├── nodes.js │ │ └── dns.js │ ├── shame.js │ ├── errors.js │ ├── index.js │ ├── i18n.js │ ├── api.js │ └── main.css ├── templates │ ├── 500.html │ ├── neighbors.html │ ├── sanctions.html │ ├── ping_my_node.html │ ├── 404.html │ ├── user_dashboard.html │ ├── index.html │ └── _base.html ├── prettier.config.js ├── jsconfig.json ├── .eslintrc.js ├── package.json └── rollup.config.js ├── scripts ├── db_restore.sh ├── db_dump.sh ├── build.sh ├── daily_backup.sh ├── storjnet-proxy.service.example ├── storjnet-probe.service.example ├── storjnet-update.service.example ├── storjnet-http.service.example ├── storjnet-tgbot.service.example ├── geoip_db_load.sh ├── install.sh ├── crontab.example └── node_id_conv │ └── main.go ├── .gitignore ├── core ├── tokens.go ├── nodes_history.go ├── appconfig.go ├── ip_companies_test.go ├── nodes.go ├── users.go ├── ip_types.go ├── versions.go └── ip_companies.go ├── migrations ├── 007_versions_extra.go ├── 018_off_stats_full_nodes.go ├── 014_user_nodes_last_ping_was_ok.go ├── 004_appconfig.go ├── 026_node_stats_ip_types_asn_tops_isp.go ├── 003_versons.go ├── 016_user_nodes_details_updated_at.go ├── 027_network_company_unknown_ips.go ├── 019_node_stats_subnets.go ├── 002_user_texts.go ├── 022_node_stats_subnet_countries.go ├── 025_node_stats_countries_isp.go ├── 006_storj_token_known_addresses.go ├── 010_drop_storj_token_addresses.go ├── 012_subnet_search_index.go ├── 011_client_errors.go ├── 013_users_last_seen_at.go ├── 028_geoip_overrides.go ├── 015_tcp_quic_updated_at.go ├── 020_node_stats_ip_types.go ├── 023_partition_user_nodes_history.go ├── 009_node_stats.go ├── 008_nodes.go ├── 021_autonomous_systems_ipinfo.go ├── 024_network_companies.go ├── 005_storj_transactions.go ├── 001_init.go ├── 017_off_stats.go └── main.go ├── utils ├── tg.go ├── config.go ├── stuff.go ├── countries.go ├── worker.go ├── countries_generator.go ├── storjutils │ └── ping_proxy.go └── db.go ├── README.md ├── versions └── checker.go ├── server └── middlewares.go ├── nodes ├── loc_snap.go └── prober.go └── tgbot └── tgbot.go /www/src/components/user_dashboard.css: -------------------------------------------------------------------------------- 1 | .user-dashboard-nodes { 2 | margin: 0 8px; 3 | } 4 | -------------------------------------------------------------------------------- /www/templates/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 50Oups! 4 | 5 | -------------------------------------------------------------------------------- /www/src/utils/htm.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact' 2 | import htm from 'htm' 3 | 4 | export const html = htm.bind(h) 5 | -------------------------------------------------------------------------------- /www/src/utils/arrays.js: -------------------------------------------------------------------------------- 1 | /** @param {number} count */ 2 | export function zeroes(count) { 3 | return new Array(count).fill(0) 4 | } 5 | -------------------------------------------------------------------------------- /www/templates/neighbors.html: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | {{.L.Loc "Neighbors" "ru" "Соседи"}} 3 | {{end}} 4 | 5 | 6 | {{define "content"}} 7 |
8 | {{end}} 9 | -------------------------------------------------------------------------------- /www/templates/sanctions.html: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | {{.L.Loc "Sanctions" "ru" "Санкции"}} 3 | {{end}} 4 | 5 | 6 | {{define "content"}} 7 |
8 | {{end}} 9 | -------------------------------------------------------------------------------- /www/src/shame.js: -------------------------------------------------------------------------------- 1 | if (!Object.fromEntries) { 2 | Object.fromEntries = entries => { 3 | const obj = {} 4 | for (const entry of entries) obj[entry[0]] = entry[1] 5 | return obj 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /www/prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 110, 3 | useTabs: true, 4 | tabWidth: 4, 5 | semi: false, 6 | singleQuote: true, 7 | trailingComma: 'all', 8 | arrowParens: 'avoid', 9 | } 10 | -------------------------------------------------------------------------------- /scripts/db_restore.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | fname=${1:-dump.sql.gz} 3 | echo "restoring DB from $fname" 4 | 5 | zcat "$fname" | psql --set=ON_ERROR_STOP=1 --single-transaction --username=storjnet storjnet_db 6 | 7 | echo "done" 8 | -------------------------------------------------------------------------------- /www/src/components/node_countries_chart.css: -------------------------------------------------------------------------------- 1 | .nodes-count-chart .chart { 2 | height: 110px; 3 | } 4 | .node-countries-chart { 5 | height: 192px; 6 | } 7 | .node-countries-chart .legend { 8 | font-size: 10px; 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | storjnet 3 | scripts/probe_nodes/probe_nodes 4 | scripts/node_id_conv/node_id_conv 5 | scripts/update_ip_types/update_ip_types 6 | identity 7 | history 8 | dumps 9 | *.mmdb 10 | www/node_modules 11 | www/dist 12 | -------------------------------------------------------------------------------- /www/templates/ping_my_node.html: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | {{.L.Loc "Pingdom" "ru" "Пинговальня"}} 3 | {{end}} 4 | 5 | {{define "content"}} 6 | 7 |
8 | {{end}} 9 | -------------------------------------------------------------------------------- /core/tokens.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import "time" 4 | 5 | type StorjTokenTxSummary struct { 6 | Date time.Time 7 | Preparings []float32 `pg:",array"` 8 | Payouts []float32 `pg:",array"` 9 | PayoutCounts []int32 `pg:",array"` 10 | Withdrawals []float32 `pg:",array"` 11 | } 12 | -------------------------------------------------------------------------------- /scripts/db_dump.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | fname=${1:-dump.sql.gz} 3 | echo "dumping DB to $fname" 4 | 5 | pg_dump --username=storjnet --schema=storjnet --format=p storjnet_db --clean --if-exists | grep -vP "(DROP SCHEMA|CREATE SCHEMA)" | gzip -5 --force > "$fname" 6 | 7 | echo -n "done, " 8 | du -h "$fname" | cut -f1 9 | -------------------------------------------------------------------------------- /www/src/components/check_sanctions.css: -------------------------------------------------------------------------------- 1 | .check-sanctions-form input[type='submit'] { 2 | vertical-align: top; 3 | } 4 | 5 | .check-sanctions-result-table tr:last-of-type { 6 | border: none; 7 | } 8 | 9 | .check-sanctions-result-table td.sanction { 10 | max-width: 192px; 11 | text-align: center; 12 | } 13 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | 2 | #!/bin/bash 3 | set -e 4 | base=`dirname "$0"`"/.." 5 | 6 | echo building server... 7 | cd $base 8 | go build -v 9 | 10 | cd "www" 11 | echo installing client deps... 12 | npm install --omit=dev 13 | 14 | echo cleanup... 15 | rm -rf "dist" 16 | 17 | echo building client... 18 | npm run build 19 | -------------------------------------------------------------------------------- /scripts/daily_backup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | scripts_dirname=`dirname "$0"` 3 | dumps_dirname=${1:-$scripts_dirname/../dumps} 4 | mkdir -p "$dumps_dirname" 5 | stamp=`date -Idate` 6 | "$scripts_dirname/db_dump.sh" "$dumps_dirname/dump_$stamp.sql.gz" 7 | find "$dumps_dirname" -type f ! -regex '.*/dump.*-\(01\|15\).sql.gz' ! -name "dump_$stamp.sql.gz" -exec rm {} \; 8 | -------------------------------------------------------------------------------- /scripts/storjnet-proxy.service.example: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Storjnet Pings Proxy 3 | After=network.target 4 | 5 | [Service] 6 | User=storj 7 | WorkingDirectory=/home/storj/storjnet 8 | Environment="PROXY_ENDPOINT_PATH=/proxy/endpoint/path" 9 | ExecStart=/home/storj/storjnet/storjnet ping-proxy --addr=0.0.0.0:9005 10 | Restart=on-failure 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /scripts/storjnet-probe.service.example: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Storjnet Nodes Prober 3 | After=network.target postgresql.service 4 | 5 | [Service] 6 | User=storj 7 | WorkingDirectory=/home/storj/storjnet 8 | Environment="SATELLITES=Local:identity|Proxy:/proxy/endpoint/path:9005:1.2.3.4" 9 | ExecStart=/home/storj/storjnet/storjnet probe-nodes 10 | Restart=on-failure 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /migrations/007_versions_extra.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | ALTER TABLE storjnet.versions ADD COLUMN extra jsonb 9 | `) 10 | }, func(db migrations.DB) error { 11 | return execSome(db, ` 12 | ALTER TABLE storjnet.versions DROP COLUMN extra 13 | `) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /scripts/storjnet-update.service.example: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Storjnet nodes updating daemon 3 | After=network.target postgresql.service 4 | 5 | [Service] 6 | User=storj 7 | WorkingDirectory=/home/storj/storjnet 8 | Environment="SATELLITES=Local:identity|Proxy:/proxy/endpoint/path:9005:1.2.3.4" 9 | ExecStart=/home/storj/storjnet/storjnet update 10 | Restart=on-failure 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /core/nodes_history.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "time" 5 | 6 | "storj.io/common/storj" 7 | ) 8 | 9 | type UserNodeHistory struct { 10 | tableName struct{} `pg:"user_nodes_history"` 11 | RawNodeID []byte `json:"-"` 12 | NodeID storj.NodeID `json:"nodeId"` 13 | UserID int64 `json:"userId"` 14 | Date time.Time `json:"date"` 15 | Pings []uint16 `json:"pings" pg:",array"` 16 | } 17 | -------------------------------------------------------------------------------- /www/src/components/pings_chart.css: -------------------------------------------------------------------------------- 1 | .pings-chart { 2 | position: relative; 3 | height: 40px; 4 | margin-bottom: 4px; 5 | } 6 | 7 | .pings-chart .zoom-canvas { 8 | position: absolute; 9 | pointer-events: none; 10 | left: 0; 11 | bottom: 0; 12 | width: 512px; 13 | height: 64px; 14 | max-width: 100%; 15 | /* outline: 1px solid red; */ 16 | z-index: 1; 17 | } 18 | .pings-chart .zoom-canvas.touch { 19 | height: 96px; 20 | } 21 | -------------------------------------------------------------------------------- /migrations/018_off_stats_full_nodes.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | ALTER TABLE off_node_stats ADD COLUMN full_nodes bigint; 9 | `) 10 | }, func(db migrations.DB) error { 11 | return execSome(db, ` 12 | ALTER TABLE off_node_stats DROP COLUMN full_nodes; 13 | `) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /scripts/storjnet-http.service.example: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Storjnet HTTP server 3 | After=network.target postgresql.service 4 | 5 | [Service] 6 | User=storj 7 | WorkingDirectory=/home/storj/storjnet 8 | Environment="SATELLITES=Local:identity|Proxy:/proxy/endpoint/path:9005:1.2.3.4" 9 | ExecStart=/home/storj/storjnet/storjnet http --env=prod --addr=127.0.0.1:9004 10 | Restart=on-failure 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /migrations/014_user_nodes_last_ping_was_ok.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | ALTER TABLE user_nodes ADD COLUMN last_ping_was_ok bool; 9 | `) 10 | }, func(db migrations.DB) error { 11 | return execSome(db, ` 12 | ALTER TABLE user_nodes DROP COLUMN last_ping_was_ok; 13 | `) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /migrations/004_appconfig.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | CREATE TABLE storjnet.appconfig ( 9 | key TEXT PRIMARY KEY, 10 | value JSONB NOT NULL 11 | ) 12 | `) 13 | }, func(db migrations.DB) error { 14 | return execSome(db, ` 15 | DROP TABLE storjnet.appconfig 16 | `) 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /www/src/components/storj_tx_summary.css: -------------------------------------------------------------------------------- 1 | .storj-tx-summary-chart .legend { 2 | right: 0; 3 | } 4 | .storj-tx-summary-chart .scale-mode-wrap { 5 | display: flex; 6 | justify-content: flex-end; 7 | flex-grow: 1000; 8 | } 9 | .storj-tx-summary-chart .scale-mode-wrap button { 10 | font: inherit; 11 | height: auto; 12 | padding: 1px 2px; 13 | margin: -2px 1px; 14 | } 15 | .storj-tx-summary-chart .scale-mode-wrap button.active { 16 | background: lightgray; 17 | } 18 | -------------------------------------------------------------------------------- /www/templates/404.html: -------------------------------------------------------------------------------- 1 | {{define "title"}}404{{end}} 2 | 3 | {{define "content"}} 4 | 19 |
20 |
p4ge n0t f4und
21 |
22 | {{end}} 23 | -------------------------------------------------------------------------------- /www/src/utils/types.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @template T 3 | * @param {T|null} val 4 | * @returns T 5 | */ 6 | export function mustBeNotNull(val) { 7 | if (val === null) throw new Error('value must not be null') 8 | return val 9 | } 10 | 11 | /** 12 | * @template T 13 | * @param {Promise|unknown} value 14 | * @returns {value is Promise} 15 | */ 16 | export function isPromise(value) { 17 | return typeof value === 'object' && value !== null && 'then' in value 18 | } 19 | -------------------------------------------------------------------------------- /migrations/026_node_stats_ip_types_asn_tops_isp.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | ALTER TABLE node_stats ADD COLUMN ip_types_asn_tops jsonb NOT NULL DEFAULT '{}'::jsonb; 9 | `) 10 | }, func(db migrations.DB) error { 11 | return execSome(db, ` 12 | ALTER TABLE node_stats DROP COLUMN ip_types_asn_tops; 13 | `) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /migrations/003_versons.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | CREATE TABLE storjnet.versions ( 9 | kind text NOT NULL, 10 | version text NOT NULL, 11 | created_at timestamptz NOT NULL DEFAULT NOW() 12 | ) 13 | `) 14 | }, func(db migrations.DB) error { 15 | return execSome(db, ` 16 | DROP TABLE storjnet.versions 17 | `) 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /www/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "checkJs": true, 4 | "strictNullChecks": true, 5 | "strictFunctionTypes": true, 6 | "strictBindCallApply": true, 7 | "strictPropertyInitialization": true, 8 | "noImplicitThis": true, 9 | "target": "es2020", 10 | "module": "es2020", 11 | // если module != commonjs, этот параметр съезжает на classic, 12 | // и TS начинает видеть кучу ошибок типов в нодо_модулях 13 | "moduleResolution": "node", 14 | "baseUrl": ".", 15 | }, 16 | "include": ["src"] 17 | } 18 | -------------------------------------------------------------------------------- /utils/tg.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/ansel1/merry" 5 | tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api" 6 | ) 7 | 8 | func TGSendMessageMD(bot *tgbotapi.BotAPI, chatID int64, text string) error { 9 | _, err := bot.Send(tgbotapi.MessageConfig{ 10 | BaseChat: tgbotapi.BaseChat{ 11 | ChatID: chatID, 12 | ReplyToMessageID: 0, 13 | }, 14 | Text: text, 15 | DisableWebPagePreview: true, 16 | ParseMode: "Markdown", 17 | }) 18 | return merry.Wrap(err) 19 | } 20 | -------------------------------------------------------------------------------- /migrations/016_user_nodes_details_updated_at.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | ALTER TABLE user_nodes ADD COLUMN details_updated_at timestamptz NOT NULL DEFAULT now(); 9 | UPDATE user_nodes SET details_updated_at = created_at; 10 | `) 11 | }, func(db migrations.DB) error { 12 | return execSome(db, ` 13 | ALTER TABLE user_nodes DROP COLUMN details_updated_at; 14 | `) 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /www/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | }, 6 | extends: 'plugin:prettier/recommended', 7 | globals: { 8 | Atomics: 'readonly', 9 | SharedArrayBuffer: 'readonly', 10 | }, 11 | parserOptions: { 12 | ecmaVersion: 2020, 13 | sourceType: 'module', 14 | }, 15 | rules: { 16 | 'no-console': 'warn', 17 | 'no-unused-vars': ['error', { vars: 'all', args: 'none' }], 18 | 'no-undef': 'error', 19 | eqeqeq: ['warn', 'always'], 20 | }, 21 | globals: { 22 | process: true, 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /scripts/storjnet-tgbot.service.example: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Storjnet TG bot 3 | After=network.target postgresql.service 4 | 5 | [Service] 6 | User=storj 7 | WorkingDirectory=/home/storj/storjnet 8 | ExecStart=/home/storj/storjnet/storjnet tg-bot \ 9 | --tg-bot-token= \ 10 | --tg-webhook-url https://storjnet.info/path/to/bot/hook \ 11 | --tg-webhook-addr 127.0.0.1:9005 \ 12 | --tg-webhook-path /path/to/bot/hook \ 13 | --github-oauth-token 14 | Restart=on-failure 15 | 16 | [Install] 17 | WantedBy=multi-user.target 18 | -------------------------------------------------------------------------------- /migrations/027_network_company_unknown_ips.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | CREATE TABLE storjnet.network_company_unknown_ips ( 9 | ip_addr inet PRIMARY KEY, 10 | created_at timestamptz NOT NULL DEFAULT now(), 11 | updated_at timestamptz NOT NULL DEFAULT now() 12 | ); 13 | `) 14 | }, func(db migrations.DB) error { 15 | return execSome(db, ` 16 | DROP TABLE storjnet.network_company_unknown_ips; 17 | `) 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /utils/config.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "github.com/ansel1/merry" 4 | 5 | type Env struct { 6 | Val string 7 | } 8 | 9 | func (e *Env) Set(name string) error { 10 | if name != "dev" && name != "prod" { 11 | return merry.New("wrong env: " + name) 12 | } 13 | e.Val = name 14 | return nil 15 | } 16 | 17 | func (e Env) String() string { 18 | return e.Val 19 | } 20 | 21 | func (e Env) Type() string { 22 | return "string" 23 | } 24 | 25 | func (e Env) IsDev() bool { 26 | return e.Val == "dev" 27 | } 28 | 29 | func (e Env) IsProd() bool { 30 | return e.Val == "prod" 31 | } 32 | -------------------------------------------------------------------------------- /migrations/019_node_stats_subnets.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | ALTER TABLE node_stats ADD COLUMN subnets_top jsonb NOT NULL DEFAULT '{}'::jsonb; 9 | ALTER TABLE node_stats ADD COLUMN subnet_sizes jsonb NOT NULL DEFAULT '{}'::jsonb; 10 | `) 11 | }, func(db migrations.DB) error { 12 | return execSome(db, ` 13 | ALTER TABLE node_stats DROP COLUMN subnets_top; 14 | ALTER TABLE node_stats DROP COLUMN subnet_sizes; 15 | `) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /migrations/002_user_texts.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | CREATE TABLE storjnet.user_texts ( 9 | user_id integer NOT NULL REFERENCES storjnet.users (id), 10 | date date NOT NULL, 11 | text text NOT NULL, 12 | updated_at timestamptz NOT NULL DEFAULT NOW(), 13 | PRIMARY KEY (user_id, date) 14 | ) 15 | `) 16 | }, func(db migrations.DB) error { 17 | return execSome(db, ` 18 | DROP TABLE storjnet.user_texts 19 | `) 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /migrations/022_node_stats_subnet_countries.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | ALTER TABLE node_stats ADD COLUMN subnets_count integer NOT NULL DEFAULT 0; 9 | ALTER TABLE node_stats ADD COLUMN subnet_countries jsonb NOT NULL DEFAULT '{}'::jsonb; 10 | `) 11 | }, func(db migrations.DB) error { 12 | return execSome(db, ` 13 | ALTER TABLE node_stats DROP COLUMN subnets_count; 14 | ALTER TABLE node_stats DROP COLUMN subnet_countries; 15 | `) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /migrations/025_node_stats_countries_isp.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | ALTER TABLE node_stats ADD COLUMN countries_isp jsonb NOT NULL DEFAULT '{}'::jsonb; 9 | ALTER TABLE node_stats ADD COLUMN subnet_countries_isp jsonb NOT NULL DEFAULT '{}'::jsonb; 10 | `) 11 | }, func(db migrations.DB) error { 12 | return execSome(db, ` 13 | ALTER TABLE node_stats DROP COLUMN countries_isp; 14 | ALTER TABLE node_stats DROP COLUMN subnet_countries_isp; 15 | `) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /migrations/006_storj_token_known_addresses.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | CREATE TABLE storjnet.storj_token_known_addresses ( 9 | addr bytea PRIMARY KEY CHECK (length(addr) = 20), 10 | description text NOT NULL, 11 | kind text NOT NULL, 12 | proof text, 13 | created_at timestamptz NOT NULL DEFAULT NOW() 14 | )`) 15 | }, func(db migrations.DB) error { 16 | return execSome(db, ` 17 | DROP TABLE storjnet.storj_token_known_addresses 18 | `) 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /migrations/010_drop_storj_token_addresses.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | DROP TABLE storj_token_addresses 9 | `) 10 | }, func(db migrations.DB) error { 11 | return execSome(db, ` 12 | CREATE TABLE storjnet.storj_token_addresses ( 13 | addr bytea PRIMARY KEY CHECK (length(addr) = 20), 14 | last_block_number int NOT NULL, 15 | created_at timestamptz NOT NULL DEFAULT NOW(), 16 | updated_at timestamptz NOT NULL DEFAULT NOW() 17 | ) 18 | `) 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /migrations/012_subnet_search_index.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | CREATE FUNCTION storjnet.node_ip_subnet(ip inet) 9 | RETURNS inet AS $$ 10 | SELECT set_masklen(ip::cidr, 24); 11 | $$ LANGUAGE SQL IMMUTABLE; 12 | CREATE INDEX nodes__ip_addr_subnet__index ON nodes (node_ip_subnet(ip_addr)); 13 | `) 14 | }, func(db migrations.DB) error { 15 | return execSome(db, ` 16 | DROP INDEX nodes__ip_addr_subnet__index; 17 | DROP FUNCTION storjnet.node_ip_subnet(inet); 18 | `) 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /migrations/011_client_errors.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | CREATE TABLE storjnet.client_errors ( 9 | id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, 10 | url text NOT NULL, 11 | user_id int, 12 | user_agent text, 13 | lang text, 14 | message text NOT NULL, 15 | stack text, 16 | created_at timestamptz NOT NULL DEFAULT now() 17 | ) 18 | `) 19 | }, func(db migrations.DB) error { 20 | return execSome(db, ` 21 | DROP TABLE storjnet.client_errors 22 | `) 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /migrations/013_users_last_seen_at.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | ALTER TABLE users ADD COLUMN last_seen_at timestamptz DEFAULT now(); 9 | UPDATE users SET last_seen_at = greatest( 10 | created_at, 11 | (select max(created_at) from user_nodes where user_id=id), 12 | (select max(updated_at) from user_texts where user_id=id) 13 | ); 14 | ALTER TABLE users ALTER COLUMN last_seen_at SET NOT NULL; 15 | `) 16 | }, func(db migrations.DB) error { 17 | return execSome(db, ` 18 | ALTER TABLE users DROP COLUMN last_seen_at; 19 | `) 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /scripts/geoip_db_load.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | repo_dir=`dirname "$0"`"/.." 5 | edition_id=$1 6 | 7 | if [ -z "$edition_id" ]; then 8 | echo " argument is required (GeoLite2-City, GeoLite2-ASN, etc.)" 9 | exit 1 10 | fi 11 | 12 | if [ -z "$MAXMIND_KEY" ]; then 13 | echo "MAXMIND_KEY env variable is required" 14 | exit 1 15 | fi 16 | 17 | curl --silent --show-error --fail --location "https://download.maxmind.com/app/geoip_download?edition_id=$edition_id&license_key=$MAXMIND_KEY&suffix=tar.gz" \ 18 | | tar --extract --gzip --to-stdout --wildcards "*/$edition_id.mmdb" > "$repo_dir/$edition_id.mmdb.tmp" 19 | 20 | mv "$repo_dir/$edition_id.mmdb.tmp" "$repo_dir/$edition_id.mmdb" 21 | -------------------------------------------------------------------------------- /migrations/028_geoip_overrides.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | CREATE TABLE storjnet.geoip_overrides ( 9 | network cidr PRIMARY KEY, 10 | location JSONB NOT NULL, 11 | created_at timestamptz NOT NULL DEFAULT now(), 12 | updated_at timestamptz NOT NULL DEFAULT now() 13 | ); 14 | CREATE INDEX geoip_overrides__network__index ON geoip_overrides USING GIST(network inet_ops); 15 | `) 16 | }, func(db migrations.DB) error { 17 | return execSome(db, ` 18 | DROP INDEX geoip_overrides__network__index; 19 | DROP TABLE storjnet.geoip_overrides; 20 | `) 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /migrations/015_tcp_quic_updated_at.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | ALTER TABLE nodes ADD COLUMN tcp_updated_at timestamptz; 9 | ALTER TABLE nodes ADD COLUMN quic_updated_at timestamptz; 10 | 11 | ALTER TABLE node_stats ADD COLUMN active_count_proto jsonb NOT NULL default '{}'; 12 | 13 | CREATE INDEX nodes__updated_at__index ON nodes (updated_at); 14 | `) 15 | }, func(db migrations.DB) error { 16 | return execSome(db, ` 17 | DROP INDEX nodes__updated_at__index; 18 | 19 | ALTER TABLE node_stats DROP COLUMN active_count_proto; 20 | 21 | ALTER TABLE nodes DROP COLUMN tcp_updated_at; 22 | ALTER TABLE nodes DROP COLUMN quic_updated_at; 23 | `) 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /www/src/components/nodes_location_summary.css: -------------------------------------------------------------------------------- 1 | .map-wrap { 2 | position: relative; 3 | width: 100%; 4 | height: 384px; 5 | background-color: #eee; 6 | } 7 | 8 | .node-countries-table thead td:first-of-type { 9 | text-align: right; 10 | } 11 | 12 | .node-countries-table thead td:nth-child(3), 13 | .node-countries-table thead td:nth-child(5), 14 | .node-countries-table thead td:nth-child(7) { 15 | padding-left: 48px; 16 | } 17 | .node-countries-table thead td.isp { 18 | padding-left: 10px; 19 | } 20 | .node-countries-table thead td.avg .help { 21 | margin-right: -20px; 22 | } 23 | 24 | .node-countries-table tbody td:not(.name) { 25 | text-align: right; 26 | } 27 | 28 | .node-countries-table tbody tr:last-child { 29 | border-bottom: none; 30 | } 31 | 32 | .node-countries-table tbody tr:last-child td { 33 | text-align: center; 34 | } 35 | -------------------------------------------------------------------------------- /www/src/components/auth.css: -------------------------------------------------------------------------------- 1 | input, 2 | button { 3 | border: 1px solid; 4 | border-color: #cdc7c2 #cdc7c2 #bfb8b1; 5 | border-radius: 3px; 6 | box-sizing: border-box; 7 | height: 25px; 8 | } 9 | button { 10 | background: linear-gradient(white 2%, #f6f5f3 4%, #edebe9); 11 | } 12 | 13 | .registration-form { 14 | display: flex; 15 | flex-direction: column; 16 | max-width: 256px; 17 | margin-bottom: 8px; 18 | } 19 | .registration-form .buttons-wrap { 20 | display: flex; 21 | flex-wrap: wrap; 22 | } 23 | .registration-form .buttons-wrap button { 24 | flex-grow: 1; 25 | } 26 | .registration-form input, 27 | .registration-form button { 28 | margin: 6px 0; 29 | } 30 | .registration-form button:not([type='submit']) { 31 | border: none; 32 | background: none; 33 | color: gray; 34 | } 35 | .registration-form .auth-error { 36 | color: darkred; 37 | } 38 | -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | repo_dir=`dirname "$0"`"/.." 5 | 6 | static_dir="${1%/}" 7 | 8 | function usage { 9 | echo "usage: $0 static_dir [--migrate] [--restart]" 10 | } 11 | if [ "$0" == "-h" ] || [ "$0" == "--help" ]; then usage; exit 2; fi 12 | if [ "$static_dir" == "" ]; then usage; exit 1; fi 13 | 14 | migrate=false 15 | restart=false 16 | for i in "$@"; do 17 | case $i in 18 | --migrate) migrate=true;; 19 | --restart) restart=true;; 20 | esac 21 | done 22 | 23 | find "$repo_dir/www/dist" -regex '.*\.\(js\|css\)$' -exec gzip -k5f "{}" \; 24 | cp -r "$repo_dir/www/dist" "$static_dir" 25 | 26 | if [ $migrate = true ]; then 27 | go run "$repo_dir"/migrations/*.go 28 | fi 29 | 30 | if [ $restart = true ]; then 31 | for name in storjnet-http storjnet-update storjnet-tgbot storjnet-probe; do 32 | sudo systemctl restart $name 33 | done 34 | fi 35 | -------------------------------------------------------------------------------- /migrations/020_node_stats_ip_types.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | ALTER TABLE nodes ADD COLUMN ip_type text; 9 | 10 | CREATE TABLE storjnet.autonomous_systems ( 11 | number bigint PRIMARY KEY, 12 | name text CHECK(name IS NULL OR name != ''), 13 | type text CHECK(type IS NULL OR type != ''), 14 | created_at timestamptz NOT NULL DEFAULT now(), 15 | updated_at timestamptz NOT NULL DEFAULT now() 16 | ); 17 | 18 | ALTER TABLE node_stats ADD COLUMN ip_types jsonb NOT NULL DEFAULT '{}'::jsonb; 19 | `) 20 | }, func(db migrations.DB) error { 21 | return execSome(db, ` 22 | ALTER TABLE nodes DROP COLUMN ip_type; 23 | 24 | DROP TABLE storjnet.autonomous_systems; 25 | 26 | ALTER TABLE node_stats DROP COLUMN ip_types; 27 | `) 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /migrations/023_partition_user_nodes_history.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | ALTER TABLE storjnet.user_nodes_history RENAME TO user_nodes_history__current; 9 | 10 | CREATE TABLE storjnet.user_nodes_history 11 | (LIKE storjnet.user_nodes_history__current INCLUDING ALL) 12 | PARTITION BY RANGE (date); 13 | 14 | ALTER TABLE storjnet.user_nodes_history 15 | ATTACH PARTITION storjnet.user_nodes_history__current 16 | DEFAULT; 17 | `) 18 | }, func(db migrations.DB) error { 19 | return execSome(db, ` 20 | ALTER TABLE storjnet.user_nodes_history DETACH PARTITION storjnet.user_nodes_history__current; 21 | DROP TABLE storjnet.user_nodes_history; 22 | ALTER TABLE storjnet.user_nodes_history__current RENAME TO user_nodes_history; 23 | `) 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /utils/stuff.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/binary" 6 | "encoding/hex" 7 | "math" 8 | "os" 9 | 10 | "github.com/ansel1/merry" 11 | ) 12 | 13 | func RandHexString(n int) string { 14 | buf := make([]byte, n/2) 15 | if _, err := rand.Read(buf); err != nil { 16 | panic(err) 17 | } 18 | return hex.EncodeToString(buf) 19 | } 20 | 21 | func RequireEnv(key string) (string, error) { 22 | value, ok := os.LookupEnv(key) 23 | if !ok { 24 | return "", merry.New("missing required env variable " + key) 25 | } 26 | return value, nil 27 | } 28 | 29 | func CopyFloat32SliceToBuf(buf []byte, arr []float32) { 30 | for i, val := range arr { 31 | binary.LittleEndian.PutUint32(buf[i*4:], math.Float32bits(val)) 32 | } 33 | } 34 | 35 | func CopyInt32SliceToBuf(buf []byte, arr []int32) { 36 | for i, val := range arr { 37 | binary.LittleEndian.PutUint32(buf[i*4:], uint32(val)) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /utils/countries.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "strings" 4 | 5 | //go:generate go run countries_generator.go 6 | //go:generate gofmt -w countries_generated.go 7 | 8 | type Country struct{ A2, A3, EN, RU string } 9 | 10 | var CountryA2ToA3 map[string]string 11 | var CountryByA3 map[string]*Country 12 | 13 | func init() { 14 | CountryA2ToA3 = make(map[string]string, len(Countries)) 15 | CountryByA3 = make(map[string]*Country, len(Countries)) 16 | for _, c := range Countries { 17 | CountryA2ToA3[c.A2] = c.A3 18 | CountryByA3[c.A3] = c 19 | } 20 | } 21 | 22 | func CountryA2ToA3IfExists(maybeA2 string) string { 23 | a3, ok := CountryA2ToA3[strings.ToLower(maybeA2)] 24 | if ok { 25 | return a3 26 | } 27 | return maybeA2 28 | } 29 | 30 | func CountryA3ToName(maybeA3, lang string) (string, bool) { 31 | country, ok := CountryByA3[strings.ToLower(maybeA3)] 32 | if ok { 33 | if lang == "ru" { 34 | return country.RU, true 35 | } 36 | return country.EN, true 37 | } 38 | return maybeA3, false 39 | } 40 | -------------------------------------------------------------------------------- /utils/worker.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "github.com/ansel1/merry" 4 | 5 | type Worker interface { 6 | Done() 7 | AddError(error) 8 | PopError() error 9 | CloseAndWait() error 10 | } 11 | 12 | type SimpleWorker struct { 13 | errChan chan error 14 | doneChan chan struct{} 15 | } 16 | 17 | func NewSimpleWorker(count int) *SimpleWorker { 18 | return &SimpleWorker{ 19 | errChan: make(chan error, 1), 20 | doneChan: make(chan struct{}, count), 21 | } 22 | } 23 | 24 | func (w SimpleWorker) Done() { 25 | w.doneChan <- struct{}{} 26 | } 27 | 28 | func (w SimpleWorker) AddError(err error) { 29 | w.errChan <- merry.WrapSkipping(err, 1) 30 | } 31 | 32 | func (w SimpleWorker) PopError() error { 33 | select { 34 | case err := <-w.errChan: 35 | return err 36 | default: 37 | return nil 38 | } 39 | } 40 | 41 | func (w SimpleWorker) CloseAndWait() error { 42 | for i := 0; i < cap(w.doneChan); i++ { 43 | <-w.doneChan 44 | } 45 | close(w.doneChan) 46 | close(w.errChan) 47 | return w.PopError() 48 | } 49 | -------------------------------------------------------------------------------- /migrations/009_node_stats.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | CREATE TABLE storjnet.node_stats ( 9 | id serial PRIMARY KEY, 10 | created_at timestamptz NOT NULL DEFAULT NOW(), 11 | count_total integer NOT NULL, 12 | active_count_hours jsonb NOT NULL, 13 | all_sat_offers_count_hours jsonb NOT NULL, 14 | per_sat_offers_count_hours jsonb NOT NULL, 15 | countries jsonb NOT NULL, 16 | ports jsonb NOT NULL 17 | ); 18 | CREATE TABLE storjnet.node_daily_stats ( 19 | date date NOT NULL, 20 | kind text NOT NULL, 21 | node_ids bytea[] NOT NULL, 22 | come_node_ids bytea[] NOT NULL, 23 | left_node_ids bytea[] NOT NULL, 24 | PRIMARY KEY (date, kind) 25 | ); 26 | `) 27 | }, func(db migrations.DB) error { 28 | return execSome(db, ` 29 | DROP TABLE storjnet.node_stats; 30 | DROP TABLE storjnet.node_daily_stats; 31 | `) 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /migrations/008_nodes.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | CREATE TABLE storjnet.nodes ( 9 | id bytea PRIMARY KEY, 10 | ip_addr inet NOT NULL, 11 | port integer NOT NULL, 12 | location jsonb, 13 | created_at timestamptz NOT NULL DEFAULT NOW(), 14 | last_received_from_sat_at timestamptz NOT NULL, 15 | updated_at timestamptz, 16 | checked_at timestamptz, 17 | CHECK (length(id) = 32), 18 | CHECK (port > 0 AND port <= 65535) 19 | ); 20 | CREATE TABLE storjnet.nodes_sat_offers ( 21 | node_id bytea, 22 | satellite_name text, 23 | stamps timestamptz[] NOT NULL DEFAULT '{}', 24 | PRIMARY KEY (node_id, satellite_name), 25 | CHECK (length(node_id) = 32), 26 | CHECK (length(satellite_name) > 0) 27 | ); 28 | `) 29 | }, func(db migrations.DB) error { 30 | return execSome(db, ` 31 | DROP TABLE storjnet.nodes_sat_offers; 32 | DROP TABLE storjnet.nodes; 33 | `) 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /www/src/components/nodes_subnet_summary.css: -------------------------------------------------------------------------------- 1 | .node-subnets-summary-wrap { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: flex-start; 5 | flex-wrap: wrap; 6 | justify-content: space-around; 7 | } 8 | .node-subnets-summary-wrap table { 9 | margin-left: 24px; 10 | margin-right: 24px; 11 | } 12 | 13 | .node-subnets-table tbody td { 14 | text-align: right; 15 | } 16 | .node-subnets-table tbody tr:last-child { 17 | border-bottom: none; 18 | } 19 | 20 | .node-subnet-sizes-table tbody td:last-child { 21 | text-align: right; 22 | } 23 | .node-subnet-sizes-table tbody tr:last-child td { 24 | text-align: center; 25 | } 26 | .node-subnet-sizes-table tbody tr:last-child { 27 | border-bottom: none; 28 | } 29 | 30 | .node-ip-types-table tbody td.name { 31 | max-width: 320px; 32 | } 33 | .node-ip-types-table tbody td.type { 34 | text-align: center; 35 | } 36 | .node-ip-types-table tbody td.count { 37 | text-align: right; 38 | } 39 | .node-ip-types-table tbody tr.expanded { 40 | font-weight: bold; 41 | } 42 | .node-ip-types-table tbody tr:last-child { 43 | border-bottom: none; 44 | } 45 | -------------------------------------------------------------------------------- /scripts/crontab.example: -------------------------------------------------------------------------------- 1 | * * * * * /home/storj/storjnet/storjnet check-versions --tg-bot-token= --github-oauth-token= 2 | 3 | #5 * * * * /home/storj/storjnet/scripts/daily_backup.sh 4 | 5 | ETHERSCAN_API_KEY= 6 | */30 * * * * /home/storj/storjnet/storjnet fetch-transactions 7 | 8 | STORJ_API_KEY= 9 | * * * * * /home/storj/storjnet/storjnet fetch-nodes --satellite=121RTSDpyNZVcEU84Ticf2L1ntiuUimbWgfATz21tuvgk3vzoA6@asia-east-1.tardigrade.io:7777 10 | STORJ_API_KEY= 11 | * * * * * /home/storj/storjnet/storjnet fetch-nodes --satellite=12EayRS2V1kEsWESU9QMRseFhdxYxKicsiFmxrsLZHeLUtdps3S@us-central-1.tardigrade.io:7777 12 | STORJ_API_KEY= 13 | * * * * * /home/storj/storjnet/storjnet fetch-nodes --satellite=12L9ZFwhzVpuEKMUNUqkaTLGzwY9G24tbiigLiXpmZWKwmcNDDs@europe-west-1.tardigrade.io:7777 14 | 15 | 10 * * * * /home/storj/storjnet/storjnet stat-nodes 16 | 17 | 05 */3 * * * /home/storj/storjnet/storjnet snap-node-locations 18 | 19 | MAXMIND_KEY= 20 | 30 10 * * 1 scripts/geoip_db_load.sh GeoLite2-ASN 21 | 35 10 * * 1 scripts/geoip_db_load.sh GeoLite2-City 22 | 23 | 15 4 * * * /home/storj/storjnet/storjnet optimize-db -------------------------------------------------------------------------------- /core/appconfig.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "github.com/ansel1/merry" 5 | "github.com/go-pg/pg/v10" 6 | "github.com/go-pg/pg/v10/orm" 7 | ) 8 | 9 | type DBTx interface { 10 | QueryOne(model, query interface{}, params ...interface{}) (orm.Result, error) 11 | Exec(query interface{}, params ...interface{}) (orm.Result, error) 12 | } 13 | 14 | func appConfigVal(db DBTx, key string, val interface{}, forUpdate bool) error { 15 | sql := "SELECT value FROM appconfig WHERE key = ?" 16 | if forUpdate { 17 | sql += " FOR UPDATE" 18 | } 19 | _, err := db.QueryOne(val, sql, key) 20 | return err 21 | } 22 | 23 | func AppConfigInt64Slice(db DBTx, key string, forUpdate bool) ([]int64, error) { 24 | var res struct{ Value []int64 } 25 | err := appConfigVal(db, key, &res, forUpdate) 26 | if err == pg.ErrNoRows { 27 | return nil, nil 28 | } 29 | if err != nil { 30 | return nil, merry.Wrap(err) 31 | } 32 | return res.Value, nil 33 | } 34 | 35 | func AppConfigSet(db DBTx, key string, value interface{}) error { 36 | _, err := db.Exec(` 37 | INSERT INTO appconfig (key, value) VALUES (?,?) 38 | ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, 39 | key, value) 40 | return merry.Wrap(err) 41 | } 42 | -------------------------------------------------------------------------------- /www/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "storjnet", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "dependencies": { 7 | "@babel/core": "^7.14.6", 8 | "@rollup/plugin-babel": "^6.0.3", 9 | "babel-plugin-htm": "^3.0.0", 10 | "concat-with-sourcemaps": "^1.1.0", 11 | "gl-matrix": "^3.4.3", 12 | "htm": "^3.0.4", 13 | "locmap": "github:3bl3gamer/locmap#v1.8.0", 14 | "preact": "^10.5.13", 15 | "rollup": "^2.79.2", 16 | "rollup-plugin-node-resolve": "^5.2.0", 17 | "rollup-plugin-terser": "^7.0.2", 18 | "rollup-pluginutils": "^2.8.2", 19 | "unistore": "^3.5.2" 20 | }, 21 | "devDependencies": { 22 | "eslint": "^8.2.0", 23 | "eslint-config-prettier": "^8.3.0", 24 | "eslint-plugin-prettier": "^4.0.0", 25 | "prettier": "^2.3.2", 26 | "rollup-plugin-livereload": "^2.0.5", 27 | "rollup-plugin-serve": "^1.1.0", 28 | "rollup-plugin-visualizer": "^5.5.0", 29 | "typescript": "^5.0.4" 30 | }, 31 | "scripts": { 32 | "test": "echo \"Error: no test specified\" && exit 1", 33 | "dev": "./node_modules/.bin/rollup --config rollup.config.js --watch", 34 | "build": "./node_modules/.bin/rollup --config rollup.config.js --environment NODE_ENV:production" 35 | }, 36 | "author": "", 37 | "license": "ISC" 38 | } 39 | -------------------------------------------------------------------------------- /migrations/021_autonomous_systems_ipinfo.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | DROP TABLE storjnet.autonomous_systems; 9 | 10 | CREATE TABLE storjnet.autonomous_systems ( 11 | number bigint PRIMARY KEY, 12 | 13 | incolumitas jsonb, 14 | incolumitas_updated_at timestamptz NOT NULL DEFAULT now(), 15 | 16 | ipinfo jsonb, 17 | ipinfo_updated_at timestamptz NOT NULL DEFAULT now(), 18 | 19 | created_at timestamptz NOT NULL DEFAULT now() 20 | ); 21 | 22 | ALTER TABLE nodes DROP COLUMN ip_type; 23 | ALTER TABLE nodes ADD COLUMN asn bigint; 24 | `) 25 | }, func(db migrations.DB) error { 26 | return execSome(db, ` 27 | DROP TABLE storjnet.autonomous_systems; 28 | 29 | CREATE TABLE storjnet.autonomous_systems ( 30 | number bigint PRIMARY KEY, 31 | name text CHECK(name IS NULL OR name != ''), 32 | type text CHECK(type IS NULL OR type != ''), 33 | created_at timestamptz NOT NULL DEFAULT now(), 34 | updated_at timestamptz NOT NULL DEFAULT now() 35 | ); 36 | 37 | ALTER TABLE nodes ADD COLUMN ip_type text; 38 | ALTER TABLE nodes DROP COLUMN asn; 39 | `) 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /www/src/utils/preact_compat.js: -------------------------------------------------------------------------------- 1 | // direct imports to avoid kilobytes of side-effects from 'preact/compat' 2 | import { PureComponent as PureComponent_ } from 'preact/compat/src/PureComponent' 3 | import { createPortal as createPortal_ } from 'preact/compat/src/portals' 4 | import { memo as memo_ } from 'preact/compat/src/memo' 5 | 6 | // overriding type definition by preact/compat/index.d.ts since src/PureComponent is typed incorrectly 7 | export const PureComponent = /** @type {typeof import('preact/compat').PureComponent} */ ( 8 | /** @type {*} */ (PureComponent_) 9 | ) 10 | 11 | // completelly overriding type: createPortal actually CAN accept array in first argument 12 | export const createPortal = 13 | /** @type {(vnode: preact.VNode<{}>|preact.VNode<{}>[], container: Element) => preact.VNode} */ ( 14 | createPortal_ 15 | ) 16 | 17 | /** 18 | * @template TProps 19 | * @template {import('preact').VNode | import('preact').VNode[] | null} TRet 20 | * @template {(props:TProps) => TRet} C 21 | * @param {C} c 22 | * @param {(prev:TProps, next:TProps) => boolean} [comparer] 23 | * @returns {C} 24 | */ 25 | export function memo(c, comparer) { 26 | // extra wrapper, because JSDoc @function does not work with plain memo_ 27 | return /** @type {*} */ (memo_)(c, comparer) 28 | } 29 | -------------------------------------------------------------------------------- /migrations/024_network_companies.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | CREATE TABLE storjnet.network_companies ( 9 | id serial PRIMARY KEY, 10 | ip_from inet NOT NULL, 11 | ip_to inet NOT NULL, 12 | incolumitas jsonb, 13 | incolumitas_updated_at timestamptz, 14 | created_at timestamptz NOT NULL DEFAULT now() 15 | ); 16 | CREATE INDEX network_companies__ip_range__index ON network_companies (ip_from ASC, ip_to DESC); 17 | 18 | CREATE TYPE storjnet.autonomous_system_info_source AS ENUM ('incolumitas', 'ipinfo'); 19 | 20 | CREATE TABLE storjnet.autonomous_systems_prefixes ( 21 | prefix cidr NOT NULL, 22 | number bigint NOT NULL, 23 | source autonomous_system_info_source NOT NULL, 24 | created_at timestamptz NOT NULL DEFAULT now(), 25 | updated_at timestamptz NOT NULL DEFAULT now(), 26 | PRIMARY KEY (prefix, number, source) 27 | ); 28 | `) 29 | }, func(db migrations.DB) error { 30 | return execSome(db, ` 31 | DROP TABLE storjnet.network_companies; 32 | DROP TABLE storjnet.autonomous_systems_prefixes; 33 | DROP TYPE storjnet.autonomous_system_info_source; 34 | `) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /www/templates/user_dashboard.html: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | {{.L.Loc "My nodes" "ru" "Мои ноды"}} 3 | {{end}} 4 | 5 | {{define "content"}} 6 | 7 | {{if .User}} 8 | 9 | 10 |
11 |
12 | 13 | {{if .UserText}} 14 |
{{.UserText}}
15 | {{else}} 16 |
17 | {{- if .L.Is "ru"}}
18 | Сюда можно вывести произвольный текст, отправив его в теле POST-запроса на storjnet.info/api/user_texts с Basic Auth.
19 | 
20 | Простую строку можно отправи так:
21 | curl --user username:password --data 'some data' https://storjnet.info/api/user_texts
22 | 
23 | Выхлоп скрипта — так:
24 | sh print_stats.sh | curl -u username:password --data-binary  @- https://storjnet.info/api/user_texts
25 | {{else}}
26 | You can display arbitrary text here by sending it in the POST request body to storjnet.info/api/user_texts with Basic Auth.
27 | 
28 | Simple line can be sent with:
29 | curl --user username:password --data 'some data' https://storjnet.info/api/user_texts
30 | 
31 | Some script output — with:
32 | sh print_stats.sh | curl -u username:password --data-binary  @- https://storjnet.info/api/user_texts
33 | {{end -}}
34 | 
35 | {{end}} 36 | 37 | {{else}} 38 |

39 | {{end}} 40 | 41 | {{end}} -------------------------------------------------------------------------------- /www/src/components/help.css: -------------------------------------------------------------------------------- 1 | button.help { 2 | background: #eee; 3 | color: gray; 4 | border: none; 5 | border-radius: 50%; 6 | width: 20px; 7 | height: 20px; 8 | padding: 0; 9 | transition: background-color ease 0.1s, color ease 0.1s; 10 | } 11 | button.help:hover { 12 | background: #ddd; 13 | color: black; 14 | } 15 | 16 | button.help-line { 17 | background: none; 18 | border: none; 19 | padding: 0; 20 | text-decoration: underline; 21 | text-decoration-style: dashed; 22 | font: inherit; 23 | cursor: pointer; 24 | user-select: text; 25 | } 26 | 27 | .popup { 28 | position: fixed; 29 | display: flex; 30 | flex-direction: column; 31 | justify-content: space-around; 32 | align-items: center; 33 | width: 100%; 34 | height: 100%; 35 | background-color: rgba(0, 0, 0, 0.5); 36 | } 37 | .popup .popup-frame { 38 | position: relative; 39 | display: inline-block; 40 | background-color: white; 41 | border-radius: 2px; 42 | max-width: min(800px, 90vw); 43 | max-height: 90vh; 44 | } 45 | .popup .popup-close { 46 | position: absolute; 47 | top: 0; 48 | right: 0; 49 | width: 25px; 50 | background: none; 51 | border: none; 52 | transition: transform ease 0.1s; 53 | } 54 | .popup .popup-close:hover { 55 | transform: scale(1.2); 56 | } 57 | .popup .popup-content { 58 | padding: 3px 14px; /*10px 16px*/ 59 | box-sizing: border-box; 60 | overflow-y: auto; 61 | max-height: 100%; 62 | } 63 | -------------------------------------------------------------------------------- /www/src/errors.js: -------------------------------------------------------------------------------- 1 | window.addEventListener('error', e => { 2 | // looks like plugin error, ignoring it 3 | if (e.message === 'Script error.' && e.filename === '') return 4 | // usually, e.message already prefixed with "Uncaught Error:" 5 | const message = `${e.message} in ${e.filename}:${e.lineno}:${e.colno}` 6 | sendError(message, e.error) 7 | }) 8 | 9 | window.addEventListener('unhandledrejection', e => { 10 | // in case of Promise.reject("string") error will have no message/stack; passing that "reason" as plain text 11 | const message = 12 | 'Unhandled Rejection: ' + (e.reason && e.reason.message ? e.reason.message : e.reason + '') 13 | sendError(message, e.reason) 14 | }) 15 | 16 | /** 17 | * @param {string} message 18 | * @param {Error} [error] 19 | */ 20 | export function sendError(message, error) { 21 | const stack = error && error.stack 22 | const body = JSON.stringify({ message, stack, url: location.href }) 23 | const headers = { 'Content-Type': 'application/json' } 24 | fetch('/api/client_errors', { method: 'POST', headers, credentials: 'same-origin', body }) 25 | } 26 | 27 | export function isAbortError(error) { 28 | return typeof error === 'object' && error !== null && error.name === 'AbortError' 29 | } 30 | 31 | export function onError(error) { 32 | if (isAbortError(error)) return 33 | // eslint-disable-next-line no-console 34 | console.error(error) 35 | sendError(error + '', error) 36 | } 37 | -------------------------------------------------------------------------------- /core/ip_companies_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "encoding/json" 5 | "net/netip" 6 | "testing" 7 | ) 8 | 9 | func Test_ipNetwork(t *testing.T) { 10 | tests := []struct { 11 | src, ipFrom, ipTo string 12 | }{ 13 | {src: `"0.0.0.0 - 255.255.255.255"`, ipFrom: "0.0.0.0", ipTo: "255.255.255.255"}, 14 | {src: `"0.1.2.3 - 1.2.3.4"`, ipFrom: "0.1.2.3", ipTo: "1.2.3.4"}, 15 | 16 | {src: `"1.2.3.4/25"`, ipFrom: "1.2.3.0", ipTo: "1.2.3.127"}, 17 | {src: `"1.2.3.128/25"`, ipFrom: "1.2.3.128", ipTo: "1.2.3.255"}, 18 | {src: `"1.2.3.4/24"`, ipFrom: "1.2.3.0", ipTo: "1.2.3.255"}, 19 | {src: `"1.2.3.4/23"`, ipFrom: "1.2.2.0", ipTo: "1.2.3.255"}, 20 | 21 | {src: `"2001:4002:: - 2001:4003::"`, ipFrom: "2001:4002::", ipTo: "2001:4003::"}, 22 | {src: `"2001:4002::/33"`, ipFrom: "2001:4002::", ipTo: "2001:4002:7fff:ffff:ffff:ffff:ffff:ffff"}, 23 | {src: `"2001:4002::/32"`, ipFrom: "2001:4002::", ipTo: "2001:4002:ffff:ffff:ffff:ffff:ffff:ffff"}, 24 | {src: `"2001:4002::/31"`, ipFrom: "2001:4002::", ipTo: "2001:4003:ffff:ffff:ffff:ffff:ffff:ffff"}, 25 | } 26 | for _, test := range tests { 27 | var network ipNetwork 28 | if err := json.Unmarshal([]byte(test.src), &network); err != nil { 29 | t.Fatal(err) 30 | } 31 | if network.IPFrom.Compare(netip.MustParseAddr(test.ipFrom)) != 0 { 32 | t.Errorf("%s: %s != %s", test.src, network.IPFrom, test.ipFrom) 33 | } 34 | if network.IPTo.Compare(netip.MustParseAddr(test.ipTo)) != 0 { 35 | t.Errorf("%s: %s != %s", test.src, network.IPTo, test.ipTo) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /www/src/index.js: -------------------------------------------------------------------------------- 1 | import './errors' 2 | import './shame' 3 | 4 | import './main.css' 5 | 6 | import { renderIfExists } from './utils/elems' 7 | import { StorjTxSummary } from './components/storj_tx_summary' 8 | import { NodesLocationSummary } from './components/nodes_location_summary' 9 | import { NodesCountChart } from './components/nodes_count_chart' 10 | import { RewindControl } from './components/rewind_control' 11 | import { SatsPingsCharts } from './components/pings_chart' 12 | import { PingMyNode } from './components/ping_my_node' 13 | import { AuthForm } from './components/auth' 14 | import { SearchNeighbors } from './components/search_neighbors' 15 | import { CheckSanctions } from './components/check_sanctions' 16 | import { UserDashboardNodes, UserDashboardPings } from './components/user_dashboard' 17 | import { NodesSubnetSummary } from './components/nodes_subnet_summary' 18 | 19 | renderIfExists(AuthForm, '.auth-forms') 20 | renderIfExists(RewindControl, '.rewind-control') 21 | renderIfExists(SatsPingsCharts, '.sat-nodes') 22 | renderIfExists(StorjTxSummary, '.storj-tx-summary') 23 | renderIfExists(NodesCountChart, '.nodes-count-chart') 24 | renderIfExists(NodesLocationSummary, '.nodes-location-summary') 25 | renderIfExists(NodesSubnetSummary, '.nodes-subnet-summary') 26 | renderIfExists(PingMyNode, '.ping-my-node') 27 | renderIfExists(SearchNeighbors, '.search-neighbors') 28 | renderIfExists(CheckSanctions, '.check-sanctions') 29 | renderIfExists(UserDashboardNodes, '.user-dashboard-nodes') 30 | renderIfExists(UserDashboardPings, '.user-dashboard-pings') 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # StorjNet 2 | 3 | ## Useful APIs 4 | 5 | ### GET /api/neighbors/\ 6 | 7 | Where `subnet` may be actual subnet address like `1.2.3.0` or just IP `1.2.3.4`. 8 | 9 | **Response** 10 | 11 | ```json 12 | {"ok": true, "result": {"count": 3}} 13 | ``` 14 | 15 | ### POST /api/neighbors 16 | 17 | **Request payload** 18 | 19 | ```json 20 | { 21 | "subnets": ["1.2.3.4", "2.3.4.0", "2.3.5.0"], 22 | "myNodeIds": ["1AaaAA","1AaaAB","1AaaAC"] 23 | } 24 | ``` 25 | 26 | * `subnets` — list of subnets/IPs; 27 | * `myNodeIds` — optional list of node IDs to count foreign nodes. 28 | 29 | **Response** 30 | 31 | ```json 32 | { 33 | "ok": true, 34 | "result": { 35 | "counts": [ 36 | { 37 | "subnet": "1.2.3.0", 38 | "nodesTotal": 4, 39 | "foreignNodesCount": 2 40 | }, 41 | { 42 | "subnet": "2.3.4.0", 43 | "nodesTotal": 1, 44 | "foreignNodesCount": 0 45 | } 46 | ] 47 | } 48 | } 49 | ``` 50 | 51 | * `subnet` — requested subnet (with trailing `.0`); 52 | * `nodesTotal` — total nodes in subnet; 53 | * `foreignNodesCount` — count of subnet nodes except `myNodeIds`. 54 | 55 | Items will be absent for empty subnets. 56 | 57 | ## DB setup 58 | ```bash 59 | sudo su - postgres 60 | createuser storjnet -P # with password "storj" 61 | createdb storjnet_db -O storjnet --echo 62 | psql storjnet_db -c "CREATE SCHEMA storjnet AUTHORIZATION storjnet" 63 | psql storjnet_db -c "CREATE EXTENSION pgcrypto WITH SCHEMA storjnet" 64 | 65 | go run migrations/*.go init 66 | go run migrations/*.go 67 | ``` 68 | -------------------------------------------------------------------------------- /migrations/005_storj_transactions.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | CREATE TABLE storjnet.storj_token_addresses ( 9 | addr bytea PRIMARY KEY CHECK (length(addr) = 20), 10 | last_block_number int NOT NULL, 11 | created_at timestamptz NOT NULL DEFAULT NOW(), 12 | updated_at timestamptz NOT NULL DEFAULT NOW() 13 | ); 14 | CREATE TABLE storjnet.storj_token_transactions ( 15 | hash bytea PRIMARY KEY CHECK (length(hash) = 32), 16 | block_number int NOT NULL, 17 | created_at timestamptz NOT NULL, 18 | addr_from bytea NOT NULL CHECK (length(addr_from) = 20), 19 | addr_to bytea NOT NULL CHECK (length(addr_to) = 20), 20 | value float8 NOT NULL 21 | ); 22 | CREATE INDEX storj_token_transactions__created_at ON storjnet.storj_token_transactions (created_at); 23 | CREATE TABLE storjnet.storj_token_tx_summaries ( 24 | date date PRIMARY KEY, 25 | preparings float4[] NOT NULL CHECK (array_dims(preparings) = '[1:24]'), 26 | payouts float4[] NOT NULL CHECK (array_dims(payouts) = '[1:24]'), 27 | payout_counts int[] NOT NULL CHECK (array_dims(payout_counts) = '[1:24]'), 28 | withdrawals float4[] NOT NULL CHECK (array_dims(withdrawals) = '[1:24]') 29 | ) 30 | `) 31 | }, func(db migrations.DB) error { 32 | return execSome(db, ` 33 | DROP TABLE storjnet.storj_token_addresses; 34 | DROP TABLE storjnet.storj_token_transactions; 35 | DROP TABLE storjnet.storj_token_tx_summaries; 36 | `) 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /www/src/components/user_dashboard.js: -------------------------------------------------------------------------------- 1 | import createStore from 'unistore' 2 | 3 | import { sortNodes } from 'src/utils/nodes' 4 | import { UserNodesList } from './user_nodes' 5 | import { PingsChartsList } from './pings_chart' 6 | import { getJSONContent } from 'src/utils/elems' 7 | 8 | import './user_dashboard.css' 9 | import { h } from 'preact' 10 | import { connectAndWrap } from 'src/utils/store' 11 | 12 | function convertFromJSON(node) { 13 | node.lastPingedAt = new Date(node.lastPingedAt) 14 | node.lastUpAt = new Date(node.lastUpAt) 15 | return node 16 | } 17 | 18 | let storeData = { nodes: [], nodesUpdateTime: new Date() } 19 | try { 20 | let data = getJSONContent('user_nodes_data') 21 | storeData.nodes = sortNodes(data.nodes.map(convertFromJSON)) 22 | storeData.nodesUpdateTime = new Date(data.updateTime) 23 | } catch (ex) { 24 | // ¯\_(ツ)_/¯ 25 | } 26 | let store = createStore(storeData) 27 | 28 | let nodesActions = { 29 | setNode(state, node) { 30 | let nodes 31 | let existing = state.nodes.find(n => n.id === node.id) 32 | if (existing) { 33 | nodes = state.nodes.slice() 34 | nodes[nodes.indexOf(existing)] = node 35 | } else { 36 | nodes = sortNodes([...state.nodes, node]) 37 | } 38 | return { nodes } 39 | }, 40 | delNode(state, node) { 41 | let nodes = state.nodes.filter(n => n.id !== node.id) 42 | return { nodes } 43 | }, 44 | } 45 | 46 | export const UserDashboardNodes = connectAndWrap( 47 | UserNodesList, 48 | store, 49 | ['nodes', 'nodesUpdateTime'], 50 | nodesActions, 51 | ) 52 | export const UserDashboardPings = connectAndWrap( 53 | props => h(PingsChartsList, { ...props, group: 'my' }), 54 | store, 55 | 'nodes', 56 | nodesActions, 57 | ) 58 | -------------------------------------------------------------------------------- /migrations/001_init.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | CREATE TABLE storjnet.users ( 9 | id serial PRIMARY KEY, 10 | email text UNIQUE, 11 | username text NOT NULL UNIQUE, 12 | password_hash text NOT NULL, 13 | sessid uuid NOT NULL, 14 | created_at timestamptz NOT NULL DEFAULT NOW() 15 | ); 16 | 17 | CREATE TYPE storjnet.node_ping_mode AS ENUM ('off', 'dial', 'ping'); 18 | 19 | CREATE TABLE storjnet.user_nodes ( 20 | node_id bytea NOT NULL, 21 | user_id integer NOT NULL REFERENCES storjnet.users (id), 22 | address text NOT NULL, 23 | ping_mode storjnet.node_ping_mode NOT NULL DEFAULT 'off', 24 | last_pinged_at timestamptz, 25 | last_ping int, 26 | last_up_at timestamptz, 27 | created_at timestamptz NOT NULL DEFAULT NOW(), 28 | CHECK (length(node_id) = 32), 29 | PRIMARY KEY (node_id, user_id) 30 | ); 31 | 32 | CREATE TABLE storjnet.user_nodes_history ( 33 | node_id bytea NOT NULL, 34 | user_id integer NOT NULL, 35 | date date NOT NULL, 36 | pings smallint[] NOT NULL, 37 | CHECK (length(node_id) = 32), 38 | CHECK (array_dims(pings) = '[1:1440]'), 39 | PRIMARY KEY (node_id, user_id, date) 40 | -- no reference to user_nodes (so user_node can be removed and restored without loosing all history) 41 | ); 42 | `) 43 | }, func(db migrations.DB) error { 44 | return execSome(db, ` 45 | DROP TABLE storjnet.user_nodes_history; 46 | DROP TABLE storjnet.user_nodes; 47 | DROP TYPE storjnet.node_ping_mode; 48 | DROP TABLE storjnet.users; 49 | `) 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /www/src/utils/store.js: -------------------------------------------------------------------------------- 1 | import { Provider, connect as connect_ } from 'unistore/src/integrations/preact' 2 | import { html } from './htm' 3 | import { useCallback, useRef, useState } from 'preact/hooks' 4 | 5 | // fixig types. could import it just from unistore/preact, but will have to add rollup commonjs plugin 6 | const connect = /** @type {import('unistore/preact').connect} */ (/**@type {*}*/ (connect_)) 7 | 8 | export function connectAndWrap(Comp, store, mapStateToProps, actions) { 9 | let ConnectedComp = connect(mapStateToProps, actions)(Comp) 10 | return () => html` 11 | <${Provider} store=${store}> 12 | <${ConnectedComp} group="my" /> 13 | 14 | ` 15 | } 16 | 17 | /** 18 | * @template T 19 | * @param {string} key 20 | * @param {(storedVal:unknown) => T} initialState 21 | * @returns {[T, import('preact/hooks').StateUpdater]} 22 | */ 23 | export function useStorageState(key, initialState) { 24 | const [val, setValInner] = useState(() => { 25 | let storedVal = undefined 26 | try { 27 | const item = localStorage.getItem(key) 28 | storedVal = item === null ? null : JSON.parse(item) 29 | } catch (ex) { 30 | // eslint-disable-next-line no-console 31 | console.error(ex) 32 | } 33 | return initialState(storedVal) 34 | }) 35 | 36 | const keyRef = useRef(key) 37 | keyRef.current = key 38 | 39 | const setVal = useCallback((/**@type {T | ((prevState: T) => T)}*/ v) => { 40 | setValInner(oldVal => { 41 | // @ts-expect-error 42 | const newVal = typeof v === 'function' ? v(oldVal) : v 43 | try { 44 | localStorage.setItem(keyRef.current, JSON.stringify(newVal)) 45 | } catch (ex) { 46 | // eslint-disable-next-line no-console 47 | console.error(ex) 48 | } 49 | return newVal 50 | }) 51 | }, []) 52 | 53 | return [val, setVal] 54 | } 55 | -------------------------------------------------------------------------------- /www/src/components/ping_my_node.css: -------------------------------------------------------------------------------- 1 | .node-ping-form { 2 | display: flex; 3 | flex-direction: row; 4 | max-width: 620px; 5 | margin-left: 8px; 6 | margin-right: 8px; 7 | } 8 | 9 | .node-ping-form .node-address-input, 10 | .node-ping-form .node-id-input { 11 | width: 64px; 12 | } 13 | 14 | .node-ping-form > *:not(.node-id-input) { 15 | margin-left: 8px; 16 | } 17 | 18 | .node-ping-form .node-address-input { 19 | flex-grow: 1; 20 | } 21 | 22 | .node-ping-form .node-id-input { 23 | flex-grow: 3; 24 | } 25 | 26 | .remembered-nodes-list { 27 | margin-left: 8px; 28 | margin-right: 8px; 29 | margin-bottom: 16px; 30 | } 31 | 32 | .remembered-nodes-list .item { 33 | display: flex; 34 | } 35 | 36 | .remembered-nodes-list .item .node { 37 | display: flex; 38 | overflow: hidden; 39 | } 40 | 41 | .remembered-nodes-list .item .node .node-id { 42 | overflow: hidden; 43 | text-overflow: ellipsis; 44 | margin-right: 4px; 45 | } 46 | 47 | .remembered-nodes-list .item .node:hover .node-id { 48 | overflow: visible; 49 | background-color: white; 50 | word-break: break-all; 51 | } 52 | 53 | .remembered-nodes-list .item .node .node-address { 54 | margin: 0 8px; 55 | } 56 | 57 | .remembered-nodes-list .item .remove { 58 | color: darkred; 59 | text-decoration: none; 60 | border: none; 61 | background: none; 62 | margin: 0; 63 | padding: 0 8px; 64 | } 65 | 66 | .remembered-nodes-list .item .remove:hover { 67 | /* border: 1px solid darkred; */ 68 | background-color: #fdd; 69 | } 70 | 71 | .log-box { 72 | position: relative; 73 | white-space: pre-line; 74 | } 75 | .log-box.pending::before { 76 | content: '⌛'; 77 | position: absolute; 78 | left: 1px; 79 | top: 1px; 80 | } 81 | 82 | @media (max-width: 479px) { 83 | .node-ping-form { 84 | flex-wrap: wrap; 85 | } 86 | 87 | .node-ping-form .node-id-input { 88 | width: 100%; 89 | } 90 | 91 | .node-ping-form > *:not(.node-id-input) { 92 | margin-top: 4px; 93 | } 94 | 95 | .node-ping-form .node-address-input { 96 | margin-left: 0; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /migrations/017_off_stats.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/go-pg/migrations/v8" 4 | 5 | func init() { 6 | migrations.MustRegisterTx(func(db migrations.DB) error { 7 | return execSome(db, ` 8 | CREATE TABLE storjnet.off_data_stats ( 9 | id serial PRIMARY KEY, 10 | created_at timestamptz NOT NULL DEFAULT now(), 11 | satellite_id bytea NOT NULL, 12 | satellite_host text NOT NULL, 13 | bandwidth_bytes_downloaded bigint, 14 | bandwidth_bytes_uploaded bigint, 15 | storage_inline_bytes bigint, 16 | storage_inline_segments bigint, 17 | storage_median_healthy_pieces_count bigint, 18 | storage_min_healthy_pieces_count bigint, 19 | storage_remote_bytes bigint, 20 | storage_remote_segments bigint, 21 | storage_remote_segments_lost bigint, 22 | storage_total_bytes bigint, 23 | storage_total_objects bigint, 24 | storage_total_pieces bigint, 25 | storage_total_segments bigint, 26 | storage_free_capacity_estimate_bytes bigint, 27 | CHECK (length(satellite_id) = 32) 28 | ); 29 | CREATE TABLE storjnet.off_node_stats ( 30 | id serial PRIMARY KEY, 31 | created_at timestamptz NOT NULL DEFAULT now(), 32 | satellite_id bytea NOT NULL, 33 | satellite_host text NOT NULL, 34 | active_nodes int, 35 | disqualified_nodes int, 36 | exited_nodes int, 37 | offline_nodes int, 38 | suspended_nodes int, 39 | total_nodes int, 40 | vetted_nodes int, 41 | CHECK (length(satellite_id) = 32) 42 | ); 43 | CREATE TABLE storjnet.off_account_stats ( 44 | id serial PRIMARY KEY, 45 | satellite_id bytea NOT NULL, 46 | satellite_host text NOT NULL, 47 | created_at timestamptz NOT NULL DEFAULT now(), 48 | registered_accounts int, 49 | CHECK (length(satellite_id) = 32) 50 | ); 51 | `) 52 | }, func(db migrations.DB) error { 53 | return execSome(db, ` 54 | DROP TABLE storjnet.off_data_stats; 55 | DROP TABLE storjnet.off_node_stats; 56 | DROP TABLE storjnet.off_account_stats; 57 | `) 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /utils/countries_generator.go: -------------------------------------------------------------------------------- 1 | //go:build exclude 2 | 3 | package main 4 | 5 | import ( 6 | "encoding/json" 7 | "fmt" 8 | "log" 9 | "net/http" 10 | "os" 11 | "strings" 12 | 13 | "github.com/ansel1/merry" 14 | ) 15 | 16 | func main() { 17 | if err := mainErr(); err != nil { 18 | log.Fatal(merry.Details(err)) 19 | } 20 | } 21 | 22 | func mainErr() error { 23 | resp, err := http.Get("https://raw.githubusercontent.com/stefangabos/world_countries/master/data/countries/_combined/world.json") 24 | if err != nil { 25 | return merry.Wrap(err) 26 | } 27 | var items []*struct{ Alpha2, Alpha3, EN, RU string } 28 | if err := json.NewDecoder(resp.Body).Decode(&items); err != nil { 29 | return merry.Wrap(err) 30 | } 31 | 32 | for _, item := range items { 33 | if strings.HasSuffix(item.EN, " of") { 34 | index := strings.LastIndex(item.EN, ",") 35 | item.EN = item.EN[:index] 36 | } 37 | if item.Alpha3 == "twn" { 38 | item.EN = "Taiwan" 39 | item.RU = "Тайвань" 40 | } 41 | if item.Alpha3 == "prk" { 42 | item.EN = "North Korea" 43 | item.RU = "Северная Корея" 44 | } 45 | if item.Alpha3 == "kor" { 46 | item.EN = "South Korea" 47 | item.RU = "Южная Корея" 48 | } 49 | if item.Alpha3 == "vnm" { 50 | item.EN = "Vietnam" 51 | } 52 | if item.Alpha3 == "usa" { 53 | item.EN = "United States" 54 | } 55 | if item.Alpha3 == "gbr" { 56 | item.EN = "United Kingdom" 57 | } 58 | if item.Alpha3 == "rus" { 59 | item.EN = "Russia" 60 | } 61 | if item.Alpha3 == "chn" { 62 | item.RU = "Китай" 63 | } 64 | } 65 | 66 | s := "package utils\nvar Countries = []*Country{\n" 67 | for _, item := range items { 68 | s += fmt.Sprintf("{A2: `%s`, A3: `%s`, EN: `%s`, RU: `%s`},\n", 69 | item.Alpha2, item.Alpha3, item.EN, item.RU) 70 | } 71 | s += "}" 72 | 73 | f, err := os.Create("countries_generated.go") 74 | if err != nil { 75 | return merry.Wrap(err) 76 | } 77 | if _, err := f.Write([]byte(s)); err != nil { 78 | return merry.Wrap(err) 79 | } 80 | if err := f.Close(); err != nil { 81 | return merry.Wrap(err) 82 | } 83 | return nil 84 | } 85 | -------------------------------------------------------------------------------- /versions/checker.go: -------------------------------------------------------------------------------- 1 | package versions 2 | 3 | import ( 4 | "net/http" 5 | "storjnet/core" 6 | "storjnet/utils" 7 | "time" 8 | 9 | "github.com/ansel1/merry" 10 | tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api" 11 | "github.com/rs/zerolog/log" 12 | "golang.org/x/net/proxy" 13 | ) 14 | 15 | func sendTGMessages(botToken, socks5ProxyAddr, text string, chatIDs []int64) error { 16 | httpClient := &http.Client{} 17 | if socks5ProxyAddr != "" { 18 | // auth := &proxy.Auth{User: *socksUser, Password: *socksPassword} 19 | dialer, err := proxy.SOCKS5("tcp", socks5ProxyAddr, nil, proxy.Direct) 20 | if err != nil { 21 | return merry.Wrap(err) 22 | } 23 | httpTransport := &http.Transport{Dial: dialer.Dial} 24 | httpClient.Transport = httpTransport 25 | } 26 | 27 | bot, err := tgbotapi.NewBotAPIWithClient(botToken, httpClient) 28 | if err != nil { 29 | return merry.Wrap(err) 30 | } 31 | 32 | for _, chatID := range chatIDs { 33 | for i := 0; i < 3; i++ { 34 | err := utils.TGSendMessageMD(bot, chatID, text) 35 | if err == nil { 36 | break 37 | } else { 38 | log.Error().Err(err).Int("iter", i).Int64("chatID", chatID).Msg("message sending error") 39 | time.Sleep(time.Second) 40 | } 41 | } 42 | } 43 | return nil 44 | } 45 | 46 | func CheckVersions(tgBotToken, tgSocks5ProxyAddr string) error { 47 | db := utils.MakePGConnection() 48 | 49 | for _, checker := range core.MakeVersionCheckers(db) { 50 | if err := checker.FetchPrevVersion(); err != nil { 51 | return merry.Wrap(err) 52 | } 53 | if err := checker.FetchCurVersion(); err != nil { 54 | return merry.Wrap(err) 55 | } 56 | prevVersion, curVersion := checker.DebugVersions() 57 | log.Debug().Msgf("%s -> %s (%s)", prevVersion, curVersion, checker.Key()) 58 | 59 | if checker.VersionHasChanged() { 60 | text := checker.MessageNew() 61 | tgChatIDs, err := core.AppConfigInt64Slice(db, "tgbot:version_notif_ids", false) 62 | if err != nil { 63 | return merry.Wrap(err) 64 | } 65 | if err := sendTGMessages(tgBotToken, tgSocks5ProxyAddr, text, tgChatIDs); err != nil { 66 | return merry.Wrap(err) 67 | } 68 | if err := checker.SaveCurVersion(); err != nil { 69 | return merry.Wrap(err) 70 | } 71 | } 72 | } 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /www/src/components/rewind_control.js: -------------------------------------------------------------------------------- 1 | import { lang } from 'src/i18n' 2 | import { html } from 'src/utils/htm' 3 | import { PureComponent } from 'src/utils/preact_compat' 4 | import { endOfMonth, makeUpdatedHashInterval, watchHashInterval } from 'src/utils/time' 5 | 6 | const monthLangNames = { 7 | en: 'january february march april may june july august september october november december'.split(' '), 8 | ru: 'январь февраль март апрель май июнь июль август сентябрь октябрь ноябрь декабрь'.split(' '), 9 | } 10 | 11 | export class RewindControl extends PureComponent { 12 | constructor() { 13 | super() 14 | 15 | let watch = watchHashInterval((startDate, endDate) => { 16 | this.setState({ ...this.state, startDate, endDate }) 17 | }) 18 | this.stopWatchingHashInterval = watch.off 19 | 20 | this.state = { 21 | startDate: watch.startDate, 22 | endDate: watch.endDate, 23 | } 24 | } 25 | 26 | makeIntervalHash(monthDelta) { 27 | let startDate = new Date(this.state.startDate) 28 | startDate.setUTCMonth(startDate.getUTCMonth() + monthDelta) 29 | 30 | let endDate = new Date(this.state.endDate) 31 | endDate.setUTCMonth(endDate.getUTCMonth() + monthDelta) 32 | 33 | const hasSwitchedExtraMonth = 34 | Math.abs(monthDelta + 12) % 12 !== 35 | (this.state.endDate.getUTCMonth() - endDate.getUTCMonth() + 24) % 12 36 | if (hasSwitchedExtraMonth) { 37 | // so that 01-31 + 1 month = 02-28 but not 03-03 38 | endDate.setDate(endDate.getUTCDate() - 10) 39 | endDate = endOfMonth(endDate) 40 | } 41 | return makeUpdatedHashInterval(startDate, endDate) 42 | } 43 | 44 | render(props, { startDate, endDate }) { 45 | let monthNames = monthLangNames[lang] || monthLangNames.en 46 | 47 | let curMonthName = monthNames[startDate.getUTCMonth()] 48 | if (startDate.getUTCMonth() !== endDate.getUTCMonth()) 49 | curMonthName += ' — ' + monthNames[endDate.getUTCMonth()] 50 | 51 | let prevMonthName = monthNames[(startDate.getUTCMonth() + 11) % 12] 52 | let nextMonthName = monthNames[(endDate.getUTCMonth() + 1) % 12] 53 | 54 | return html`

55 | ← ${prevMonthName} 56 | ${' '}${curMonthName}${' '} 57 | ${nextMonthName} → 58 |

` 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /www/src/i18n.js: -------------------------------------------------------------------------------- 1 | export const lang = document.documentElement.lang || 'en' 2 | 3 | export function L(defaultText, ...texts) { 4 | for (let i = 0; i < texts.length; i += 2) { 5 | if (texts[i] === lang) return texts[i + 1] 6 | } 7 | return defaultText 8 | } 9 | 10 | export function pluralize(val, ...words) { 11 | if (val < 0) { 12 | val = -val 13 | } 14 | let d0 = val % 10 15 | let d10 = val % 100 16 | switch (lang) { 17 | case 'ru': 18 | if (d10 === 11 || d10 === 12 || d0 === 0 || (d0 >= 5 && d0 <= 9)) { 19 | return words[2] 20 | } 21 | if (d0 >= 2 && d0 <= 4) { 22 | return words[1] 23 | } 24 | return words[0] 25 | default: 26 | if (d10 === 11 || d10 === 12 || d0 === 0 || (d0 >= 2 && d0 <= 9)) { 27 | return words[1] 28 | } 29 | return words[0] 30 | } 31 | } 32 | 33 | /** 34 | * @param {number|null} val 35 | * @param {...string} words 36 | */ 37 | L.n = function L_n(val, ...words) { 38 | return (val === null ? '...' : val.toLocaleString(lang)) + ' ' + pluralize(val || 0, ...words) 39 | } 40 | 41 | /** 42 | * @param {number|null} val 43 | * @param {string} suffix 44 | * @param {...string} words 45 | */ 46 | L.ns = function L_ns(val, suffix, ...words) { 47 | return (val === null ? '...' : val.toLocaleString(lang)) + suffix + ' ' + pluralize(val || 0, ...words) 48 | } 49 | 50 | /** 51 | * @param {number} duration 52 | * @returns {string} 53 | */ 54 | export function stringifyDuration(duration) { 55 | const days = Math.floor(duration / (24 * 3600 * 1000)) 56 | const hours = Math.floor((duration / (3600 * 1000)) % 24) 57 | const minutes = Math.floor((duration / (60 * 1000)) % 60) 58 | const seconds = Math.floor((duration / 1000) % 60) 59 | switch (lang) { 60 | case 'ru': { 61 | let res = seconds + ' с' 62 | if (minutes !== 0) res = minutes + ' мин ' + res 63 | if (hours !== 0) res = hours + ' ч ' + res 64 | if (days !== 0) res = days + ' д ' + res 65 | return res 66 | } 67 | default: { 68 | let res = seconds + ' s' 69 | if (minutes !== 0) res = minutes + ' min ' + res 70 | if (hours !== 0) res = hours + ' h ' + res 71 | if (days !== 0) res = days + ' d ' + res 72 | return res 73 | } 74 | } 75 | } 76 | /** 77 | * @param {Date|number} date 78 | * @returns {string} 79 | */ 80 | export function ago(date) { 81 | return stringifyDuration(Date.now() - +date) 82 | } 83 | -------------------------------------------------------------------------------- /migrations/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "os" 9 | "strings" 10 | "time" 11 | 12 | "github.com/go-pg/migrations/v8" 13 | "github.com/go-pg/pg/v10" 14 | ) 15 | 16 | const usageText = `This program runs command on the db. Supported commands are: 17 | - up - runs all available migrations. 18 | - down - reverts last migration. 19 | - reset - reverts all migrations. 20 | - version - prints current db version. 21 | - set_version [version] - sets db version without running migrations. 22 | Usage: 23 | go run *.go [args] 24 | ` 25 | 26 | func usage() { 27 | fmt.Print(usageText) 28 | flag.PrintDefaults() 29 | os.Exit(2) 30 | } 31 | 32 | func execSome(db migrations.DB, queries ...string) error { 33 | for _, query := range queries { 34 | if _, err := db.Exec(query); err != nil { 35 | return err 36 | } 37 | } 38 | return nil 39 | } 40 | 41 | func align(sql string) string { 42 | lines := strings.Split(sql, "\n") 43 | 44 | minIndent := 999 45 | for _, line := range lines { 46 | if strings.TrimSpace(line) == "" { 47 | continue 48 | } 49 | for i, char := range line { 50 | if char != '\t' { 51 | if i < minIndent { 52 | minIndent = i 53 | } 54 | break 55 | } 56 | } 57 | } 58 | 59 | sql = "" 60 | for _, line := range lines { 61 | if strings.TrimSpace(line) != "" { 62 | sql += line[minIndent:] 63 | } 64 | sql += "\n" 65 | } 66 | return strings.TrimSpace(sql) 67 | } 68 | 69 | type dbLogger struct{} 70 | 71 | func (d dbLogger) BeforeQuery(ctx context.Context, event *pg.QueryEvent) (context.Context, error) { 72 | return ctx, nil 73 | } 74 | 75 | func (d dbLogger) AfterQuery(ctx context.Context, event *pg.QueryEvent) error { 76 | query, err := event.FormattedQuery() 77 | if err != nil { 78 | return err 79 | } 80 | log.Printf("\033[36m%s\n\033[34m%s\033[39m", time.Since(event.StartTime), align(string(query))) 81 | return nil 82 | } 83 | 84 | func main() { 85 | flag.Usage = usage 86 | dbname := flag.String("dbname", "storjnet_db", "database name") 87 | flag.Parse() 88 | 89 | db := pg.Connect(&pg.Options{User: "storjnet", Password: "storj", Database: *dbname}) 90 | db.AddQueryHook(dbLogger{}) 91 | 92 | migrations.SetTableName("storjnet.gopg_migrations") 93 | 94 | oldVersion, newVersion, err := migrations.Run(db, flag.Args()...) 95 | if err != nil { 96 | panic(err) 97 | } 98 | fmt.Printf("\033[32mMigrated\033[39m: %d -> %d\n", oldVersion, newVersion) 99 | } 100 | -------------------------------------------------------------------------------- /www/src/api.js: -------------------------------------------------------------------------------- 1 | import { lang } from './i18n' 2 | 3 | function encodeKeyValue(key, value) { 4 | return encodeURIComponent(key) + '=' + encodeURIComponent(value) 5 | } 6 | 7 | class APIError extends Error { 8 | constructor(method, url, res) { 9 | let msg = `${method}:${url} -> ${res.error}` 10 | if (res.description) msg += ': ' + res.description 11 | super(msg) 12 | this.error = res.error 13 | this.description = res.description 14 | } 15 | } 16 | 17 | /** 18 | * @param {'GET'|'POST'|'DELETE'} method 19 | * @param {string} path 20 | * @param {(Parameters[1] & {data?:Record})?} [params] 21 | */ 22 | export async function apiReq(method, path, params) { 23 | params = params || {} 24 | params.method = method 25 | 26 | // подефолту шлём куки 27 | if (!params.credentials) params.credentials = 'include' 28 | 29 | // если это GET-зпрос, добавляем params.data как query 30 | if ('data' in params && (!params.method || params.method === 'GET')) { 31 | let args = [] 32 | for (let key in params.data) { 33 | let value = params.data[key] 34 | if (value !== null) args.push(encodeKeyValue(key, value)) 35 | } 36 | if (args.length > 0) path += '?' + args.join('&') 37 | delete params.data 38 | } 39 | 40 | // если это не-GET-запрос, отправляем дату как ЖСОН в боди 41 | if ('data' in params && params.method !== 'GET') { 42 | params.body = JSON.stringify(params.data) 43 | delete params.data 44 | } 45 | 46 | const res = await fetch(path, params) 47 | if (res.headers.get('Content-Type') === 'application/json') { 48 | const data = await res.json() 49 | if (!data.ok) throw new APIError(method, path, data) 50 | return data.result 51 | } 52 | return res 53 | } 54 | 55 | /** 56 | * @typedef {{ 57 | * ips: Record, 66 | * }} IPsSanctionsResponse 67 | */ 68 | 69 | /** 70 | * @typedef {{reason: string, detail: string}} NodeIPSanction 71 | */ 72 | 73 | /** 74 | * @param {string[]} ips 75 | * @param {boolean} fullInfo 76 | * @param {AbortController|null} abortController 77 | * @returns {Promise} 78 | */ 79 | export function apiReqIPsSanctions(ips, fullInfo, abortController) { 80 | return apiReq('POST', '/api/ips_sanctions', { 81 | data: { ips, lang, fullInfo }, 82 | signal: abortController?.signal, 83 | }) 84 | } 85 | -------------------------------------------------------------------------------- /www/src/components/help.js: -------------------------------------------------------------------------------- 1 | import { useCallback, useLayoutEffect, useState } from 'preact/hooks' 2 | import { html } from 'src/utils/htm' 3 | import { createPortal } from 'src/utils/preact_compat' 4 | 5 | import './help.css' 6 | 7 | /** 8 | * @param {{ 9 | * onClose(): void, 10 | * children: import('preact').JSX.Element 11 | * }} props 12 | */ 13 | export function Popup({ onClose, children }) { 14 | const onKeyDown = useCallback( 15 | e => { 16 | if (e.key === 'Escape') { 17 | e.preventDefault() 18 | onClose() 19 | } 20 | }, 21 | [onClose], 22 | ) 23 | const onBackgroundClick = useCallback( 24 | e => { 25 | if (e.target.classList.contains('popup')) { 26 | onClose() 27 | } 28 | }, 29 | [onClose], 30 | ) 31 | 32 | useLayoutEffect(() => { 33 | addEventListener('keydown', onKeyDown) 34 | return () => removeEventListener('keydown', onKeyDown) 35 | }, [onKeyDown]) 36 | 37 | return html` 38 | 47 | ` 48 | } 49 | 50 | /** 51 | * @param {{ 52 | * contentFunc(): import('preact').JSX.Element, 53 | * letter?: string 54 | * }} props 55 | */ 56 | export function Help({ contentFunc, letter = '?' }) { 57 | const [isShown, setIsShown] = useState(false) 58 | 59 | const onClick = useCallback(() => { 60 | setIsShown(true) 61 | }, [setIsShown]) 62 | const onPopupClose = useCallback(() => { 63 | setIsShown(false) 64 | }, [setIsShown]) 65 | 66 | return html` 67 | 68 | ${isShown && 69 | createPortal( 70 | html`<${Popup} onClose=${onPopupClose}>${contentFunc()}`, // 71 | document.body, 72 | )} 73 | ` 74 | } 75 | 76 | /** 77 | * @param {{ 78 | * classes?: string 79 | * contentFunc(): import('preact').JSX.Element, 80 | * children: import('preact').JSX.Element 81 | * }} props 82 | */ 83 | export function HelpLine({ classes, contentFunc, children }) { 84 | const [isShown, setIsShown] = useState(false) 85 | 86 | const onClick = useCallback(() => { 87 | setIsShown(true) 88 | }, [setIsShown]) 89 | const onPopupClose = useCallback(() => { 90 | setIsShown(false) 91 | }, [setIsShown]) 92 | 93 | return html` 94 | 95 | ${isShown && 96 | createPortal( 97 | html`<${Popup} onClose=${onPopupClose}>${contentFunc()}`, // 98 | document.body, 99 | )} 100 | ` 101 | } 102 | -------------------------------------------------------------------------------- /www/templates/index.html: -------------------------------------------------------------------------------- 1 | {{define "title"}} 2 | Storj Net Info 3 | {{end}} 4 | 5 | {{define "content"}} 6 | 7 |
8 |
9 | {{if .User}} 10 |

11 | {{.L.Loc "My nodes" "ru" "Мои ноды"}} 12 |

13 | {{else}} 14 |

15 | {{end}} 16 |

17 | {{.L.Loc "Ping nodes" "ru" "Попинговать ноды"}} 18 | {{.L.Loc "by ID and address" "ru" "по айди и адресу"}}. 19 |

20 |

21 | {{.L.Loc "Count neighbors" "ru" "Посчитать соседей"}} 22 | {{.L.Loc "on /24-subnet" "ru" "в /24-подсети"}}. 23 |

24 |

25 | {{.L.Loc "Check" "ru" "Проверить"}}{{.L.Loc "" "ru" ","}} 26 | {{.L.Loc "if IP is sanctioned" "ru" "не попадает ли IP под санкции"}}. 27 |

28 |
29 | 30 |
31 |

2024.08.14

32 |

33 | {{.L.Loc "Checking" "ru" "Проверка"}} 34 | {{.L.Loc "IP sanctions (in" "ru" "санкций на IP (и в"}} 35 | {{.L.Loc "nodes table" "ru" "списке нод"}} 36 | {{.L.Loc "too)" "ru" "тоже)"}}. 37 |

38 |

2024.08.12

39 |

40 | {{.L.Loc "IPs ASN and company details in" "ru" "Сведения об ASN и компаниях в"}} 41 | {{.L.Loc "nodes table" "ru" "списке нод"}}. 42 |

43 |

2023.05.11

44 |

45 | {{.L.Loc "Subnet stats" "ru" "Статистика подсетей"}}. 46 |

47 |

2021.06.29

48 |

49 | {{.L.Loc "Network size with official nodes count" "ru" "Размер сети с официальным кол-вом нод"}}. 50 |

51 |

2021.04.30

52 |

53 | {{.L.Loc "Ping nodes" "ru" "Проверка нод"}} 54 | {{.L.Loc "by QUIC too" "ru" "и через QUIC"}}. 55 |

56 |

2021.03.28

57 |

58 | {{.L.Loc "Nodes count by countries." "ru" "График нод по странам."}} 59 |

60 |

2021.03.26

61 |

62 | {{.L.Loc "API for multi-node" "ru" "АПИ для группового"}} 63 | 64 | {{.L.Loc "neighbors counting" "ru" "подсчёта соседей"}}.
65 | {{.L.Loc "Neighbors counting in" "ru" "Подсчёт соседей в"}} 66 | {{.L.Loc "my nodes list" "ru" "списке своих нод"}}. 67 |

68 |

69 | {{.L.Loc "more" "ru" "ещё" }} 70 |

71 |
72 |
73 | 74 |
75 | 76 | 77 |
78 | 79 |
80 | 81 |
82 | 83 |
84 | 85 |
86 | 87 | {{end}} -------------------------------------------------------------------------------- /www/src/components/auth.js: -------------------------------------------------------------------------------- 1 | import { PureComponent } from 'src/utils/preact_compat' 2 | import { onError } from 'src/errors' 3 | import { bindHandlers } from 'src/utils/elems' 4 | import { apiReq } from 'src/api' 5 | import { html } from 'src/utils/htm' 6 | import { L } from 'src/i18n' 7 | 8 | import './auth.css' 9 | 10 | /** 11 | * @class 12 | * @typedef AF_State 13 | * @prop {'register'|'login'} mode 14 | * @prop {string|null} authError 15 | * @extends {PureComponent<{}, AF_State>} 16 | */ 17 | export class AuthForm extends PureComponent { 18 | constructor() { 19 | super() 20 | bindHandlers(this) 21 | /** @type {AF_State} */ 22 | this.state = { mode: 'login', authError: null } 23 | } 24 | 25 | register(form) { 26 | this.setState({ authError: null }) 27 | apiReq('POST', '/api/register', { data: Object.fromEntries(new FormData(form)) }) 28 | .then(res => { 29 | location.href = '/~' 30 | }) 31 | .catch(err => { 32 | if (err.error === 'USERNAME_TO_SHORT') 33 | this.setState({ 34 | authError: L('username to short', 'ru', 'логин слишком короткий'), 35 | }) 36 | else if (err.error === 'USERNAME_EXISTS') 37 | this.setState({ 38 | authError: L('username not available', 'ru', 'логин занят'), 39 | }) 40 | else onError(err) 41 | }) 42 | } 43 | login(form) { 44 | this.setState({ authError: null }) 45 | apiReq('POST', '/api/login', { data: Object.fromEntries(new FormData(form)) }) 46 | .then(res => { 47 | location.href = '/~' 48 | }) 49 | .catch(err => { 50 | if (err.error === 'WRONG_USERNAME_OR_PASSWORD') 51 | this.setState({ 52 | authError: L('wrong username or password', 'ru', 'неправильный логин или пароль'), 53 | }) 54 | else onError(err) 55 | }) 56 | } 57 | 58 | onSubmit(e) { 59 | e.preventDefault() 60 | this[this.state.mode](e.target) 61 | } 62 | onClick(e) { 63 | if (e.target.name !== this.state.mode) { 64 | e.preventDefault() //иначе на инпутах срабатывает валидация 65 | let form = e.target.closest('form') 66 | let data = new FormData(form) 67 | this.setState({ mode: e.target.name }) 68 | if (data.get('username') !== '' && data.get('password') !== '') { 69 | if (form.checkValidity()) this[e.target.name](form) 70 | } 71 | } 72 | } 73 | 74 | /** 75 | * @param {{}} props 76 | * @param {AF_State} state 77 | */ 78 | render(props, { mode, authError }) { 79 | const regButType = mode === 'register' ? 'submit' : 'button' 80 | const logButType = mode === 'login' ? 'submit' : 'button' 81 | return html` 82 |
83 |
${authError}
84 | 85 | 91 |
92 | 95 | 98 |
99 |
100 | ` 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /www/src/utils/elems.js: -------------------------------------------------------------------------------- 1 | import { h, render } from 'preact' 2 | import { useLayoutEffect } from 'preact/hooks' 3 | 4 | export const NBSP = '\xA0' 5 | export const NBHYP = '\u2011' //NON-BREAKING HYPHEN 6 | export const THINSP = '\u2009' //THIN SPACE 7 | 8 | export function renderIfExists(Comp, selector) { 9 | let elem = document.querySelector(selector) 10 | if (elem !== null) render(h(Comp, null), elem) 11 | } 12 | 13 | export function bindHandlers(comp) { 14 | Object.getOwnPropertyNames(comp.constructor.prototype).forEach(name => { 15 | let attr = comp.__proto__[name] 16 | if (typeof attr === 'function' && /^on[A-Z]/.test(name)) comp[name] = attr.bind(comp) 17 | }) 18 | } 19 | 20 | export function isElemIn(parent, elem) { 21 | while (elem !== null) { 22 | if (parent === elem) return true 23 | elem = elem.parentElement 24 | } 25 | return false 26 | } 27 | 28 | export function delayedRedraw(redrawFunc) { 29 | let redrawRequested = false 30 | 31 | function onRedraw() { 32 | redrawRequested = false 33 | redrawFunc() 34 | } 35 | 36 | return function () { 37 | if (redrawRequested) return 38 | redrawRequested = true 39 | requestAnimationFrame(onRedraw) 40 | } 41 | } 42 | 43 | /** @param {string} elemId */ 44 | export function getJSONContent(elemId) { 45 | let elem = document.getElementById(elemId) 46 | if (elem === null) throw new Error(`elem #${elemId} not found`) 47 | return JSON.parse(elem.textContent + '') 48 | } 49 | 50 | /** 51 | * @param {() => unknown} onResize 52 | * @param {unknown[]} args 53 | */ 54 | export function useResizeEffect(onResize, args) { 55 | useLayoutEffect(() => { 56 | addEventListener('resize', onResize) 57 | return () => { 58 | removeEventListener('resize', onResize) 59 | } 60 | }, args) 61 | } 62 | 63 | export function hoverSingle({ onHover, onLeave }) { 64 | let elem = null 65 | 66 | function move(e) { 67 | let box = elem.getBoundingClientRect() 68 | onHover(e.clientX - box.left, e.clientY - box.top, e, null) 69 | } 70 | function leave(e) { 71 | let box = elem.getBoundingClientRect() 72 | onLeave(e.clientX - box.left, e.clientY - box.top, e, null) 73 | } 74 | 75 | function touchMove(e) { 76 | if (e.targetTouches.length > 1) return 77 | 78 | let box = elem.getBoundingClientRect() 79 | let t0 = e.targetTouches[0] 80 | 81 | onHover(t0.clientX - box.left, t0.clientY - box.top, e, t0) 82 | e.preventDefault() 83 | } 84 | function touchOuter(e) { 85 | if (e.targetTouches.length > 1) return 86 | if (isElemIn(elem, e.target)) return 87 | let t0 = e.targetTouches[0] 88 | onLeave(0, 0, e, t0) 89 | } 90 | 91 | let events = [ 92 | ['mousemove', move], 93 | ['mouseleave', leave], 94 | ['touchstart', touchMove], 95 | ['touchmove', touchMove], 96 | ] 97 | 98 | function mount() { 99 | for (let [name, handler] of events) elem.addEventListener(name, handler, true) 100 | window.addEventListener('touchstart', touchOuter, true) 101 | } 102 | function unmount() { 103 | for (let [name, handler] of events) elem.removeEventListener(name, handler, true) 104 | window.removeEventListener('touchstart', touchOuter, true) 105 | } 106 | 107 | return { 108 | setRef: function (newElem) { 109 | if (newElem === null) { 110 | unmount() 111 | elem = null 112 | } else { 113 | elem = newElem 114 | mount() 115 | } 116 | }, 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /server/middlewares.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "compress/gzip" 5 | "context" 6 | "encoding/json" 7 | "net/http" 8 | "storjnet/core" 9 | "strings" 10 | "sync" 11 | 12 | httputils "github.com/3bl3gamer/go-http-utils" 13 | "github.com/ansel1/merry" 14 | "github.com/go-pg/pg/v10" 15 | "github.com/julienschmidt/httprouter" 16 | ) 17 | 18 | func withUserInner(handle httputils.HandlerExt, wr http.ResponseWriter, r *http.Request, ps httprouter.Params, mustBeLoggedIn bool) error { 19 | db := r.Context().Value(CtxKeyDB).(*pg.DB) 20 | var user *core.User 21 | var err error 22 | 23 | // trying basic auth 24 | if username, password, ok := r.BasicAuth(); ok { 25 | user, err = core.FindUserByUsernameAndPassword(db, username, password) 26 | if err != nil && !merry.Is(err, core.ErrUserNotFound) { 27 | return merry.Wrap(err) 28 | } 29 | } else { 30 | // trying regular cookie sessid 31 | cookie, err := r.Cookie("sessid") 32 | if err == nil { 33 | sessid := cookie.Value 34 | user, err = core.FindUserBySessid(db, sessid) 35 | if err != nil && !merry.Is(err, core.ErrUserNotFound) { 36 | return merry.Wrap(err) 37 | } 38 | if user != nil { 39 | if err := core.UpdateSessionData(db, wr, user); err != nil { 40 | return merry.Wrap(err) 41 | } 42 | } 43 | } 44 | } 45 | 46 | if user != nil { 47 | core.UpdateUserLastSeenAtIfNeed(db, user) 48 | } 49 | 50 | if mustBeLoggedIn && user == nil { 51 | wr.Header().Set("Content-Type", "application/json") 52 | return merry.Wrap(json.NewEncoder(wr).Encode(httputils.JsonError{Ok: false, Code: 403, Error: "FORBIDDEN"})) 53 | } 54 | 55 | r = r.WithContext(context.WithValue(r.Context(), CtxKeyUser, user)) 56 | return handle(wr, r, ps) 57 | } 58 | 59 | func WithOptUser(handle httputils.HandlerExt) httputils.HandlerExt { 60 | return func(wr http.ResponseWriter, r *http.Request, ps httprouter.Params) error { 61 | return withUserInner(handle, wr, r, ps, false) 62 | } 63 | } 64 | 65 | func WithUser(handle httputils.HandlerExt) httputils.HandlerExt { 66 | return func(wr http.ResponseWriter, r *http.Request, ps httprouter.Params) error { 67 | return withUserInner(handle, wr, r, ps, true) 68 | } 69 | } 70 | 71 | var gzippers = sync.Pool{New: func() interface{} { 72 | // full pings array: 1 - 62.9KB, 2 - 45.2KB, 3 - 45.0KB, 9 - 44.7KB 73 | // short pings array: 1 - 16091, 2 - 15677, 3 - 15362, 4 - 15036, 5 - 14674 74 | // speed: https://tukaani.org/lzma/benchmarks.html 75 | gz, err := gzip.NewWriterLevel(nil, 5) 76 | if err != nil { 77 | panic(err) 78 | } 79 | return gz 80 | }} 81 | 82 | type gzipResponseWriter struct { 83 | http.ResponseWriter 84 | gz *gzip.Writer 85 | } 86 | 87 | func (w *gzipResponseWriter) Write(p []byte) (int, error) { 88 | return w.gz.Write(p) 89 | } 90 | 91 | func WithGzip(handle httputils.HandlerExt) httputils.HandlerExt { 92 | return func(wr http.ResponseWriter, r *http.Request, ps httprouter.Params) error { 93 | if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { 94 | return handle(wr, r, ps) 95 | } 96 | wr.Header().Set("Content-Encoding", "gzip") 97 | gz := gzippers.Get().(*gzip.Writer) 98 | defer gzippers.Put(gz) 99 | gz.Reset(wr) 100 | err := handle(&gzipResponseWriter{wr, gz}, r, ps) 101 | if err != nil { 102 | return merry.Wrap(err) 103 | } 104 | return merry.Wrap(gz.Close()) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /core/nodes.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/ansel1/merry" 7 | "github.com/go-pg/pg/v10" 8 | "storj.io/common/storj" 9 | ) 10 | 11 | type BriefNode struct { 12 | RawID []byte `json:"-"` 13 | ID storj.NodeID `json:"id"` 14 | Address string `json:"address"` 15 | } 16 | 17 | type Node struct { 18 | BriefNode 19 | PingMode string `json:"pingMode"` 20 | LastPingedAt time.Time `json:"lastPingedAt"` 21 | LastPing int64 `json:"lastPing"` 22 | LastPingWasOk bool `json:"lastPingWasOk"` 23 | LastUpAt time.Time `json:"lastUpAt"` 24 | CreatedAt time.Time `json:"-"` 25 | } 26 | 27 | type UserNode struct { 28 | Node 29 | UserID int64 30 | } 31 | 32 | func ConvertBriefNodeIDs(nodes []*BriefNode) error { 33 | var err error 34 | for _, node := range nodes { 35 | node.ID, err = storj.NodeIDFromBytes(node.RawID) 36 | if err != nil { 37 | return merry.Wrap(err) 38 | } 39 | } 40 | return nil 41 | } 42 | 43 | func ConvertNodeIDs(nodes []*Node) error { 44 | var err error 45 | for _, node := range nodes { 46 | node.ID, err = storj.NodeIDFromBytes(node.RawID) 47 | if err != nil { 48 | return merry.Wrap(err) 49 | } 50 | } 51 | return nil 52 | } 53 | 54 | func ConvertUserNodeIDs(nodes []*UserNode) error { 55 | var err error 56 | for _, node := range nodes { 57 | node.ID, err = storj.NodeIDFromBytes(node.RawID) 58 | if err != nil { 59 | return merry.Wrap(err) 60 | } 61 | } 62 | return nil 63 | } 64 | 65 | func SetUserNode(db *pg.DB, user *User, node *Node) error { 66 | _, err := db.Exec(` 67 | INSERT INTO user_nodes (node_id, user_id, address, ping_mode, details_updated_at) VALUES (?, ?, ?, ?, now()) 68 | ON CONFLICT (node_id, user_id) DO UPDATE SET 69 | address = EXCLUDED.address, 70 | ping_mode = EXCLUDED.ping_mode, 71 | details_updated_at = now()`, 72 | node.ID, user.ID, node.Address, node.PingMode) 73 | return merry.Wrap(err) 74 | } 75 | 76 | func DelUserNode(db *pg.DB, user *User, nodeID storj.NodeID) error { 77 | _, err := db.Exec(` 78 | DELETE FROM user_nodes WHERE node_id = ? AND user_id = ?`, 79 | nodeID, user.ID) 80 | return merry.Wrap(err) 81 | } 82 | 83 | func LoadUserNodes(db *pg.DB, user *User) ([]*Node, error) { 84 | nodes := make([]*Node, 0) 85 | _, err := db.Query(&nodes, ` 86 | SELECT node_id AS raw_id, address, ping_mode, last_pinged_at, last_ping, last_ping_was_ok, last_up_at 87 | FROM user_nodes WHERE user_id = ?`, user.ID) 88 | if err != nil { 89 | return nil, merry.Wrap(err) 90 | } 91 | if err := ConvertNodeIDs(nodes); err != nil { 92 | return nil, merry.Wrap(err) 93 | } 94 | return nodes, nil 95 | } 96 | 97 | func LoadSatNodes(db *pg.DB, startDate, endDate time.Time) ([]*BriefNode, error) { 98 | nodes := make([]*BriefNode, 0) 99 | _, err := db.Query(&nodes, ` 100 | SELECT node_id AS raw_id, address FROM user_nodes 101 | WHERE user_id = (SELECT id FROM users WHERE username = 'satellites') 102 | AND EXISTS ( 103 | SELECT 1 FROM user_nodes_history 104 | WHERE node_id = user_nodes.node_id 105 | AND user_id = user_nodes.user_id 106 | AND date BETWEEN ? AND ? 107 | )`, 108 | startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) 109 | if err != nil { 110 | return nil, merry.Wrap(err) 111 | } 112 | if err := ConvertBriefNodeIDs(nodes); err != nil { 113 | return nil, merry.Wrap(err) 114 | } 115 | return nodes, nil 116 | } 117 | -------------------------------------------------------------------------------- /core/users.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "net/http" 5 | "storjnet/utils" 6 | "strings" 7 | "time" 8 | 9 | "github.com/ansel1/merry" 10 | "github.com/go-pg/pg/v10" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | const SessionDuration = 365 * 24 * time.Hour 15 | 16 | // var ErrEmailExsists = merry.New("email_exists") 17 | var ErrUsernameExsists = merry.New("username_exists") 18 | var ErrUserNotFound = merry.New("user_not_found") 19 | 20 | type User struct { 21 | ID int64 22 | Email string 23 | Username string 24 | PasswordHash string 25 | Sessid string 26 | CreatedAt time.Time 27 | LastSeenAt time.Time 28 | } 29 | 30 | func RegisterUser(db *pg.DB, wr http.ResponseWriter, username, password string) (*User, error) { 31 | user := &User{} 32 | _, err := db.QueryOne(user, 33 | "INSERT INTO users (username, password_hash, sessid) VALUES (?, crypt(?, gen_salt('bf')), gen_random_uuid()) RETURNING *", 34 | username, password) 35 | if utils.IsConstrError(err, "users", "unique_violation", "users_username_key") { 36 | return nil, ErrUsernameExsists.Here() 37 | } 38 | if err != nil { 39 | return nil, merry.Wrap(err) 40 | } 41 | setSessionCookie(wr, user.Sessid) 42 | return user, nil 43 | } 44 | 45 | func LoginUser(db *pg.DB, wr http.ResponseWriter, username, password string) (*User, error) { 46 | user, err := FindUserByUsernameAndPassword(db, username, password) 47 | if err != nil { 48 | return nil, merry.Wrap(err) 49 | } 50 | setSessionCookie(wr, user.Sessid) 51 | UpdateUserLastSeenAtIfNeed(db, user) 52 | return user, nil 53 | } 54 | 55 | func FindUserBySessid(db *pg.DB, sessid string) (*User, error) { 56 | user := &User{} 57 | err := db.Model(user).Where("sessid = ?", sessid).Select() 58 | if err == pg.ErrNoRows { 59 | return nil, ErrUserNotFound.Here() 60 | } 61 | if perr, ok := merry.Unwrap(err).(pg.Error); ok { 62 | if strings.HasPrefix(perr.Field('M'), "invalid input syntax for type uuid:") { 63 | return nil, ErrUserNotFound.Here() 64 | } 65 | } 66 | if err != nil { 67 | return nil, merry.Wrap(err) 68 | } 69 | return user, nil 70 | } 71 | 72 | func FindUserByUsernameAndPassword(db *pg.DB, username, password string) (*User, error) { 73 | user := &User{} 74 | err := db.Model(user).Where("username = ? AND password_hash = crypt(?, password_hash)", username, password).Select() 75 | if err == pg.ErrNoRows { 76 | return nil, ErrUserNotFound.Here() 77 | } 78 | if err != nil { 79 | return nil, merry.Wrap(err) 80 | } 81 | return user, nil 82 | } 83 | 84 | func UpdateSessionData(db *pg.DB, wr http.ResponseWriter, user *User) error { 85 | setSessionCookie(wr, user.Sessid) 86 | return nil 87 | } 88 | 89 | func setSessionCookie(wr http.ResponseWriter, sessid string) { 90 | cookie := &http.Cookie{ 91 | Name: "sessid", 92 | Value: sessid, 93 | Path: "/", 94 | Expires: time.Now().Add(SessionDuration), 95 | HttpOnly: true, 96 | SameSite: http.SameSiteLaxMode, 97 | } 98 | wr.Header().Set("Set-Cookie", cookie.String()) 99 | } 100 | 101 | func UpdateUserLastSeenAtIfNeed(db *pg.DB, user *User) { 102 | if time.Since(user.LastSeenAt) < time.Minute { 103 | return 104 | } 105 | id := user.ID 106 | go func() { 107 | _, err := db.Exec("UPDATE users SET last_seen_at = now() WHERE id = ?", id) 108 | if err != nil { 109 | log.Error().Err(err).Int64("user_id", id).Msg("failed to update last_seen_at") 110 | } 111 | }() 112 | } 113 | -------------------------------------------------------------------------------- /scripts/node_id_conv/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/hex" 6 | "flag" 7 | "os" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/ansel1/merry" 12 | "storj.io/common/storj" 13 | ) 14 | 15 | var convMap = map[string]struct { 16 | fromString FromStringFunc 17 | toString ToStringFunc 18 | }{ 19 | "hex": { 20 | fromString: func(str string) (storj.NodeID, error) { 21 | buf, err := hex.DecodeString(str) 22 | if err != nil { 23 | return storj.NodeID{}, merry.Wrap(err) 24 | } 25 | id, err := storj.NodeIDFromBytes(buf) 26 | return id, merry.Wrap(err) 27 | }, 28 | toString: func(id storj.NodeID) (string, error) { 29 | return hex.EncodeToString(id[:]), nil 30 | }, 31 | }, 32 | "base58": { 33 | fromString: func(str string) (storj.NodeID, error) { 34 | id, err := storj.NodeIDFromString(str) 35 | return id, merry.Wrap(err) 36 | }, 37 | toString: func(id storj.NodeID) (string, error) { 38 | return id.String(), nil 39 | }, 40 | }, 41 | "difficulty": { 42 | fromString: func(str string) (storj.NodeID, error) { 43 | return storj.NodeID{}, merry.New("can not make node from its difficulty value") 44 | }, 45 | toString: func(id storj.NodeID) (string, error) { 46 | dif, err := id.Difficulty() 47 | if err != nil { 48 | return "", merry.Wrap(err) 49 | } 50 | return strconv.FormatUint(uint64(dif), 10), nil 51 | }, 52 | }, 53 | } 54 | 55 | type Format struct { 56 | Val string 57 | } 58 | 59 | func (e *Format) Set(name string) error { 60 | if _, ok := convMap[name]; !ok { 61 | return merry.New("wrong format: " + name) 62 | } 63 | e.Val = name 64 | return nil 65 | } 66 | 67 | func (e Format) String() string { 68 | return e.Val 69 | } 70 | 71 | func (e Format) Type() string { 72 | return "string" 73 | } 74 | 75 | type FromStringFunc func(string) (storj.NodeID, error) 76 | type ToStringFunc func(storj.NodeID) (string, error) 77 | 78 | func forEachLine(fromString FromStringFunc, toString ToStringFunc) error { 79 | // file, err := os.Open() 80 | // if err != nil { 81 | // return merry.Wrap(err) 82 | // } 83 | // defer file.Close() 84 | file := os.Stdin 85 | 86 | scanner := bufio.NewScanner(file) 87 | for scanner.Scan() { 88 | line := strings.TrimSpace(scanner.Text()) 89 | if len(line) == 0 { 90 | continue 91 | } 92 | line = strings.TrimPrefix(line, "\\x") //cut off Postgresql HEX prefix (if any) 93 | id, err := fromString(line) 94 | if err != nil { 95 | return merry.Wrap(err) 96 | } 97 | str, err := toString(id) 98 | if err != nil { 99 | return merry.Wrap(err) 100 | } 101 | os.Stdout.WriteString(str + "\n") 102 | } 103 | 104 | if err := scanner.Err(); err != nil { 105 | return merry.Wrap(err) 106 | } 107 | return nil 108 | } 109 | 110 | func process(modeFrom, modeTo Format) error { 111 | return merry.Wrap(forEachLine( 112 | convMap[modeFrom.Val].fromString, 113 | convMap[modeTo.Val].toString, 114 | )) 115 | } 116 | 117 | func main() { 118 | var modesArr []string 119 | for name := range convMap { 120 | modesArr = append(modesArr, name) 121 | } 122 | modes := strings.Join(modesArr, ", ") 123 | 124 | modeFrom := Format{Val: "hex"} 125 | modeTo := Format{Val: "base58"} 126 | flag.Var(&modeFrom, "from", "source format: "+modes) 127 | flag.Var(&modeTo, "to", "destination format") 128 | flag.Parse() 129 | 130 | if err := process(modeFrom, modeTo); err != nil { 131 | panic(merry.Details(err)) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /www/templates/_base.html: -------------------------------------------------------------------------------- 1 | {{define "base" -}} 2 | 3 | 4 | 5 | 6 | 7 | {{block "title" .}}Storj3 stat{{end}} 8 | 9 | {{block "extra_styles" .}}{{end}} 10 | 11 | 12 |
13 |
14 | 18 |
19 | 20 | 22 | 23 | 24 | 28 | 31 |
32 |
33 |
34 |
35 | {{block "content" .}}...{{end}} 36 |
37 | 61 | 62 | {{block "extra_scripts" .}}{{end}} 63 | {{block "local_html_content" .}}{{end}} 64 | 65 | 66 | {{ end }} -------------------------------------------------------------------------------- /www/rollup.config.js: -------------------------------------------------------------------------------- 1 | import nodeResolve from 'rollup-plugin-node-resolve' 2 | 3 | export default async function (commandOptions) { 4 | const isProd = process.env.NODE_ENV === 'production' 5 | 6 | return { 7 | input: 'src/index.js', 8 | output: { 9 | format: 'esm', 10 | dir: 'dist', 11 | entryFileNames: isProd ? 'bundle.[hash].js' : 'bundle.js', 12 | sourcemap: true, 13 | }, 14 | plugins: [ 15 | { 16 | name: 'root-import-src', 17 | resolveId(importee, importer) { 18 | if (importee.startsWith('src/')) { 19 | // eslint-disable-next-line no-undef 20 | let path = __dirname + '/' + importee 21 | if (!path.endsWith('.js')) path += '.js' 22 | return path 23 | } 24 | return null 25 | }, 26 | }, 27 | css({ output: `dist/bundle${isProd ? '.[hash]' : ''}.css` }), 28 | // commonjs({}), //rollup-plugin-commonjs 29 | // 'source' is first: trying to import original non-minified source 30 | nodeResolve({ mainFields: ['source', 'module', 'main'] }), 31 | isProd && 32 | (await import('@rollup/plugin-babel')).babel({ 33 | babelHelpers: 'inline', 34 | plugins: [ 35 | [ 36 | 'babel-plugin-htm', 37 | { 38 | import: { module: 'preact', export: 'h' }, 39 | pragma: '_h', 40 | useNativeSpread: true, 41 | }, 42 | ], 43 | ], 44 | }), 45 | isProd && 46 | (await import('rollup-plugin-terser').then(({ terser }) => 47 | terser({ 48 | format: { semicolons: false }, 49 | compress: { keep_fargs: false, ecma: 2020, passes: 2 }, 50 | }), 51 | )), 52 | !isProd && 53 | (await import('rollup-plugin-serve')).default({ 54 | contentBase: 'dist', 55 | host: commandOptions.configHost || 'localhost', 56 | port: commandOptions.configPort || '12345', 57 | }), 58 | !isProd && (await import('rollup-plugin-livereload')).default(), 59 | commandOptions['config-stats'] && (await import('rollup-plugin-visualizer')).default(), 60 | ], 61 | watch: { clearScreen: false }, 62 | } 63 | } 64 | 65 | import { createFilter } from 'rollup-pluginutils' 66 | import { promises as fs } from 'fs' 67 | import path from 'path' 68 | import Concat from 'concat-with-sourcemaps' 69 | import crypto from 'crypto' 70 | 71 | function css(options = {}) { 72 | const filter = createFilter(options.include || ['**/*.css'], options.exclude) 73 | const styles = {} 74 | let output = options.output 75 | let changes = 0 76 | 77 | return { 78 | name: 'css', 79 | transform(code, id) { 80 | if (!filter(id)) return 81 | 82 | // Keep track of every stylesheet 83 | // Check if it changed since last render 84 | if (styles[id] !== code && (styles[id] || code)) { 85 | styles[id] = code 86 | changes++ 87 | } 88 | 89 | return '' 90 | }, 91 | generateBundle(opts) { 92 | // No stylesheet needed 93 | if (!changes) return 94 | changes = 0 95 | 96 | // Combine all stylesheets 97 | let concat = new Concat(true, output, '\n\n') 98 | for (const id in styles) { 99 | concat.add(id, styles[id]) 100 | } 101 | let hash = crypto.createHash('md5').update(concat.content).digest('hex').substr(0, 8) 102 | let contentFPath = output.replace('[hash]', hash) 103 | let sourceMapFPath = contentFPath + '.map' 104 | let dirname = path.dirname(contentFPath) 105 | let content = concat.content + `\n/*# sourceMappingURL=${path.basename(sourceMapFPath)} */` 106 | let sourceMap = concat.sourceMap + '' 107 | 108 | return fs 109 | .mkdir(dirname, { recursive: true }) 110 | .then(() => 111 | Promise.all([ 112 | fs.writeFile(contentFPath, content), 113 | fs.writeFile(sourceMapFPath, sourceMap), 114 | ]), 115 | ) 116 | }, 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /nodes/loc_snap.go: -------------------------------------------------------------------------------- 1 | package nodes 2 | 3 | import ( 4 | "encoding/binary" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "sort" 10 | "storjnet/utils" 11 | "time" 12 | 13 | "github.com/ansel1/merry" 14 | "github.com/rs/zerolog/log" 15 | ) 16 | 17 | const LastFPathLabel = "" 18 | 19 | func SaveLocsSnapshot() error { 20 | now := time.Now() 21 | db := utils.MakePGConnection() 22 | 23 | var locs []struct{ Lon, Lat float64 } 24 | _, err := db.Query(&locs, ` 25 | SELECT (location->'longitude')::float AS lon, (location->'latitude')::float AS lat 26 | FROM nodes 27 | WHERE updated_at > NOW() - INTERVAL '12 hours' 28 | AND location IS NOT NULL 29 | ORDER BY id`) 30 | if err != nil { 31 | return merry.Wrap(err) 32 | } 33 | 34 | buf := make([]byte, 24+len(locs)*4) 35 | for i, loc := range locs { 36 | lon := uint16((loc.Lon + 180) / 360 * 0x10000) 37 | lat := uint16((loc.Lat + 90) / 180 * 0x10000) 38 | buf[24+i*4+0] = uint8(lon & 0xFF) 39 | buf[24+i*4+1] = uint8(lon >> 8) 40 | buf[24+i*4+2] = uint8(lat & 0xFF) 41 | buf[24+i*4+3] = uint8(lat >> 8) 42 | } 43 | binary.LittleEndian.PutUint64(buf, ^uint64(0)) 44 | binary.LittleEndian.PutUint64(buf[8:], uint64(now.Unix())) 45 | binary.LittleEndian.PutUint64(buf[16:], uint64(len(locs))) 46 | 47 | ex, err := os.Executable() 48 | if err != nil { 49 | return merry.Wrap(err) 50 | } 51 | exPath := filepath.Dir(ex) 52 | locDir := exPath + "/history/locations" 53 | if err := os.MkdirAll(locDir, os.ModePerm); err != nil { 54 | return merry.Wrap(err) 55 | } 56 | fpath := locDir + "/" + now.Format("2006-01-02") + ".bin" 57 | fd, err := os.OpenFile(fpath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) 58 | if err != nil { 59 | return merry.Wrap(err) 60 | } 61 | defer fd.Close() 62 | if _, err := fd.Write(buf); err != nil { 63 | return merry.Wrap(err) 64 | } 65 | return merry.Wrap(fd.Close()) 66 | } 67 | 68 | func PrintLocsSnapshot(fpath string) error { 69 | log.Info().Msg("starting") 70 | 71 | if fpath == LastFPathLabel { 72 | ex, err := os.Executable() 73 | if err != nil { 74 | return merry.Wrap(err) 75 | } 76 | exPath := filepath.Dir(ex) 77 | locDir := exPath + "/history/locations" 78 | fnames, err := filepath.Glob(locDir + "/*.bin") 79 | if err != nil { 80 | return merry.Wrap(err) 81 | } 82 | sort.Strings(fnames) 83 | if len(fnames) == 0 { 84 | log.Warn().Msg("no files found in default folder " + locDir) 85 | } 86 | fpath = fnames[len(fnames)-1] 87 | } 88 | 89 | fd, err := os.Open(fpath) 90 | if err != nil { 91 | return merry.Wrap(err) 92 | } 93 | defer fd.Close() 94 | 95 | buf, err := io.ReadAll(fd) 96 | if err != nil { 97 | return merry.Wrap(err) 98 | } 99 | if len(buf) == 0 { 100 | log.Warn().Msg("file is empty") 101 | return nil 102 | } 103 | 104 | pos := 0 105 | chunksCount := 0 106 | for { 107 | b := buf[pos:] 108 | if len(b) == 0 { 109 | break 110 | } 111 | if len(b) < 24 { 112 | log.Warn().Msgf("expected 24 more bytes in file, got %d, exiting", len(b)) 113 | break 114 | } 115 | 116 | prefix := binary.LittleEndian.Uint64(b) 117 | pos += 8 118 | if prefix != ^uint64(0) { 119 | log.Warn().Msgf("expected prefix at pos %d, found %X, skipping", pos, prefix) 120 | continue 121 | } 122 | 123 | stamp := time.Unix(int64(binary.LittleEndian.Uint64(b[8:])), 0) 124 | fmt.Printf(" === %s === \n", stamp.Format("2006-01-02 15:04:05 -0700")) 125 | pos += 8 126 | 127 | count := int(binary.LittleEndian.Uint64(b[16:])) 128 | fmt.Printf(" -- x%d -- \n", count) 129 | pos += 8 130 | for i := 0; i < count; i++ { 131 | loni := (int64(b[24+i*4+1]) << 8) + int64(b[24+i*4+0]) 132 | lati := (int64(b[24+i*4+3]) << 8) + int64(b[24+i*4+2]) 133 | lon := (float64(loni)/0x10000)*360 - 180 134 | lat := (float64(lati)/0x10000)*180 - 90 135 | fmt.Printf("loc: %8.3f %7.3f\n", lon, lat) 136 | } 137 | pos += count * 4 138 | chunksCount++ 139 | } 140 | 141 | log.Info().Int("chunks", chunksCount).Msg("done") 142 | return nil 143 | } 144 | -------------------------------------------------------------------------------- /www/src/utils/time.js: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useState } from 'preact/hooks' 2 | 3 | export const DAY_DURATION = 24 * 3600 * 1000 4 | 5 | /** @param {Date|number} date */ 6 | export function startOfMonth(date) { 7 | let newDate = new Date(date) 8 | newDate.setUTCHours(0, 0, 0, 0) 9 | newDate.setUTCDate(1) 10 | return newDate 11 | } 12 | /** @param {Date|number} date */ 13 | export function endOfMonth(date) { 14 | date = startOfMonth(date) 15 | date.setUTCMonth(date.getUTCMonth() + 1) 16 | date.setUTCDate(date.getUTCDate() - 1) 17 | return date 18 | } 19 | 20 | /** @param {Date} date */ 21 | export function toISODateString(date) { 22 | return date.toISOString().substr(0, 10) 23 | } 24 | 25 | /** @param {{startDate:Date, endDate:Date}} date */ 26 | export function toISODateStringInterval({ startDate, endDate }) { 27 | return { startDateStr: toISODateString(startDate), endDateStr: toISODateString(endDate) } 28 | } 29 | 30 | /** 31 | * @param {string|null} str 32 | * @param {boolean} [isEnd] 33 | */ 34 | function parseHashIntervalDate(str, isEnd) { 35 | if (!str) return null 36 | let m = str.trim().match(/^(\d{4})-(\d\d?)(?:-(\d\d?))?$/) 37 | if (m === null) return null 38 | let [, year, month, date] = m 39 | 40 | // this way Date will be at midnight in UTC 41 | let res = new Date(year + '-' + month.padStart(2, '0') + '-' + (date || '1').padStart(2, '0')) 42 | if (res.toString() === 'Invalid Date') return null 43 | 44 | if (isEnd && date === undefined) { 45 | res = endOfMonth(res) 46 | } 47 | return res 48 | } 49 | /** 50 | * @param {Date} date 51 | * @param {boolean} [isEnd] 52 | */ 53 | function formatHashIntervalDate(date, isEnd) { 54 | let canTrimDate = 55 | (!isEnd && date.getTime() === startOfMonth(date).getTime()) || 56 | (isEnd && date.getTime() === endOfMonth(date).getTime()) 57 | let str = date.getFullYear() + '-' + (date.getUTCMonth() + 1) 58 | if (canTrimDate) return str 59 | return str + '-' + date.getUTCDate() 60 | } 61 | 62 | /** @returns {[Date, Date]} */ 63 | export function getDefaultHashInterval() { 64 | let now = new Date() 65 | return [startOfMonth(now), endOfMonth(now)] 66 | } 67 | 68 | export function intervalIsDefault() { 69 | let [defStart, defEnd] = getDefaultHashInterval() 70 | let [curStart, curEnd] = getHashInterval() 71 | return defStart.getTime() === curStart.getTime() && defEnd.getTime() === curEnd.getTime() 72 | } 73 | 74 | export function intervalIsMonth() { 75 | let [start, end] = getHashInterval() 76 | return +start === +startOfMonth(start) && +end === +endOfMonth(end) 77 | } 78 | 79 | /** @returns {[Date, Date]} */ 80 | export function getHashInterval() { 81 | let hash = location.hash.substr(1) 82 | let params = new URLSearchParams(hash) 83 | let start = parseHashIntervalDate(params.get('start')) 84 | let end = parseHashIntervalDate(params.get('end'), true) 85 | 86 | if (start !== null && end !== null && start.getTime() < end.getTime()) { 87 | return [start, end] 88 | } else { 89 | return getDefaultHashInterval() 90 | } 91 | } 92 | 93 | /** 94 | * @param {Date} startDate 95 | * @param {Date} endDate 96 | */ 97 | export function makeUpdatedHashInterval(startDate, endDate) { 98 | let hash = location.hash.substr(1) 99 | let params = new URLSearchParams(hash) 100 | params.set('start', formatHashIntervalDate(startDate)) 101 | params.set('end', formatHashIntervalDate(endDate, true)) 102 | return '#' + params.toString() 103 | } 104 | 105 | /** 106 | * @param {(startDate:Date, endDate:Date) => void} onChange 107 | */ 108 | export function watchHashInterval(onChange) { 109 | function listener() { 110 | let [startDate, endDate] = getHashInterval() 111 | onChange(startDate, endDate) 112 | } 113 | function off() { 114 | removeEventListener('hashchange', listener) 115 | } 116 | addEventListener('hashchange', listener) 117 | 118 | let [startDate, endDate] = getHashInterval() 119 | return { startDate, endDate, off } 120 | } 121 | 122 | export function useHashInterval() { 123 | const [interval, setInterval] = useState(getHashInterval) 124 | useLayoutEffect(() => { 125 | const listener = () => setInterval(getHashInterval()) 126 | addEventListener('hashchange', listener) 127 | return () => removeEventListener('hashchange', listener) 128 | }, []) 129 | return interval 130 | } 131 | -------------------------------------------------------------------------------- /www/src/components/user_nodes.css: -------------------------------------------------------------------------------- 1 | .user-nodes-list-with-form { 2 | position: relative; 3 | margin-bottom: 16px; 4 | } 5 | 6 | .user-nodes-list { 7 | position: relative; 8 | display: inline-block; 9 | width: 100%; 10 | min-height: 32px; 11 | overflow-x: auto; 12 | } 13 | 14 | .user-nodes-list .user-nodes-table thead td { 15 | border-bottom: 2px solid transparent; 16 | white-space: nowrap; 17 | } 18 | 19 | .user-nodes-list .user-nodes-table .neighbors-title-cell-inner { 20 | margin: 0 -15px 0 -5px; 21 | } 22 | 23 | .user-nodes-list .node { 24 | /* border-bottom: 1px solid lightgray; */ 25 | margin-bottom: 8px; 26 | } 27 | .user-nodes-list .node td { 28 | padding: 1px 5px; 29 | } 30 | 31 | .user-nodes-list .node .node-id { 32 | position: relative; 33 | white-space: nowrap; 34 | font-weight: bold; 35 | font-family: monospace; 36 | font-size: 16px; 37 | } 38 | .user-nodes-list .node .node-id .full { 39 | position: absolute; 40 | left: 0; 41 | top: 0; 42 | background-color: rgba(255, 255, 255, 0.9); 43 | } 44 | .user-nodes-list .node .node-id:not(:hover) .full { 45 | display: none; 46 | } 47 | .user-nodes-list .node .node-status { 48 | display: inline-block; 49 | width: 10px; 50 | height: 10px; 51 | line-height: 16px; 52 | margin: 0 4px 1px 0; 53 | border-radius: 50%; 54 | box-sizing: border-box; 55 | background-color: darkgray; 56 | transition: transform ease 0.1s; 57 | } 58 | .user-nodes-list .node .node-status:hover { 59 | transform: scale(1.4, 1.4); 60 | } 61 | .user-nodes-list .node.status-ok .node-status { 62 | background-color: limegreen; 63 | } 64 | .user-nodes-list .node.status-warn .node-status { 65 | background-color: yellow; 66 | border: 1px solid lightgray; 67 | } 68 | .user-nodes-list .node.status-error .node-status { 69 | background-color: red; 70 | } 71 | .user-nodes-list .node.loading .node-status { 72 | background-color: transparent; 73 | } 74 | .user-nodes-list .node.loading .node-status::after { 75 | content: '⌛'; 76 | } 77 | 78 | .user-nodes-list .node .node-ip { 79 | min-width: 128px; 80 | max-width: 196px; 81 | overflow: hidden; 82 | text-overflow: ellipsis; 83 | white-space: nowrap; 84 | text-align: center; 85 | } 86 | 87 | .user-nodes-list .node .node-neighbors { 88 | text-align: center; 89 | } 90 | 91 | .user-nodes-list .node .node-remove-button { 92 | color: darkred; 93 | font-weight: bold; 94 | opacity: 0.5; 95 | border: none; 96 | background: none; 97 | } 98 | .user-nodes-list .node .node-remove-button:hover { 99 | opacity: 1; 100 | } 101 | 102 | .user-nodes-list .ip-info { 103 | white-space: nowrap; 104 | } 105 | .user-nodes-list .ip-info .help-line { 106 | overflow: hidden; 107 | text-overflow: ellipsis; 108 | } 109 | .user-nodes-list .ip-info:hover .help-line { 110 | overflow: visible; 111 | position: relative; 112 | background-color: rgba(255, 255, 255, 0.9); 113 | padding-right: 2px; 114 | margin-right: -2px; 115 | } 116 | .user-nodes-list .ip-info:not(:hover) .help-line { 117 | text-decoration: none; 118 | max-width: 100%; 119 | } 120 | .user-nodes-list .ip-company-name { 121 | max-width: 256px; 122 | } 123 | .user-nodes-list .ip-company-name.compact { 124 | max-width: 192px; 125 | } 126 | .user-nodes-list .ip-as-descr { 127 | max-width: 192px; 128 | } 129 | 130 | table.ip-info-full { 131 | margin-bottom: 12px; 132 | } 133 | table.ip-info-full th { 134 | text-align: right; 135 | vertical-align: top; 136 | padding-right: 8px; 137 | } 138 | 139 | .node-add-form { 140 | position: absolute; 141 | display: flex; 142 | left: 0; 143 | bottom: -5px; 144 | box-shadow: 0 0 2px lightgray; 145 | background-color: rgba(230, 230, 230, 0.8); 146 | border-radius: 3px; 147 | overflow: hidden; 148 | } 149 | 150 | .node-add-form .buttons-wrap { 151 | display: flex; 152 | align-items: flex-end; 153 | flex-direction: column; 154 | justify-content: space-between; 155 | align-items: stretch; 156 | } 157 | 158 | .node-add-form .nodes-data { 159 | margin: 2px; 160 | width: 75vw; 161 | max-width: 640px; 162 | height: 64px; 163 | } 164 | 165 | .node-add-form.minimized .submit-button { 166 | padding-left: 2px; 167 | padding-right: 2px; 168 | } 169 | 170 | .node-add-form:not(:hover) { 171 | opacity: 0.8; 172 | } 173 | .node-add-form.minimized .submit-button, 174 | .node-add-form.minimized .nodes-data { 175 | display: none; 176 | } 177 | -------------------------------------------------------------------------------- /core/ip_types.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/netip" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/ansel1/merry" 13 | "github.com/go-pg/pg/v10" 14 | "github.com/rs/zerolog/log" 15 | ) 16 | 17 | func UpdateASInfoIfNeed(db *pg.DB, asn int64) (bool, error) { 18 | var t int64 19 | _, err := db.Query(pg.Scan(&t), ` 20 | SELECT 1 FROM autonomous_systems 21 | WHERE number = ? AND incolumitas IS NOT NULL AND incolumitas_updated_at > NOW() - INTERVAL '7 days'`, 22 | asn) 23 | if t == 1 { 24 | return false, nil 25 | } 26 | if err != nil { 27 | return false, merry.Wrap(err) 28 | } 29 | 30 | info, err := fetchASInfo(asn) 31 | if err != nil { 32 | return false, merry.Wrap(err) 33 | } 34 | 35 | if len(info.Prefixes) == 0 { 36 | log.Warn().Int64("asn", asn).Msg("empty prefixes list") 37 | } 38 | if err := UpdateASPrefixes(db, asn, "incolumitas", info.Prefixes); err != nil { 39 | return false, merry.Wrap(err) 40 | } 41 | 42 | _, err = db.Exec(` 43 | INSERT INTO autonomous_systems (number, incolumitas, incolumitas_updated_at) 44 | VALUES (?, ?, NOW()) 45 | ON CONFLICT (number) DO UPDATE SET 46 | incolumitas = EXCLUDED.incolumitas, 47 | incolumitas_updated_at = EXCLUDED.incolumitas_updated_at`, 48 | asn, info.asInfoToSave) 49 | if err != nil { 50 | return false, merry.Wrap(err) 51 | } 52 | return true, nil 53 | } 54 | 55 | func UpdateASPrefixes[T fmt.Stringer](db *pg.DB, asn int64, source string, prefixes []T) error { 56 | prefixesStr := make([]string, len(prefixes)) 57 | for i, pref := range prefixes { 58 | prefixesStr[i] = pref.String() 59 | } 60 | 61 | _, err := db.Exec(` 62 | INSERT INTO autonomous_systems_prefixes (prefix, number, source) 63 | SELECT unnest(?::cidr[]), ?, ? 64 | ON CONFLICT (prefix, number, source) DO UPDATE SET 65 | updated_at = EXCLUDED.updated_at`, 66 | pg.Array(prefixesStr), asn, source) 67 | if err != nil { 68 | return merry.Wrap(err) 69 | } 70 | _, err = db.Exec(` 71 | DELETE FROM autonomous_systems_prefixes WHERE number = ? AND source = ? AND prefix NOT IN (?)`, 72 | asn, source, pg.In(prefixesStr)) 73 | if err != nil { 74 | return merry.Wrap(err) 75 | } 76 | return nil 77 | } 78 | 79 | type asInfoToSave struct { 80 | Org string `json:"org"` 81 | Descr string `json:"descr"` 82 | Type string `json:"type"` 83 | Domain string `json:"domain"` 84 | } 85 | 86 | type asInfoPrefix netip.Prefix 87 | 88 | func (n *asInfoPrefix) UnmarshalJSON(data []byte) error { 89 | var str string 90 | if err := json.Unmarshal(data, &str); err != nil { 91 | return merry.Wrap(err) 92 | } 93 | 94 | ipnet, err := netip.ParsePrefix(str) 95 | if err != nil { 96 | return merry.Wrap(err) 97 | } 98 | 99 | *n = asInfoPrefix(ipnet) 100 | return nil 101 | } 102 | 103 | func (n asInfoPrefix) String() string { 104 | return netip.Prefix(n).String() 105 | } 106 | 107 | type asInfoResponse struct { 108 | asInfoToSave 109 | Prefixes []asInfoPrefix `json:"prefixes"` 110 | // Asn int64 `json:"asn,omitempty"` 111 | Error string `json:"error"` 112 | Message string `json:"message"` 113 | } 114 | 115 | var ErrIncolumitasTooManyRequests = merry.New("incolumitas: too many requests") 116 | 117 | func fetchASInfo(asn int64) (asInfoResponse, error) { 118 | // previously was api.incolumitas.com 119 | req, err := http.NewRequest("GET", "https://api.ipapi.is/?q=AS"+strconv.FormatInt(asn, 10), nil) 120 | if err != nil { 121 | return asInfoResponse{}, merry.Wrap(err) 122 | } 123 | httpClient := http.Client{ 124 | Timeout: 2 * time.Second, 125 | } 126 | resp, err := httpClient.Do(req) 127 | if err != nil { 128 | return asInfoResponse{}, merry.Wrap(err) 129 | } 130 | defer resp.Body.Close() 131 | 132 | info := asInfoResponse{} 133 | if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { 134 | return asInfoResponse{}, merry.Wrap(err) 135 | } 136 | if info.Error != "" { 137 | var err merry.Error 138 | if strings.Contains(info.Message, "Too many API requests") { 139 | err = ErrIncolumitasTooManyRequests 140 | } else { 141 | err = merry.New("") 142 | } 143 | return asInfoResponse{}, err.Here().WithMessagef("ASN %d: %s: %s", asn, info.Error, info.Message) 144 | } 145 | 146 | log.Debug().Int64("ASN", asn).Str("org", info.Org).Str("type", info.Type).Msg("fetched AS type from ipapi (ex incolumitas.com)") 147 | return info, nil 148 | } 149 | -------------------------------------------------------------------------------- /www/src/components/check_sanctions.js: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'preact/hooks' 2 | import { apiReqIPsSanctions } from 'src/api' 3 | import { L, lang } from 'src/i18n' 4 | import { html } from 'src/utils/htm' 5 | import { NodeSanctionGeneralDescrPP, NodeSanctionDescr } from 'src/utils/nodes' 6 | 7 | import './check_sanctions.css' 8 | import { resolveAllMixed } from 'src/utils/dns' 9 | 10 | export function CheckSanctions() { 11 | const [isLoading, setIsLoading] = useState(false) 12 | const [requestAddrs, setRequestAddrs] = useState(/**@type {string[]}*/ ([])) 13 | const [resolvedIPs, setResolvedIPs] = useState(/**@type {(string|Error)[]|null}*/ (null)) 14 | const [response, setResponse] = useState( 15 | /**@type {import('src/api').IPsSanctionsResponse|Error|null}*/ (null), 16 | ) 17 | 18 | const onSubmit = useCallback(e => { 19 | e.preventDefault() 20 | 21 | const addrsStr = (new FormData(e.target).get('ips') + '').trim() 22 | if (addrsStr === '') return 23 | const addrs = addrsStr.split(/[\s,]+/) 24 | 25 | setRequestAddrs(addrs) 26 | setResolvedIPs(null) 27 | setResponse(null) 28 | setIsLoading(true) 29 | 30 | resolveAllMixed(addrs) 31 | .then(ipOrErrs => { 32 | setResolvedIPs(ipOrErrs) 33 | const ips = ipOrErrs.filter(x => typeof x === 'string') 34 | return apiReqIPsSanctions(ips, true, null) 35 | }) 36 | .then(res => { 37 | setResponse(res) 38 | }) 39 | .catch(err => { 40 | setResponse(err) 41 | }) 42 | .finally(() => { 43 | setIsLoading(false) 44 | }) 45 | }, []) 46 | 47 | return html`
48 |
49 |