Compare commits
93 Commits
Author | SHA1 | Date |
---|---|---|
Inex Code | f3cb4ce73c | |
Inex Code | 8fc4ebbcc5 | |
Inex Code | d7cc4d1d88 | |
Inex Code | a2fe689cab | |
Inex Code | 950e03fc35 | |
Inex Code | fb9ed11b5d | |
Inex Code | 6177cc4d43 | |
Inex Code | dde5fa4896 | |
Inex Code | 428af93950 | |
Inex Code | 1c5faa1ee1 | |
Inex Code | 88f1c54f8e | |
Inex Code | e127ac3596 | |
Tao Bojlén | 2e87b53024 | |
Tao Bojlén | 0dd39e6984 | |
Tao Bojlén | 15ad5f1615 | |
Tao Bojlén | 153af37ccf | |
Tao Bojlén | 5f8bd7f891 | |
Tao Bojlén | e1d48e70c4 | |
Tao Bojlén | db04c3a67a | |
Tao Bojlén | 55994be8bc | |
Tao Bojlén | 4691b8dfd7 | |
Tao Bojlén | 3148578a7d | |
Tao Bojlén | 41f5461386 | |
Tao Bojlén | a276eccba4 | |
Tao Bojlén | 49b74c189b | |
Tao Bojlén | 6f75565061 | |
Tao Bojlén | c1d939e7b3 | |
Tao Bojlén | 2eb7ea98d1 | |
Tao Bojlén | f5daa648f7 | |
Tao Bojlén | 3cdc0dc49a | |
Tao Bojlén | 15e0d982e9 | |
Tao Bojlén | e51edef22d | |
Tao Bojlén | a8874c82ba | |
Tao Bojlén | 9078c0315d | |
Tao Bojlén | 1c251866ff | |
Tao Bojlén | 3db98cbfa0 | |
Tao Bojlén | 94034ee538 | |
Tao Bojlén | dd2b43a9bf | |
Tao Bojlén | e532173322 | |
Tao Bojlén | 9b9dec818a | |
Tao Bojlén | d7b3cf8932 | |
Tao Bojlén | 51daf3efae | |
Tao Bojlén | 5d8b8c6dbd | |
Tao Bojlén | 8912ccc6f8 | |
Tao Bror Bojlén | 37c00908ec | |
Tao Bror Bojlén | 41ac4ca9a8 | |
Tao Bror Bojlén | 99f2b247dc | |
Tao Bror Bojlén | 4b332ba980 | |
Tao Bror Bojlén | 5b54e65827 | |
Tao Bror Bojlén | 902358a022 | |
Tao Bror Bojlén | b525a08521 | |
Tao Bror Bojlén | 493ee2778d | |
Tao Bror Bojlén | 074f649f26 | |
Tao Bror Bojlén | deda156837 | |
Tao Bror Bojlén | 1c76a38dbc | |
Tao Bror Bojlén | 3725a22c1c | |
Tao Bror Bojlén | 21d0e523dd | |
Tao Bror Bojlén | 30c5154d16 | |
Tao Bror Bojlén | 6e826f153a | |
Tao Bror Bojlén | 6715d9395f | |
Tao Bror Bojlén | 8558f96635 | |
Tao Bror Bojlén | 44040abd1b | |
Tao Bror Bojlén | 637278ad74 | |
Tao Bror Bojlén | 8c83e5fcf9 | |
Tao Bror Bojlén | de22141a05 | |
Tao Bror Bojlén | 5525bcb204 | |
Tao Bojlén | 1e9ec13221 | |
Tao Bror Bojlén | a80ba972aa | |
Tao Bror Bojlén | b653e1a22a | |
Tao Bror Bojlén | c6b6144d2a | |
Tao Bror Bojlén | b51ddddbeb | |
Tao Bror Bojlén | 7d97e2e397 | |
Tao Bror Bojlén | e3ab73529c | |
Tao Bror Bojlén | f2bd81ca4e | |
Tao Bror Bojlén | c538b7a4b7 | |
Tao Bror Bojlén | 5a9508f3c9 | |
Tao Bror Bojlén | 1f62f0efd5 | |
Tao Bror Bojlén | fbc8e11722 | |
Tao Bror Bojlén | d9b3024fae | |
Tao Bror Bojlén | 50968f37b5 | |
Tao Bror Bojlén | ee48bc8d10 | |
Tao Bror Bojlén | 19b3a3806d | |
Tao Bror Bojlén | 1c1ef37df9 | |
Tao Bror Bojlén | 3b28803bfa | |
Tao Bror Bojlén | f134941eb2 | |
Tao Bror Bojlén | f572cd937e | |
Tao Bror Bojlén | 8935872df9 | |
Tao Bror Bojlén | f95d2dd9e9 | |
Tao Bror Bojlén | 02dbab3d17 | |
Tao Bror Bojlén | c2f842263c | |
Tao Bojlén | 693cf2b2d9 | |
Tao Bror Bojlén | 4d333dd14c | |
Tao Bror Bojlén | 82734947f1 |
|
@ -0,0 +1,43 @@
|
||||||
|
name: Elixir CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
|
||||||
|
name: Build and test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Set up Elixir
|
||||||
|
uses: erlef/setup-elixir@885971a72ed1f9240973bd92ab57af8c1aa68f24
|
||||||
|
with:
|
||||||
|
elixir-version: '1.12.2' # Define the elixir version [required]
|
||||||
|
otp-version: '24.0.4' # Define the OTP version [required]
|
||||||
|
- name: Restore dependencies cache
|
||||||
|
uses: actions/cache@v2
|
||||||
|
with:
|
||||||
|
working-directory: ./backend
|
||||||
|
path: deps
|
||||||
|
key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-mix-
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: ./backend
|
||||||
|
run: |
|
||||||
|
mix local.hex --force
|
||||||
|
mix local.rebar --force
|
||||||
|
mix deps.get
|
||||||
|
- name: Compile dependencies
|
||||||
|
working-directory: ./backend
|
||||||
|
run: mix deps.compile
|
||||||
|
- name: Run Credo
|
||||||
|
working-directory: ./backend
|
||||||
|
run: mix credo --strict
|
||||||
|
- name: Run sobelow
|
||||||
|
working-directory: ./backend
|
||||||
|
run: mix sobelow --config
|
|
@ -0,0 +1,37 @@
|
||||||
|
# This is a basic workflow to help you get started with Actions
|
||||||
|
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
# Controls when the workflow will run
|
||||||
|
on:
|
||||||
|
# Triggers the workflow on push or pull request events but only for the master branch
|
||||||
|
push:
|
||||||
|
branches: [ master ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ master ]
|
||||||
|
|
||||||
|
# Allows you to run this workflow manually from the Actions tab
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
|
||||||
|
jobs:
|
||||||
|
# This workflow contains a single job called "build"
|
||||||
|
build:
|
||||||
|
# The type of runner that the job will run on
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
# Steps represent a sequence of tasks that will be executed as part of the job
|
||||||
|
steps:
|
||||||
|
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Setup Node.js environment
|
||||||
|
uses: actions/setup-node@v2.3.0
|
||||||
|
|
||||||
|
- name: Setup deps
|
||||||
|
working-directory: ./frontend
|
||||||
|
run: yarn install
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
working-directory: ./frontend
|
||||||
|
run: yarn lint
|
|
@ -1,10 +1,3 @@
|
||||||
include:
|
|
||||||
template: Dependency-Scanning.gitlab-ci.yml
|
|
||||||
|
|
||||||
dependency_scanning:
|
|
||||||
only:
|
|
||||||
- schedules
|
|
||||||
|
|
||||||
test-frontend:
|
test-frontend:
|
||||||
image: node:lts-alpine
|
image: node:lts-alpine
|
||||||
stage: test
|
stage: test
|
||||||
|
@ -20,16 +13,16 @@ test-frontend:
|
||||||
- frontend/.yarn
|
- frontend/.yarn
|
||||||
only:
|
only:
|
||||||
changes:
|
changes:
|
||||||
- frontend/*
|
- frontend/**/*
|
||||||
|
|
||||||
test-backend:
|
test-backend:
|
||||||
stage: test
|
stage: test
|
||||||
image: elixir:1.9
|
image: elixir:1.10
|
||||||
variables:
|
variables:
|
||||||
MIX_ENV: test
|
MIX_ENV: test
|
||||||
only:
|
only:
|
||||||
changes:
|
changes:
|
||||||
- backend/*
|
- backend/**/*
|
||||||
before_script:
|
before_script:
|
||||||
- cd backend
|
- cd backend
|
||||||
script:
|
script:
|
||||||
|
@ -44,31 +37,6 @@ test-backend:
|
||||||
- backend/deps/
|
- backend/deps/
|
||||||
- backend/_build/
|
- backend/_build/
|
||||||
|
|
||||||
deploy-backend-develop:
|
|
||||||
stage: deploy
|
|
||||||
environment:
|
|
||||||
name: develop
|
|
||||||
url: https://phoenix.api-develop.fediverse.space
|
|
||||||
image: ilyasemenov/gitlab-ci-git-push
|
|
||||||
only:
|
|
||||||
- develop
|
|
||||||
except:
|
|
||||||
- schedules
|
|
||||||
script:
|
|
||||||
- git-push dokku@api-develop.fediverse.space:phoenix develop
|
|
||||||
|
|
||||||
deploy-gephi-develop:
|
|
||||||
stage: deploy
|
|
||||||
image: ilyasemenov/gitlab-ci-git-push
|
|
||||||
environment:
|
|
||||||
name: develop
|
|
||||||
only:
|
|
||||||
- develop
|
|
||||||
except:
|
|
||||||
- schedules
|
|
||||||
script:
|
|
||||||
- git-push dokku@api-develop.fediverse.space:gephi develop
|
|
||||||
|
|
||||||
deploy-backend-production:
|
deploy-backend-production:
|
||||||
stage: deploy
|
stage: deploy
|
||||||
environment:
|
environment:
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||||
|
|
||||||
|
Files: *
|
||||||
|
Copyright: 2018-2019 Tao Bojlén
|
||||||
|
License: AGPL-3.0-or-later
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"jakebecker.elixir-ls",
|
"jakebecker.elixir-ls",
|
||||||
"ms-vscode.vscode-typescript-tslint-plugin",
|
|
||||||
"kevinmcgowan.typescriptimport",
|
"kevinmcgowan.typescriptimport",
|
||||||
"msjsdiag.debugger-for-chrome"
|
"msjsdiag.debugger-for-chrome"
|
||||||
]
|
]
|
||||||
|
|
|
@ -5,12 +5,15 @@
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"type": "chrome",
|
"type": "mix_task",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Launch Chrome",
|
"name": "phx.server",
|
||||||
"url": "http://localhost:3000",
|
"task": "phx.server",
|
||||||
"webRoot": "${workspaceFolder}/frontend/src",
|
"taskArgs": [],
|
||||||
"runtimeExecutable": "/usr/bin/chromium-browser"
|
"projectDir": "${workspaceRoot}/backend",
|
||||||
}
|
"env": {
|
||||||
|
"SKIP_CRAWL": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
131
CHANGELOG.md
131
CHANGELOG.md
|
@ -17,6 +17,137 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
## [2.9.6 - 2020-10-13]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added link to personal website on About page.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Allow `data:` images in Netlify CSP.
|
||||||
|
- Fix inability to DM login links in releases (#104).
|
||||||
|
|
||||||
|
## [2.9.5 - 2020-10-11]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed crawler not finding API in some cases
|
||||||
|
|
||||||
|
## [2.9.4 - 2020-10-09]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix CSP issues for Plausible analytics
|
||||||
|
|
||||||
|
## [2.9.3 - 2020-10-09]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Allow Plausible privacy-preserving analytics in CSP
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Update dependencies
|
||||||
|
- Update to Elixir 1.10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed CSP headers for data: images
|
||||||
|
|
||||||
|
## [2.9.2 - 2020-08-31]
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Remove staging server
|
||||||
|
|
||||||
|
## [2.9.1 - 2020-08-31]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Added AppSignal logo to "Special thanks" section
|
||||||
|
|
||||||
|
## [2.9.0 - 2020-06-19]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Bring back `develop` staging backup (now managed in DNS)
|
||||||
|
- Increase default number of concurrent crawlers to 100
|
||||||
|
- Accessibility improvements (according to axe review)
|
||||||
|
- Update dependencies
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Add rate limiting of auth endpoints
|
||||||
|
- Added security headers to netlify frontend
|
||||||
|
- Sanitize crawled HTML in the backend
|
||||||
|
|
||||||
|
## [2.8.6 - 2020-01-16]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Update default number of concurrent crawlers
|
||||||
|
- Crawl every 30 minutes instead of every hour
|
||||||
|
|
||||||
|
## [2.8.5 - 2019-12-25]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed link to Mastodon account
|
||||||
|
|
||||||
|
## [2.8.4 - 2019-11-21]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Update links to @fediversespace Mastodon account
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Remove staging backend server
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed frontend crash when instance node missing
|
||||||
|
|
||||||
|
## [2.8.3 - 2019-11-19]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Update dependencies
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed some unsuccessful crawls being saved without their errors
|
||||||
|
|
||||||
|
## [2.8.2 - 2019-08-31]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix insularity score only working for > 0
|
||||||
|
|
||||||
|
## [2.8.1 - 2019-08-31]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed error when viewing some uncrawlable instances
|
||||||
|
- Fix navbar z-index
|
||||||
|
- Optimize query used for generating status rate
|
||||||
|
|
||||||
|
## [2.8.0 - 2019-08-29]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add support for logging in via an ActivityPub direct message to the instance admin.
|
||||||
|
- Added option to hide edges between instances if there are only mentions in one direction (off by default).
|
||||||
|
- Added note to neighbors tab to make it explicit that blocked instances may appear.
|
||||||
|
- Added federation tab that shows federation restrictions (only available for some Pleroma instances).
|
||||||
|
- Add tabular view of instances.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Edges are no longer shown between instances where one blocks the other (based on the federation list in nodeinfo).
|
||||||
|
|
||||||
## [2.7.1 - 2018-08-23]
|
## [2.7.1 - 2018-08-23]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
8
LICENSE
8
LICENSE
|
@ -629,12 +629,12 @@ to attach them to the start of each source file to most effectively
|
||||||
state the exclusion of warranty; and each file should have at least
|
state the exclusion of warranty; and each file should have at least
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
fediverse.space
|
||||||
Copyright (C) <year> <name of author>
|
Copyright (C) 2019 fediverse.space
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
it under the terms of the GNU Affero General Public License as published
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
(at your option) any later version.
|
(at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
|
|
|
@ -0,0 +1,613 @@
|
||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies of this license
|
||||||
|
document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for software
|
||||||
|
and other kinds of works, specifically designed to ensure cooperation with
|
||||||
|
the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed to take
|
||||||
|
away your freedom to share and change the works. By contrast, our General
|
||||||
|
Public Licenses are intended to guarantee your freedom to share and change
|
||||||
|
all versions of a program--to make sure it remains free software for all its
|
||||||
|
users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not price. Our
|
||||||
|
General Public Licenses are designed to make sure that you have the freedom
|
||||||
|
to distribute copies of free software (and charge for them if you wish), that
|
||||||
|
you receive source code or can get it if you want it, that you can change
|
||||||
|
the software or use pieces of it in new free programs, and that you know you
|
||||||
|
can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights with two
|
||||||
|
steps: (1) assert copyright on the software, and (2) offer you this License
|
||||||
|
which gives you legal permission to copy, distribute and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that improvements made
|
||||||
|
in alternate versions of the program, if they receive widespread use, become
|
||||||
|
available for other developers to incorporate. Many developers of free software
|
||||||
|
are heartened and encouraged by the resulting cooperation. However, in the
|
||||||
|
case of software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and letting
|
||||||
|
the public access it on a server without ever releasing its source code to
|
||||||
|
the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to ensure that,
|
||||||
|
in such cases, the modified source code becomes available to the community.
|
||||||
|
It requires the operator of a network server to provide the source code of
|
||||||
|
the modified version running there to the users of that server. Therefore,
|
||||||
|
public use of a modified version, on a publicly accessible server, gives the
|
||||||
|
public access to the source code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and published by
|
||||||
|
Affero, was designed to accomplish similar goals. This is a different license,
|
||||||
|
not a version of the Affero GPL, but Affero has released a new version of
|
||||||
|
the Affero GPL which permits relicensing under this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and modification
|
||||||
|
follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of works,
|
||||||
|
such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this License.
|
||||||
|
Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals
|
||||||
|
or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work in
|
||||||
|
a fashion requiring copyright permission, other than the making of an exact
|
||||||
|
copy. The resulting work is called a "modified version" of the earlier work
|
||||||
|
or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based on the
|
||||||
|
Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without permission,
|
||||||
|
would make you directly or secondarily liable for infringement under applicable
|
||||||
|
copyright law, except executing it on a computer or modifying a private copy.
|
||||||
|
Propagation includes copying, distribution (with or without modification),
|
||||||
|
making available to the public, and in some countries other activities as
|
||||||
|
well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other parties
|
||||||
|
to make or receive copies. Mere interaction with a user through a computer
|
||||||
|
network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices" to the
|
||||||
|
extent that it includes a convenient and prominently visible feature that
|
||||||
|
(1) displays an appropriate copyright notice, and (2) tells the user that
|
||||||
|
there is no warranty for the work (except to the extent that warranties are
|
||||||
|
provided), that licensees may convey the work under this License, and how
|
||||||
|
to view a copy of this License. If the interface presents a list of user commands
|
||||||
|
or options, such as a menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work for making
|
||||||
|
modifications to it. "Object code" means any non-source form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official standard
|
||||||
|
defined by a recognized standards body, or, in the case of interfaces specified
|
||||||
|
for a particular programming language, one that is widely used among developers
|
||||||
|
working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other than
|
||||||
|
the work as a whole, that (a) is included in the normal form of packaging
|
||||||
|
a Major Component, but which is not part of that Major Component, and (b)
|
||||||
|
serves only to enable use of the work with that Major Component, or to implement
|
||||||
|
a Standard Interface for which an implementation is available to the public
|
||||||
|
in source code form. A "Major Component", in this context, means a major essential
|
||||||
|
component (kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to produce
|
||||||
|
the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all the source
|
||||||
|
code needed to generate, install, and (for an executable work) run the object
|
||||||
|
code and to modify the work, including scripts to control those activities.
|
||||||
|
However, it does not include the work's System Libraries, or general-purpose
|
||||||
|
tools or generally available free programs which are used unmodified in performing
|
||||||
|
those activities but which are not part of the work. For example, Corresponding
|
||||||
|
Source includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically linked
|
||||||
|
subprograms that the work is specifically designed to require, such as by
|
||||||
|
intimate data communication or control flow between those
|
||||||
|
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users can regenerate
|
||||||
|
automatically from other parts of the Corresponding Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of copyright
|
||||||
|
on the Program, and are irrevocable provided the stated conditions are met.
|
||||||
|
This License explicitly affirms your unlimited permission to run the unmodified
|
||||||
|
Program. The output from running a covered work is covered by this License
|
||||||
|
only if the output, given its content, constitutes a covered work. This License
|
||||||
|
acknowledges your rights of fair use or other equivalent, as provided by copyright
|
||||||
|
law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not convey, without
|
||||||
|
conditions so long as your license otherwise remains in force. You may convey
|
||||||
|
covered works to others for the sole purpose of having them make modifications
|
||||||
|
exclusively for you, or provide you with facilities for running those works,
|
||||||
|
provided that you comply with the terms of this License in conveying all material
|
||||||
|
for which you do not control copyright. Those thus making or running the covered
|
||||||
|
works for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of your copyrighted
|
||||||
|
material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under the conditions
|
||||||
|
stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological measure
|
||||||
|
under any applicable law fulfilling obligations under article 11 of the WIPO
|
||||||
|
copyright treaty adopted on 20 December 1996, or similar laws prohibiting
|
||||||
|
or restricting circumvention of such measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid circumvention
|
||||||
|
of technological measures to the extent such circumvention is effected by
|
||||||
|
exercising rights under this License with respect to the covered work, and
|
||||||
|
you disclaim any intention to limit operation or modification of the work
|
||||||
|
as a means of enforcing, against the work's users, your or third parties'
|
||||||
|
legal rights to forbid circumvention of technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you receive
|
||||||
|
it, in any medium, provided that you conspicuously and appropriately publish
|
||||||
|
on each copy an appropriate copyright notice; keep intact all notices stating
|
||||||
|
that this License and any non-permissive terms added in accord with section
|
||||||
|
7 apply to the code; keep intact all notices of the absence of any warranty;
|
||||||
|
and give all recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey, and you
|
||||||
|
may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to produce
|
||||||
|
it from the Program, in the form of source code under the terms of section
|
||||||
|
4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified it, and
|
||||||
|
giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is released under
|
||||||
|
this License and any conditions added under section 7. This requirement modifies
|
||||||
|
the requirement in section 4 to "keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this License to anyone
|
||||||
|
who comes into possession of a copy. This License will therefore apply, along
|
||||||
|
with any applicable section 7 additional terms, to the whole of the work,
|
||||||
|
and all its parts, regardless of how they are packaged. This License gives
|
||||||
|
no permission to license the work in any other way, but it does not invalidate
|
||||||
|
such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display Appropriate
|
||||||
|
Legal Notices; however, if the Program has interactive interfaces that do
|
||||||
|
not display Appropriate Legal Notices, your work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent works,
|
||||||
|
which are not by their nature extensions of the covered work, and which are
|
||||||
|
not combined with it such as to form a larger program, in or on a volume of
|
||||||
|
a storage or distribution medium, is called an "aggregate" if the compilation
|
||||||
|
and its resulting copyright are not used to limit the access or legal rights
|
||||||
|
of the compilation's users beyond what the individual works permit. Inclusion
|
||||||
|
of a covered work in an aggregate does not cause this License to apply to
|
||||||
|
the other parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms of sections
|
||||||
|
4 and 5, provided that you also convey the machine-readable Corresponding
|
||||||
|
Source under the terms of this License, in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product (including
|
||||||
|
a physical distribution medium), accompanied by the Corresponding Source fixed
|
||||||
|
on a durable physical medium customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product (including
|
||||||
|
a physical distribution medium), accompanied by a written offer, valid for
|
||||||
|
at least three years and valid for as long as you offer spare parts or customer
|
||||||
|
support for that product model, to give anyone who possesses the object code
|
||||||
|
either (1) a copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical medium customarily
|
||||||
|
used for software interchange, for a price no more than your reasonable cost
|
||||||
|
of physically performing this conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the written
|
||||||
|
offer to provide the Corresponding Source. This alternative is allowed only
|
||||||
|
occasionally and noncommercially, and only if you received the object code
|
||||||
|
with such an offer, in accord with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated place (gratis
|
||||||
|
or for a charge), and offer equivalent access to the Corresponding Source
|
||||||
|
in the same way through the same place at no further charge. You need not
|
||||||
|
require recipients to copy the Corresponding Source along with the object
|
||||||
|
code. If the place to copy the object code is a network server, the Corresponding
|
||||||
|
Source may be on a different server (operated by you or a third party) that
|
||||||
|
supports equivalent copying facilities, provided you maintain clear directions
|
||||||
|
next to the object code saying where to find the Corresponding Source. Regardless
|
||||||
|
of what server hosts the Corresponding Source, you remain obligated to ensure
|
||||||
|
that it is available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided you inform
|
||||||
|
other peers where the object code and Corresponding Source of the work are
|
||||||
|
being offered to the general public at no charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded from
|
||||||
|
the Corresponding Source as a System Library, need not be included in conveying
|
||||||
|
the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any tangible
|
||||||
|
personal property which is normally used for personal, family, or household
|
||||||
|
purposes, or (2) anything designed or sold for incorporation into a dwelling.
|
||||||
|
In determining whether a product is a consumer product, doubtful cases shall
|
||||||
|
be resolved in favor of coverage. For a particular product received by a particular
|
||||||
|
user, "normally used" refers to a typical or common use of that class of product,
|
||||||
|
regardless of the status of the particular user or of the way in which the
|
||||||
|
particular user actually uses, or expects or is expected to use, the product.
|
||||||
|
A product is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent the
|
||||||
|
only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods, procedures,
|
||||||
|
authorization keys, or other information required to install and execute modified
|
||||||
|
versions of a covered work in that User Product from a modified version of
|
||||||
|
its Corresponding Source. The information must suffice to ensure that the
|
||||||
|
continued functioning of the modified object code is in no case prevented
|
||||||
|
or interfered with solely because modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or specifically
|
||||||
|
for use in, a User Product, and the conveying occurs as part of a transaction
|
||||||
|
in which the right of possession and use of the User Product is transferred
|
||||||
|
to the recipient in perpetuity or for a fixed term (regardless of how the
|
||||||
|
transaction is characterized), the Corresponding Source conveyed under this
|
||||||
|
section must be accompanied by the Installation Information. But this requirement
|
||||||
|
does not apply if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has been installed
|
||||||
|
in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a requirement
|
||||||
|
to continue to provide support service, warranty, or updates for a work that
|
||||||
|
has been modified or installed by the recipient, or for the User Product in
|
||||||
|
which it has been modified or installed. Access to a network may be denied
|
||||||
|
when the modification itself materially and adversely affects the operation
|
||||||
|
of the network or violates the rules and protocols for communication across
|
||||||
|
the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided, in accord
|
||||||
|
with this section must be in a format that is publicly documented (and with
|
||||||
|
an implementation available to the public in source code form), and must require
|
||||||
|
no special password or key for unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this License
|
||||||
|
by making exceptions from one or more of its conditions. Additional permissions
|
||||||
|
that are applicable to the entire Program shall be treated as though they
|
||||||
|
were included in this License, to the extent that they are valid under applicable
|
||||||
|
law. If additional permissions apply only to part of the Program, that part
|
||||||
|
may be used separately under those permissions, but the entire Program remains
|
||||||
|
governed by this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option remove any
|
||||||
|
additional permissions from that copy, or from any part of it. (Additional
|
||||||
|
permissions may be written to require their own removal in certain cases when
|
||||||
|
you modify the work.) You may place additional permissions on material, added
|
||||||
|
by you to a covered work, for which you have or can give appropriate copyright
|
||||||
|
permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you add
|
||||||
|
to a covered work, you may (if authorized by the copyright holders of that
|
||||||
|
material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the terms of
|
||||||
|
sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or author
|
||||||
|
attributions in that material or in the Appropriate Legal Notices displayed
|
||||||
|
by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or requiring
|
||||||
|
that modified versions of such material be marked in reasonable ways as different
|
||||||
|
from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or authors
|
||||||
|
of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some trade names,
|
||||||
|
trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that material by
|
||||||
|
anyone who conveys the material (or modified versions of it) with contractual
|
||||||
|
assumptions of liability to the recipient, for any liability that these contractual
|
||||||
|
assumptions directly impose on those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further restrictions"
|
||||||
|
within the meaning of section 10. If the Program as you received it, or any
|
||||||
|
part of it, contains a notice stating that it is governed by this License
|
||||||
|
along with a term that is a further restriction, you may remove that term.
|
||||||
|
If a license document contains a further restriction but permits relicensing
|
||||||
|
or conveying under this License, you may add to a covered work material governed
|
||||||
|
by the terms of that license document, provided that the further restriction
|
||||||
|
does not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you must place,
|
||||||
|
in the relevant source files, a statement of the additional terms that apply
|
||||||
|
to those files, or a notice indicating where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the form
|
||||||
|
of a separately written license, or stated as exceptions; the above requirements
|
||||||
|
apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly provided
|
||||||
|
under this License. Any attempt otherwise to propagate or modify it is void,
|
||||||
|
and will automatically terminate your rights under this License (including
|
||||||
|
any patent licenses granted under the third paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your license from
|
||||||
|
a particular copyright holder is reinstated (a) provisionally, unless and
|
||||||
|
until the copyright holder explicitly and finally terminates your license,
|
||||||
|
and (b) permanently, if the copyright holder fails to notify you of the violation
|
||||||
|
by some reasonable means prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is reinstated permanently
|
||||||
|
if the copyright holder notifies you of the violation by some reasonable means,
|
||||||
|
this is the first time you have received notice of violation of this License
|
||||||
|
(for any work) from that copyright holder, and you cure the violation prior
|
||||||
|
to 30 days after your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the licenses
|
||||||
|
of parties who have received copies or rights from you under this License.
|
||||||
|
If your rights have been terminated and not permanently reinstated, you do
|
||||||
|
not qualify to receive new licenses for the same material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or run a copy
|
||||||
|
of the Program. Ancillary propagation of a covered work occurring solely as
|
||||||
|
a consequence of using peer-to-peer transmission to receive a copy likewise
|
||||||
|
does not require acceptance. However, nothing other than this License grants
|
||||||
|
you permission to propagate or modify any covered work. These actions infringe
|
||||||
|
copyright if you do not accept this License. Therefore, by modifying or propagating
|
||||||
|
a covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically receives
|
||||||
|
a license from the original licensors, to run, modify and propagate that work,
|
||||||
|
subject to this License. You are not responsible for enforcing compliance
|
||||||
|
by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an organization,
|
||||||
|
or substantially all assets of one, or subdividing an organization, or merging
|
||||||
|
organizations. If propagation of a covered work results from an entity transaction,
|
||||||
|
each party to that transaction who receives a copy of the work also receives
|
||||||
|
whatever licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the Corresponding
|
||||||
|
Source of the work from the predecessor in interest, if the predecessor has
|
||||||
|
it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the rights
|
||||||
|
granted or affirmed under this License. For example, you may not impose a
|
||||||
|
license fee, royalty, or other charge for exercise of rights granted under
|
||||||
|
this License, and you may not initiate litigation (including a cross-claim
|
||||||
|
or counterclaim in a lawsuit) alleging that any patent claim is infringed
|
||||||
|
by making, using, selling, offering for sale, or importing the Program or
|
||||||
|
any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this License
|
||||||
|
of the Program or a work on which the Program is based. The work thus licensed
|
||||||
|
is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims owned or controlled
|
||||||
|
by the contributor, whether already acquired or hereafter acquired, that would
|
||||||
|
be infringed by some manner, permitted by this License, of making, using,
|
||||||
|
or selling its contributor version, but do not include claims that would be
|
||||||
|
infringed only as a consequence of further modification of the contributor
|
||||||
|
version. For purposes of this definition, "control" includes the right to
|
||||||
|
grant patent sublicenses in a manner consistent with the requirements of this
|
||||||
|
License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free patent
|
||||||
|
license under the contributor's essential patent claims, to make, use, sell,
|
||||||
|
offer for sale, import and otherwise run, modify and propagate the contents
|
||||||
|
of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express agreement
|
||||||
|
or commitment, however denominated, not to enforce a patent (such as an express
|
||||||
|
permission to practice a patent or covenant not to s ue for patent infringement).
|
||||||
|
To "grant" such a patent license to a party means to make such an agreement
|
||||||
|
or commitment not to enforce a patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license, and the
|
||||||
|
Corresponding Source of the work is not available for anyone to copy, free
|
||||||
|
of charge and under the terms of this License, through a publicly available
|
||||||
|
network server or other readily accessible means, then you must either (1)
|
||||||
|
cause the Corresponding Source to be so available, or (2) arrange to deprive
|
||||||
|
yourself of the benefit of the patent license for this particular work, or
|
||||||
|
(3) arrange, in a manner consistent with the requirements of this License,
|
||||||
|
to extend the patent
|
||||||
|
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have actual
|
||||||
|
knowledge that, but for the patent license, your conveying the covered work
|
||||||
|
in a country, or your recipient's use of the covered work in a country, would
|
||||||
|
infringe one or more identifiable patents in that country that you have reason
|
||||||
|
to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or arrangement,
|
||||||
|
you convey, or propagate by procuring conveyance of, a covered work, and grant
|
||||||
|
a patent license to some of the parties receiving the covered work authorizing
|
||||||
|
them to use, propagate, modify or convey a specific copy of the covered work,
|
||||||
|
then the patent license you grant is automatically extended to all recipients
|
||||||
|
of the covered work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within the scope
|
||||||
|
of its coverage, prohibits the exercise of, or is conditioned on the non-exercise
|
||||||
|
of one or more of the rights that are specifically granted under this License.
|
||||||
|
You may not convey a covered work if you are a party to an arrangement with
|
||||||
|
a third party that is in the business of distributing software, under which
|
||||||
|
you make payment to the third party based on the extent of your activity of
|
||||||
|
conveying the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory patent
|
||||||
|
license (a) in connection with copies of the covered work conveyed by you
|
||||||
|
(or copies made from those copies), or (b) primarily for and in connection
|
||||||
|
with specific products or compilations that contain the covered work, unless
|
||||||
|
you entered into that arrangement, or that patent license was granted, prior
|
||||||
|
to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting any implied
|
||||||
|
license or other defenses to infringement that may otherwise be available
|
||||||
|
to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or otherwise)
|
||||||
|
that contradict the conditions of this License, they do not excuse you from
|
||||||
|
the conditions of this License. If you cannot convey a covered work so as
|
||||||
|
to satisfy simultaneously your obligations under this License and any other
|
||||||
|
pertinent obligations, then as a consequence you may
|
||||||
|
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey the
|
||||||
|
Program, the only way you could satisfy both those terms and this License
|
||||||
|
would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the Program,
|
||||||
|
your modified version must prominently offer all users interacting with it
|
||||||
|
remotely through a computer network (if your version supports such interaction)
|
||||||
|
an opportunity to receive the Corresponding Source of your version by providing
|
||||||
|
access to the Corresponding Source from a network server at no charge, through
|
||||||
|
some standard or customary means of facilitating copying of software. This
|
||||||
|
Corresponding Source shall include the Corresponding Source for any work covered
|
||||||
|
by version 3 of the GNU General Public License that is incorporated pursuant
|
||||||
|
to the following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have permission to
|
||||||
|
link or combine any covered work with a work licensed under version 3 of the
|
||||||
|
GNU General Public License into a single combined work, and to convey the
|
||||||
|
resulting work. The terms of this License will continue to apply to the part
|
||||||
|
which is the covered work, but the work with which it is combined will remain
|
||||||
|
governed by version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of the
|
||||||
|
GNU Affero General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to address
|
||||||
|
new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program specifies
|
||||||
|
that a certain numbered version of the GNU Affero General Public License "or
|
||||||
|
any later version" applies to it, you have the option of following the terms
|
||||||
|
and conditions either of that numbered version or of any later version published
|
||||||
|
by the Free Software Foundation. If the Program does not specify a version
|
||||||
|
number of the GNU Affero General Public License, you may choose any version
|
||||||
|
ever published by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future versions of
|
||||||
|
the GNU Affero General Public License can be used, that proxy's public statement
|
||||||
|
of acceptance of a version permanently authorizes you to choose that version
|
||||||
|
for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different permissions. However,
|
||||||
|
no additional obligations are imposed on any author or copyright holder as
|
||||||
|
a result of your choosing to follow a later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE
|
||||||
|
LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
|
||||||
|
OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER
|
||||||
|
EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||||
|
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||||
|
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM
|
||||||
|
PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
|
||||||
|
CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL
|
||||||
|
ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM
|
||||||
|
AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL,
|
||||||
|
INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO
|
||||||
|
USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED
|
||||||
|
INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE
|
||||||
|
PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
|
||||||
|
PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided above cannot
|
||||||
|
be given local legal effect according to their terms, reviewing courts shall
|
||||||
|
apply local law that most closely approximates an absolute waiver of all civil
|
||||||
|
liability in connection with the Program, unless a warranty or assumption
|
||||||
|
of liability accompanies a copy of the Program in return for a fee. END OF
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest possible
|
||||||
|
use to the public, the best way to achieve this is to make it free software
|
||||||
|
which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest to attach
|
||||||
|
them to the start of each source file to most effectively state the exclusion
|
||||||
|
of warranty; and each file should have at least the "copyright" line and a
|
||||||
|
pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify it under
|
||||||
|
the terms of the GNU Affero General Public License as published by the Free
|
||||||
|
Software Foundation, either version 3 of the License, or (at your option)
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful, but WITHOUT
|
||||||
|
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||||
|
FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License along
|
||||||
|
with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer network,
|
||||||
|
you should also make sure that it provides a way for users to get its source.
|
||||||
|
For example, if your program is a web application, its interface could display
|
||||||
|
a "Source" link that leads users to an archive of the code. There are many
|
||||||
|
ways you could offer source, and different solutions will be better for different
|
||||||
|
programs; see section 13 for the specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary. For
|
||||||
|
more information on this, and how to apply and follow the GNU AGPL, see <https://www.gnu.org/licenses/>.
|
41
README.md
41
README.md
|
@ -1,23 +1,30 @@
|
||||||
# fediverse.space 🌐
|
# index.community 🌐
|
||||||
|
|
||||||
The map of the fediverse that you always wanted.
|
The map of the fediverse that you always wanted.
|
||||||
|
|
||||||
Read the latest updates on Mastodon: [@fediversespace](https://cursed.technology/@fediversespace)
|
Read the latest updates on Mastodon: [@indexCommunity](https://social.inex.rocks/@indexCommunity)
|
||||||
|
|
||||||
![A screenshot of fediverse.space](screenshot.png)
|
![A screenshot of fediverse.space](screenshot.png)
|
||||||
|
|
||||||
1. [Requirements](#requirements)
|
- [index.community 🌐](#indexcommunity-%f0%9f%8c%90)
|
||||||
2. [Running it](#running-it)
|
- [Requirements](#requirements)
|
||||||
3. [Commands](#commands)
|
- [Running it](#running-it)
|
||||||
4. [Privacy](#privacy)
|
- [Backend](#backend)
|
||||||
5. [Deployment](#deployment)
|
- [Frontend](#frontend)
|
||||||
6. [Acknowledgements](#acknowledgements)
|
- [Commands](#commands)
|
||||||
|
- [Backend](#backend-1)
|
||||||
|
- [Frontend](#frontend-1)
|
||||||
|
- [Privacy](#privacy)
|
||||||
|
- [Deployment](#deployment)
|
||||||
|
- [Acknowledgements](#acknowledgements)
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
Though dockerized, backend development is easiest if you have the following installed.
|
Note: examples here use `podman`. In most cases you should be able to replace `podman` with `docker`.
|
||||||
|
|
||||||
- For the scraper + API:
|
Though containerized, backend development is easiest if you have the following installed.
|
||||||
|
|
||||||
|
- For the crawler + API:
|
||||||
- Elixir
|
- Elixir
|
||||||
- Postgres
|
- Postgres
|
||||||
- For laying out the graph:
|
- For laying out the graph:
|
||||||
|
@ -31,9 +38,11 @@ Though dockerized, backend development is easiest if you have the following inst
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
- `cp example.env .env` and modify environment variables as required
|
- `cp example.env .env` and modify environment variables as required
|
||||||
- `docker-compose build`
|
- `podman build gephi && podman build phoenix`
|
||||||
- `docker-compose up -d phoenix`
|
- `podman run --name elasticsearch -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" elasticsearch:6.8.9`
|
||||||
- if you don't specify `phoenix`, it'll also start `gephi` which should only be run as a regular one-off job
|
- If you've `run` this container previously, use `podman start elasticsearch`
|
||||||
|
- `podman run --name postgres -e "POSTGRES_USER=postgres" -e "POSTGRES_PASSWORD=postgres" -p 5432:5432 postgres:12`
|
||||||
|
- `podman-compose -f compose.backend-services.yml -f compose.phoenix.yml`
|
||||||
- Create the elasticsearch index:
|
- Create the elasticsearch index:
|
||||||
- `iex -S mix app.start`
|
- `iex -S mix app.start`
|
||||||
- `Elasticsearch.Index.hot_swap(Backend.Elasticsearch.Cluster, :instances)`
|
- `Elasticsearch.Index.hot_swap(Backend.Elasticsearch.Cluster, :instances)`
|
||||||
|
@ -48,10 +57,6 @@ Though dockerized, backend development is easiest if you have the following inst
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
`./gradlew shadowJar` compiles the graph layout program. `java -Xmx1g -jar build/libs/graphBuilder.jar` runs it.
|
`./gradlew shadowJar` compiles the graph layout program. `java -Xmx1g -jar build/libs/graphBuilder.jar` runs it.
|
||||||
If running in docker, this means you run
|
|
||||||
|
|
||||||
- `docker-compose build gephi`
|
|
||||||
- `docker-compose run gephi java -Xmx1g -jar build/libs/graphBuilder.jar` lays out the graph
|
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
|
@ -97,8 +102,6 @@ SHELL=/bin/bash
|
||||||
0 2 * * * /usr/bin/dokku run gephi java -Xmx1g -jar build/libs/graphBuilder.jar
|
0 2 * * * /usr/bin/dokku run gephi java -Xmx1g -jar build/libs/graphBuilder.jar
|
||||||
```
|
```
|
||||||
|
|
||||||
10. (Optional) Set up caching with something like [dokku-nginx-cache](https://github.com/Aluxian/dokku-nginx-cache)
|
|
||||||
|
|
||||||
Before the app starts running, make sure that the Elasticsearch index exists -- otherwise it'll create one called
|
Before the app starts running, make sure that the Elasticsearch index exists -- otherwise it'll create one called
|
||||||
`instances`, which should be the name of the alias. Then it won't be able to hot swap if you reindex in the future.
|
`instances`, which should be the name of the alias. Then it won't be able to hot swap if you reindex in the future.
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
FROM elixir:1.9.0-alpine as build
|
FROM elixir:1.12-alpine as build
|
||||||
|
|
||||||
# install build dependencies
|
# install build dependencies
|
||||||
RUN apk add --update git build-base
|
RUN apk add --update git build-base
|
||||||
|
|
||||||
# prepare build dir
|
# prepare build dir
|
||||||
RUN mkdir /app
|
RUN mkdir /app
|
||||||
|
@ -37,7 +37,7 @@ RUN mix release
|
||||||
|
|
||||||
# prepare release image
|
# prepare release image
|
||||||
FROM alpine:3.9 AS app
|
FROM alpine:3.9 AS app
|
||||||
RUN apk add --update bash openssl
|
RUN apk add --update bash openssl libstdc++ build-base
|
||||||
|
|
||||||
RUN mkdir /app
|
RUN mkdir /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- This project requires Elixir >= 1.9.
|
- This project requires Elixir >= 1.10.
|
||||||
- Run with `SKIP_CRAWL=true` to just run the server (useful for working on the API without also crawling)
|
- Run with `SKIP_CRAWL=true` to just run the server (useful for working on the API without also crawling)
|
||||||
- This project is automatically scanned for potential vulnerabilities with [Sobelow](https://sobelow.io/).
|
- This project is automatically scanned for potential vulnerabilities with [Sobelow](https://sobelow.io/).
|
||||||
|
|
||||||
|
@ -24,6 +24,8 @@ There are several environment variables you can set to configure how the crawler
|
||||||
- `FRONTEND_DOMAIN` (required). Used to generate login links for instance admins.
|
- `FRONTEND_DOMAIN` (required). Used to generate login links for instance admins.
|
||||||
- Don't enter `https://`, this is added automatically.
|
- Don't enter `https://`, this is added automatically.
|
||||||
- `SENDGRID_API_KEY`. Needed to send emails to the admin, or to instance admins who want to opt in/out.
|
- `SENDGRID_API_KEY`. Needed to send emails to the admin, or to instance admins who want to opt in/out.
|
||||||
|
- `MASTODON_DOMAIN`. The domain (e.g. `mastodon.social`) that your bot login account is hosted on.
|
||||||
|
- `MASTODON_TOKEN`. The access token for the bot login account.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|
|
@ -13,15 +13,13 @@ config :backend,
|
||||||
# Configures the endpoint
|
# Configures the endpoint
|
||||||
config :backend, BackendWeb.Endpoint,
|
config :backend, BackendWeb.Endpoint,
|
||||||
url: [host: "localhost"],
|
url: [host: "localhost"],
|
||||||
secret_key_base: "XL4NKGBN9lZMrQbMEI1KJOlwAt8S7younVJl90TdAgzmwyapr3g7BRYSNYvX0sZ9",
|
secret_key_base: System.get_env("SECRET_KEY_BASE"),
|
||||||
render_errors: [view: BackendWeb.ErrorView, accepts: ~w(json)],
|
render_errors: [view: BackendWeb.ErrorView, accepts: ~w(json)]
|
||||||
pubsub: [name: Backend.PubSub, adapter: Phoenix.PubSub.PG2],
|
|
||||||
instrumenters: [Appsignal.Phoenix.Instrumenter]
|
|
||||||
|
|
||||||
config :backend, Backend.Repo, queue_target: 5000
|
config :backend, Backend.Repo, queue_target: 5000
|
||||||
|
|
||||||
config :backend, Backend.Elasticsearch.Cluster,
|
config :backend, Backend.Elasticsearch.Cluster,
|
||||||
url: "http://localhost:9200",
|
url: "http://elastic:9200",
|
||||||
api: Elasticsearch.API.HTTP,
|
api: Elasticsearch.API.HTTP,
|
||||||
json_library: Jason
|
json_library: Jason
|
||||||
|
|
||||||
|
@ -37,33 +35,46 @@ config :gollum,
|
||||||
# 24 hrs
|
# 24 hrs
|
||||||
refresh_secs: 86_400,
|
refresh_secs: 86_400,
|
||||||
lazy_refresh: true,
|
lazy_refresh: true,
|
||||||
user_agent: "fediverse.space crawler"
|
user_agent: "index.community crawler"
|
||||||
|
|
||||||
config :backend, Graph.Cache,
|
config :backend, Graph.Cache,
|
||||||
# 1 hour
|
# 1 hour
|
||||||
gc_interval: 3600
|
gc_interval: 3600
|
||||||
|
|
||||||
config :ex_twilio,
|
|
||||||
account_sid: System.get_env("TWILIO_ACCOUNT_SID"),
|
|
||||||
auth_token: System.get_env("TWILIO_AUTH_TOKEN")
|
|
||||||
|
|
||||||
config :backend, Backend.Mailer,
|
config :backend, Backend.Mailer,
|
||||||
adapter: Swoosh.Adapters.Sendgrid,
|
adapter: Swoosh.Adapters.SMTP,
|
||||||
api_key: System.get_env("SENDGRID_API_KEY")
|
relay: System.get_env("MAILER_RELAY"),
|
||||||
|
username: System.get_env("MAILER_USERNAME"),
|
||||||
|
password: System.get_env("MAILER_PASSWORD"),
|
||||||
|
ssl: true,
|
||||||
|
tls: :always,
|
||||||
|
auth: :always,
|
||||||
|
port: 465
|
||||||
|
|
||||||
|
config :backend, Mastodon.Messenger,
|
||||||
|
domain: System.get_env("MASTODON_DOMAIN"),
|
||||||
|
token: System.get_env("MASTODON_TOKEN")
|
||||||
|
|
||||||
config :backend, :crawler,
|
config :backend, :crawler,
|
||||||
status_age_limit_days: 28,
|
status_age_limit_days: 28,
|
||||||
status_count_limit: 5000,
|
status_count_limit: 5000,
|
||||||
personal_instance_threshold: 10,
|
personal_instance_threshold: 10,
|
||||||
crawl_interval_mins: 60,
|
crawl_interval_mins: 30,
|
||||||
crawl_workers: 20,
|
crawl_workers: 100,
|
||||||
blacklist: [
|
blacklist: [
|
||||||
|
# spam
|
||||||
"gab.best",
|
"gab.best",
|
||||||
"4chan.icu"
|
# spam
|
||||||
|
"4chan.icu",
|
||||||
|
# *really* doesn't want to be listed on fediverse.space
|
||||||
|
"pleroma.site",
|
||||||
|
# dummy instances used for pleroma CI
|
||||||
|
"pleroma.online"
|
||||||
],
|
],
|
||||||
user_agent: "fediverse.space crawler",
|
user_agent: "index.community crawler",
|
||||||
|
require_bidirectional_mentions: false,
|
||||||
admin_phone: System.get_env("ADMIN_PHONE"),
|
admin_phone: System.get_env("ADMIN_PHONE"),
|
||||||
twilio_phone: System.get_env("TWILIO_PHONE"),
|
|
||||||
admin_email: System.get_env("ADMIN_EMAIL")
|
admin_email: System.get_env("ADMIN_EMAIL")
|
||||||
|
|
||||||
config :backend, Backend.Scheduler,
|
config :backend, Backend.Scheduler,
|
||||||
|
@ -80,6 +91,10 @@ config :backend, Backend.Scheduler,
|
||||||
{"0 */3 * * *", {Backend.Scheduler, :check_for_spam_instances, []}}
|
{"0 */3 * * *", {Backend.Scheduler, :check_for_spam_instances, []}}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
config :phoenix, :template_engines,
|
||||||
|
eex: Appsignal.Phoenix.Template.EExEngine,
|
||||||
|
exs: Appsignal.Phoenix.Template.ExsEngine
|
||||||
|
|
||||||
# Import environment specific config. This must remain at the bottom
|
# Import environment specific config. This must remain at the bottom
|
||||||
# of this file so it overrides the configuration defined above.
|
# of this file so it overrides the configuration defined above.
|
||||||
import_config "#{Mix.env()}.exs"
|
import_config "#{Mix.env()}.exs"
|
||||||
|
|
|
@ -7,7 +7,7 @@ import Config
|
||||||
# watchers to your application. For example, we use it
|
# watchers to your application. For example, we use it
|
||||||
# with webpack to recompile .js and .css sources.
|
# with webpack to recompile .js and .css sources.
|
||||||
config :backend, BackendWeb.Endpoint,
|
config :backend, BackendWeb.Endpoint,
|
||||||
http: [port: 4000],
|
http: [port: 4001],
|
||||||
debug_errors: true,
|
debug_errors: true,
|
||||||
code_reloader: true,
|
code_reloader: true,
|
||||||
check_origin: false,
|
check_origin: false,
|
||||||
|
@ -53,17 +53,13 @@ config :backend, Backend.Repo,
|
||||||
username: "postgres",
|
username: "postgres",
|
||||||
password: "postgres",
|
password: "postgres",
|
||||||
database: "backend_dev",
|
database: "backend_dev",
|
||||||
hostname: "localhost",
|
hostname: "127.0.0.1:5435",
|
||||||
pool_size: 10
|
pool_size: 10
|
||||||
|
|
||||||
config :backend, :crawler,
|
config :backend, :crawler,
|
||||||
status_age_limit_days: 28,
|
status_age_limit_days: 14,
|
||||||
status_count_limit: 100,
|
status_count_limit: 500,
|
||||||
personal_instance_threshold: 5,
|
personal_instance_threshold: 5,
|
||||||
crawl_interval_mins: 60,
|
crawl_interval_mins: 60,
|
||||||
crawl_workers: 10,
|
crawl_workers: 10,
|
||||||
blacklist: [
|
|
||||||
"gab.best",
|
|
||||||
"4chan.icu"
|
|
||||||
],
|
|
||||||
frontend_domain: "localhost:3000"
|
frontend_domain: "localhost:3000"
|
||||||
|
|
|
@ -19,7 +19,7 @@ config :backend, Backend.Elasticsearch.Cluster,
|
||||||
|
|
||||||
config :appsignal, :config, revision: System.get_env("GIT_REV")
|
config :appsignal, :config, revision: System.get_env("GIT_REV")
|
||||||
|
|
||||||
port = String.to_integer(System.get_env("PORT") || "4000")
|
port = String.to_integer(System.get_env("PORT") || "4001")
|
||||||
|
|
||||||
config :backend, BackendWeb.Endpoint,
|
config :backend, BackendWeb.Endpoint,
|
||||||
http: [:inet6, port: port],
|
http: [:inet6, port: port],
|
||||||
|
@ -28,16 +28,20 @@ config :backend, BackendWeb.Endpoint,
|
||||||
secret_key_base: System.get_env("SECRET_KEY_BASE"),
|
secret_key_base: System.get_env("SECRET_KEY_BASE"),
|
||||||
server: true
|
server: true
|
||||||
|
|
||||||
config :ex_twilio,
|
|
||||||
account_sid: System.get_env("TWILIO_ACCOUNT_SID"),
|
|
||||||
auth_token: System.get_env("TWILIO_AUTH_TOKEN")
|
|
||||||
|
|
||||||
config :backend, :crawler,
|
config :backend, :crawler,
|
||||||
admin_phone: System.get_env("ADMIN_PHONE"),
|
admin_phone: System.get_env("ADMIN_PHONE"),
|
||||||
twilio_phone: System.get_env("TWILIO_PHONE"),
|
|
||||||
admin_email: System.get_env("ADMIN_EMAIL"),
|
admin_email: System.get_env("ADMIN_EMAIL"),
|
||||||
frontend_domain: System.get_env("FRONTEND_DOMAIN")
|
frontend_domain: System.get_env("FRONTEND_DOMAIN")
|
||||||
|
|
||||||
config :backend, Backend.Mailer,
|
config :backend, Backend.Mailer,
|
||||||
adapter: Swoosh.Adapters.Sendgrid,
|
adapter: Swoosh.Adapters.SMTP,
|
||||||
api_key: System.get_env("SENDGRID_API_KEY")
|
relay: System.get_env("MAILER_RELAY"),
|
||||||
|
username: System.get_env("MAILER_USERNAME"),
|
||||||
|
password: System.get_env("MAILER_PASSWORD"),
|
||||||
|
ssl: true,
|
||||||
|
auth: :always,
|
||||||
|
port: 465
|
||||||
|
|
||||||
|
config :backend, Mastodon.Messenger,
|
||||||
|
domain: System.get_env("MASTODON_DOMAIN"),
|
||||||
|
token: System.get_env("MASTODON_TOKEN")
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
version: "2"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
space:
|
||||||
|
external: false
|
||||||
|
|
||||||
|
services:
|
||||||
|
server:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- space
|
||||||
|
volumes:
|
||||||
|
- /home/gitea/data:/data
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:12-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_PASSWORD: postgres
|
||||||
|
- POSTGRES_USER: postgres
|
||||||
|
networks:
|
||||||
|
- space
|
||||||
|
volumes:
|
||||||
|
- /var/lib/postgresql/data
|
||||||
|
|
||||||
|
elastic:
|
||||||
|
image: elasticsearch:6.8.9
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- discovery.type: single-node
|
||||||
|
networks:
|
||||||
|
- space
|
||||||
|
|
||||||
|
|
|
@ -6,16 +6,50 @@ defmodule Backend.Api do
|
||||||
import Backend.Util
|
import Backend.Util
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
@spec get_instance!(String.t()) :: Instance.t()
|
@type instance_sort_field :: :name | :user_count | :status_count | :insularity
|
||||||
def get_instance!(domain) do
|
@type sort_direction :: :asc | :desc
|
||||||
|
@spec get_instances(Integer.t() | nil, instance_sort_field | nil, sort_direction | nil) ::
|
||||||
|
Scrivener.Page.t()
|
||||||
|
def get_instances(page \\ nil, sort_field \\ nil, sort_direction \\ nil) do
|
||||||
Instance
|
Instance
|
||||||
|> Repo.get_by!(domain: domain)
|
|> where([i], not is_nil(i.type) and not i.opt_out)
|
||||||
|
|> maybe_order_by(sort_field, sort_direction)
|
||||||
|
|> Repo.paginate(page: page)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec get_instance_with_peers(String.t()) :: Instance.t() | nil
|
defp maybe_order_by(query, sort_field, sort_direction) do
|
||||||
def get_instance_with_peers(domain) do
|
cond do
|
||||||
|
sort_field == nil and sort_direction != nil ->
|
||||||
|
query
|
||||||
|
|
||||||
|
sort_field != nil and sort_direction == nil ->
|
||||||
|
query
|
||||||
|
|> order_by(desc: ^sort_field)
|
||||||
|
|
||||||
|
sort_direction == :asc ->
|
||||||
|
query
|
||||||
|
|> order_by(asc_nulls_last: ^sort_field)
|
||||||
|
|
||||||
|
sort_direction == :desc ->
|
||||||
|
query
|
||||||
|
|> order_by(desc_nulls_last: ^sort_field)
|
||||||
|
|
||||||
|
true ->
|
||||||
|
query
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec get_instance(String.t()) :: Instance.t() | nil
|
||||||
|
def get_instance(domain) do
|
||||||
|
Instance
|
||||||
|
|> Repo.get_by(domain: domain)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec get_instance_with_relationships(String.t()) :: Instance.t() | nil
|
||||||
|
def get_instance_with_relationships(domain) do
|
||||||
Instance
|
Instance
|
||||||
|> preload(:peers)
|
|> preload(:peers)
|
||||||
|
|> preload(:federation_restrictions)
|
||||||
|> Repo.get_by(domain: domain)
|
|> Repo.get_by(domain: domain)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -33,7 +67,8 @@ defmodule Backend.Api do
|
||||||
* the user count is > the threshold
|
* the user count is > the threshold
|
||||||
* have x and y coordinates
|
* have x and y coordinates
|
||||||
|
|
||||||
If `domain` is passed, then this function only returns nodes that are neighbors of that instance.
|
If `domain` is passed, then this function only returns nodes that are neighbors of that
|
||||||
|
instance.
|
||||||
"""
|
"""
|
||||||
@spec list_nodes() :: [Instance.t()]
|
@spec list_nodes() :: [Instance.t()]
|
||||||
def list_nodes(domain \\ nil) do
|
def list_nodes(domain \\ nil) do
|
||||||
|
|
|
@ -7,6 +7,7 @@ defmodule Backend.Application do
|
||||||
import Backend.Util
|
import Backend.Util
|
||||||
|
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
|
|
||||||
:telemetry.attach(
|
:telemetry.attach(
|
||||||
"appsignal-ecto",
|
"appsignal-ecto",
|
||||||
[:backend, :repo, :query],
|
[:backend, :repo, :query],
|
||||||
|
|
|
@ -15,8 +15,10 @@ defmodule Backend.Crawler.ApiCrawler do
|
||||||
|
|
||||||
# {domain_mentioned, count}
|
# {domain_mentioned, count}
|
||||||
@type instance_interactions :: %{String.t() => integer}
|
@type instance_interactions :: %{String.t() => integer}
|
||||||
|
# {domain, type} e.g. {"gab.com", "reject"}
|
||||||
|
@type federation_restriction :: {String.t(), String.t()}
|
||||||
|
|
||||||
@type instance_type :: :mastodon | :pleroma | :gab | :misskey | :gnusocial
|
@type instance_type :: :mastodon | :pleroma | :gab | :misskey | :gnusocial | :smithereen
|
||||||
|
|
||||||
defstruct [
|
defstruct [
|
||||||
:version,
|
:version,
|
||||||
|
@ -26,25 +28,39 @@ defmodule Backend.Crawler.ApiCrawler do
|
||||||
:peers,
|
:peers,
|
||||||
:interactions,
|
:interactions,
|
||||||
:statuses_seen,
|
:statuses_seen,
|
||||||
:instance_type
|
:instance_type,
|
||||||
|
:federation_restrictions
|
||||||
]
|
]
|
||||||
|
|
||||||
@type t() :: %__MODULE__{
|
@type t() :: %__MODULE__{
|
||||||
version: String.t(),
|
version: String.t() | nil,
|
||||||
description: String.t(),
|
description: String.t() | nil,
|
||||||
user_count: integer | nil,
|
user_count: integer | nil,
|
||||||
status_count: integer | nil,
|
status_count: integer | nil,
|
||||||
peers: [String.t()],
|
peers: [String.t()],
|
||||||
interactions: instance_interactions,
|
interactions: instance_interactions,
|
||||||
statuses_seen: integer,
|
statuses_seen: integer,
|
||||||
instance_type: instance_type
|
instance_type: instance_type | nil,
|
||||||
|
federation_restrictions: [federation_restriction]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@empty_result %{
|
||||||
|
version: nil,
|
||||||
|
description: nil,
|
||||||
|
user_count: nil,
|
||||||
|
status_count: nil,
|
||||||
|
peers: [],
|
||||||
|
interactions: %{},
|
||||||
|
statuses_seen: 0,
|
||||||
|
instance_type: nil,
|
||||||
|
federation_restrictions: []
|
||||||
|
}
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Check whether the instance at the given domain is of the type that this ApiCrawler implements.
|
Check whether the instance at the given domain is of the type that this ApiCrawler implements.
|
||||||
Arguments are the instance domain and the nodeinfo results.
|
Arguments are the instance domain and the nodeinfo results.
|
||||||
"""
|
"""
|
||||||
@callback is_instance_type?(String.t(), Nodeinfo.t()) :: boolean()
|
@callback is_instance_type?(String.t(), ApiCrawler.t()) :: boolean()
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Check whether the instance allows crawling according to its robots.txt or otherwise.
|
Check whether the instance allows crawling according to its robots.txt or otherwise.
|
||||||
|
@ -56,4 +72,11 @@ defmodule Backend.Crawler.ApiCrawler do
|
||||||
Takes two arguments: the domain to crawl and the existing results (from nodeinfo).
|
Takes two arguments: the domain to crawl and the existing results (from nodeinfo).
|
||||||
"""
|
"""
|
||||||
@callback crawl(String.t(), Nodeinfo.t()) :: t()
|
@callback crawl(String.t(), Nodeinfo.t()) :: t()
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the default, empty state
|
||||||
|
"""
|
||||||
|
def get_default do
|
||||||
|
@empty_result
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,7 +4,17 @@ defmodule Backend.Crawler do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias __MODULE__
|
alias __MODULE__
|
||||||
alias Backend.{Crawl, CrawlInteraction, Instance, InstancePeer, MostRecentCrawl, Repo}
|
|
||||||
|
alias Backend.{
|
||||||
|
Crawl,
|
||||||
|
CrawlInteraction,
|
||||||
|
FederationRestriction,
|
||||||
|
Instance,
|
||||||
|
InstancePeer,
|
||||||
|
MostRecentCrawl,
|
||||||
|
Repo
|
||||||
|
}
|
||||||
|
|
||||||
alias Backend.Crawler.ApiCrawler
|
alias Backend.Crawler.ApiCrawler
|
||||||
alias Backend.Crawler.Crawlers.{Friendica, GnuSocial, Mastodon, Misskey, Nodeinfo}
|
alias Backend.Crawler.Crawlers.{Friendica, GnuSocial, Mastodon, Misskey, Nodeinfo}
|
||||||
|
|
||||||
|
@ -75,14 +85,24 @@ defmodule Backend.Crawler do
|
||||||
# a) it should always be run first
|
# a) it should always be run first
|
||||||
# b) it passes the results on to the next crawlers (e.g. user_count)
|
# b) it passes the results on to the next crawlers (e.g. user_count)
|
||||||
defp crawl(%Crawler{api_crawlers: [Nodeinfo | remaining_crawlers], domain: domain} = state) do
|
defp crawl(%Crawler{api_crawlers: [Nodeinfo | remaining_crawlers], domain: domain} = state) do
|
||||||
with true <- Nodeinfo.allows_crawling?(domain), {:ok, nodeinfo} <- Nodeinfo.crawl(domain) do
|
if Nodeinfo.allows_crawling?(domain) do
|
||||||
Logger.debug("Found nodeinfo for #{domain}.")
|
nodeinfo = Nodeinfo.crawl(domain, nil)
|
||||||
result = Map.merge(nodeinfo, %{peers: [], interactions: %{}, statuses_seen: 0})
|
|
||||||
crawl(%Crawler{state | result: result, found_api?: true, api_crawlers: remaining_crawlers})
|
if nodeinfo != nil do
|
||||||
else
|
Logger.debug("Found nodeinfo for #{domain}.")
|
||||||
_ ->
|
|
||||||
|
crawl(%Crawler{
|
||||||
|
state
|
||||||
|
| result: nodeinfo,
|
||||||
|
found_api?: true,
|
||||||
|
api_crawlers: remaining_crawlers
|
||||||
|
})
|
||||||
|
else
|
||||||
Logger.debug("Did not find nodeinfo for #{domain}.")
|
Logger.debug("Did not find nodeinfo for #{domain}.")
|
||||||
crawl(%Crawler{state | api_crawlers: remaining_crawlers})
|
crawl(%Crawler{state | api_crawlers: remaining_crawlers})
|
||||||
|
end
|
||||||
|
else
|
||||||
|
crawl(%Crawler{state | api_crawlers: remaining_crawlers, allows_crawling?: false})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -114,7 +134,13 @@ defmodule Backend.Crawler do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Save the state (after crawling) to the database.
|
## Save the state (after crawling) to the database. ##
|
||||||
|
|
||||||
|
# If we didn't get a server type, the crawl wasn't successful.
|
||||||
|
defp save(%Crawler{result: %{type: nil}} = state) do
|
||||||
|
save_error(state)
|
||||||
|
end
|
||||||
|
|
||||||
defp save(%Crawler{
|
defp save(%Crawler{
|
||||||
domain: domain,
|
domain: domain,
|
||||||
result: result,
|
result: result,
|
||||||
|
@ -133,8 +159,8 @@ defmodule Backend.Crawler do
|
||||||
## Update the instance we crawled ##
|
## Update the instance we crawled ##
|
||||||
instance = %Instance{
|
instance = %Instance{
|
||||||
domain: domain,
|
domain: domain,
|
||||||
description: result.description,
|
description: HtmlSanitizeEx.basic_html(result.description),
|
||||||
version: result.version,
|
version: HtmlSanitizeEx.basic_html(result.version),
|
||||||
user_count: result.user_count,
|
user_count: result.user_count,
|
||||||
status_count: result.status_count,
|
status_count: result.status_count,
|
||||||
type: instance_type,
|
type: instance_type,
|
||||||
|
@ -165,7 +191,7 @@ defmodule Backend.Crawler do
|
||||||
|
|
||||||
Elasticsearch.put_document!(Backend.Elasticsearch.Cluster, instance, "instances/_doc")
|
Elasticsearch.put_document!(Backend.Elasticsearch.Cluster, instance, "instances/_doc")
|
||||||
|
|
||||||
# Save details of a new crawl
|
## Save details of a new crawl ##
|
||||||
curr_crawl =
|
curr_crawl =
|
||||||
Repo.insert!(%Crawl{
|
Repo.insert!(%Crawl{
|
||||||
instance_domain: domain,
|
instance_domain: domain,
|
||||||
|
@ -196,18 +222,24 @@ defmodule Backend.Crawler do
|
||||||
|> list_union(result.peers)
|
|> list_union(result.peers)
|
||||||
|> Enum.filter(fn domain -> domain != nil and not is_blacklisted?(domain) end)
|
|> Enum.filter(fn domain -> domain != nil and not is_blacklisted?(domain) end)
|
||||||
|> Enum.map(&clean_domain(&1))
|
|> Enum.map(&clean_domain(&1))
|
||||||
|
|> Enum.filter(fn peer_domain ->
|
||||||
|
if is_valid_domain?(peer_domain) do
|
||||||
|
true
|
||||||
|
else
|
||||||
|
Logger.info("Found invalid peer domain from #{domain}: #{peer_domain}")
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
if not Enum.all?(peers_domains, &is_valid_domain?(&1)) do
|
new_instances =
|
||||||
invalid_peers = Enum.filter(peers_domains, fn d -> not is_valid_domain?(d) end)
|
|
||||||
raise "#{domain} has invalid peers: #{Enum.join(invalid_peers, ", ")}"
|
|
||||||
end
|
|
||||||
|
|
||||||
peers =
|
|
||||||
peers_domains
|
peers_domains
|
||||||
|
|> list_union(
|
||||||
|
Enum.map(result.federation_restrictions, fn {domain, _restriction_type} -> domain end)
|
||||||
|
)
|
||||||
|> Enum.map(&%{domain: &1, inserted_at: now, updated_at: now, next_crawl: now})
|
|> Enum.map(&%{domain: &1, inserted_at: now, updated_at: now, next_crawl: now})
|
||||||
|
|
||||||
Instance
|
Instance
|
||||||
|> Repo.insert_all(peers, on_conflict: :nothing, conflict_target: :domain)
|
|> Repo.insert_all(new_instances, on_conflict: :nothing, conflict_target: :domain)
|
||||||
|
|
||||||
Repo.transaction(fn ->
|
Repo.transaction(fn ->
|
||||||
## Save peer relationships ##
|
## Save peer relationships ##
|
||||||
|
@ -249,6 +281,55 @@ defmodule Backend.Crawler do
|
||||||
|> Repo.insert_all(new_instance_peers)
|
|> Repo.insert_all(new_instance_peers)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
## Save federation restrictions ##
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
current_restrictions =
|
||||||
|
FederationRestriction
|
||||||
|
|> select([fr], {fr.target_domain, fr.type})
|
||||||
|
|> where(source_domain: ^domain)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
wanted_restrictions_set =
|
||||||
|
result.federation_restrictions
|
||||||
|
|> MapSet.new()
|
||||||
|
|
||||||
|
current_restrictions_set = MapSet.new(current_restrictions)
|
||||||
|
|
||||||
|
# Delete the ones we don't want
|
||||||
|
restrictions_to_delete =
|
||||||
|
current_restrictions_set
|
||||||
|
|> MapSet.difference(wanted_restrictions_set)
|
||||||
|
|> MapSet.to_list()
|
||||||
|
|> Enum.map(fn {target_domain, _type} -> target_domain end)
|
||||||
|
|
||||||
|
if length(restrictions_to_delete) > 0 do
|
||||||
|
FederationRestriction
|
||||||
|
|> where(
|
||||||
|
[fr],
|
||||||
|
fr.source_domain == ^domain and fr.target_domain in ^restrictions_to_delete
|
||||||
|
)
|
||||||
|
|> Repo.delete_all()
|
||||||
|
end
|
||||||
|
|
||||||
|
# Save the new ones
|
||||||
|
new_restrictions =
|
||||||
|
wanted_restrictions_set
|
||||||
|
|> MapSet.difference(current_restrictions_set)
|
||||||
|
|> MapSet.to_list()
|
||||||
|
|> Enum.map(fn {target_domain, type} ->
|
||||||
|
%{
|
||||||
|
source_domain: domain,
|
||||||
|
target_domain: target_domain,
|
||||||
|
type: type,
|
||||||
|
inserted_at: now,
|
||||||
|
updated_at: now
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
FederationRestriction
|
||||||
|
|> Repo.insert_all(new_restrictions)
|
||||||
|
end)
|
||||||
|
|
||||||
## Save interactions ##
|
## Save interactions ##
|
||||||
interactions =
|
interactions =
|
||||||
result.interactions
|
result.interactions
|
||||||
|
@ -270,7 +351,11 @@ defmodule Backend.Crawler do
|
||||||
Appsignal.increment_counter("crawler.success", 1)
|
Appsignal.increment_counter("crawler.success", 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp save(%{domain: domain, error: error, allows_crawling?: allows_crawling}) do
|
defp save(state) do
|
||||||
|
save_error(state)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp save_error(%{domain: domain, error: error, allows_crawling?: allows_crawling}) do
|
||||||
now = get_now()
|
now = get_now()
|
||||||
|
|
||||||
error =
|
error =
|
||||||
|
|
|
@ -62,12 +62,11 @@ defmodule Backend.Crawler.Crawlers.Friendica do
|
||||||
end)
|
end)
|
||||||
|
|
||||||
if details |> Map.get(:user_count, 0) |> is_above_user_threshold?() do
|
if details |> Map.get(:user_count, 0) |> is_above_user_threshold?() do
|
||||||
Map.merge(
|
ApiCrawler.get_default()
|
||||||
%{peers: peers, interactions: %{}, statuses_seen: 0, instance_type: :friendica},
|
|> Map.merge(%{peers: peers, instance_type: :friendica})
|
||||||
Map.take(details, [:description, :version, :user_count, :status_count])
|
|> Map.merge(Map.take(details, [:description, :version, :user_count, :status_count]))
|
||||||
)
|
|
||||||
else
|
else
|
||||||
nodeinfo_result
|
Map.merge(ApiCrawler.get_default(), nodeinfo_result)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,6 @@ defmodule Backend.Crawler.Crawlers.GnuSocial do
|
||||||
Crawler for GNU Social servers.
|
Crawler for GNU Social servers.
|
||||||
"""
|
"""
|
||||||
alias Backend.Crawler.ApiCrawler
|
alias Backend.Crawler.ApiCrawler
|
||||||
alias Backend.Crawler.Crawlers.Nodeinfo
|
|
||||||
import Backend.Crawler.Util
|
import Backend.Crawler.Util
|
||||||
import Backend.Util
|
import Backend.Util
|
||||||
require Logger
|
require Logger
|
||||||
|
@ -32,17 +31,17 @@ defmodule Backend.Crawler.Crawlers.GnuSocial do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl ApiCrawler
|
@impl ApiCrawler
|
||||||
def crawl(domain, nodeinfo_result) do
|
def crawl(domain, nodeinfo) do
|
||||||
if nodeinfo_result == nil or
|
if nodeinfo == nil or
|
||||||
nodeinfo_result |> Map.get(:user_count) |> is_above_user_threshold?() do
|
nodeinfo |> Map.get(:user_count) |> is_above_user_threshold?() do
|
||||||
crawl_large_instance(domain, nodeinfo_result)
|
Map.merge(nodeinfo, crawl_large_instance(domain))
|
||||||
else
|
else
|
||||||
nodeinfo_result
|
Map.merge(ApiCrawler.get_default(), nodeinfo)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec crawl_large_instance(String.t(), Nodeinfo.t()) :: ApiCrawler.t()
|
@spec crawl_large_instance(String.t()) :: ApiCrawler.t()
|
||||||
defp crawl_large_instance(domain, nodeinfo_result) do
|
defp crawl_large_instance(domain) do
|
||||||
status_datetime_threshold =
|
status_datetime_threshold =
|
||||||
NaiveDateTime.utc_now()
|
NaiveDateTime.utc_now()
|
||||||
|> NaiveDateTime.add(get_config(:status_age_limit_days) * 24 * 3600 * -1, :second)
|
|> NaiveDateTime.add(get_config(:status_age_limit_days) * 24 * 3600 * -1, :second)
|
||||||
|
@ -52,24 +51,14 @@ defmodule Backend.Crawler.Crawlers.GnuSocial do
|
||||||
|
|
||||||
{interactions, statuses_seen} = get_interactions(domain, min_timestamp)
|
{interactions, statuses_seen} = get_interactions(domain, min_timestamp)
|
||||||
|
|
||||||
if nodeinfo_result != nil do
|
Map.merge(
|
||||||
Map.merge(nodeinfo_result, %{
|
ApiCrawler.get_default(),
|
||||||
interactions: interactions,
|
|
||||||
statuses_seen: statuses_seen,
|
|
||||||
peers: []
|
|
||||||
})
|
|
||||||
else
|
|
||||||
%{
|
%{
|
||||||
version: nil,
|
|
||||||
description: nil,
|
|
||||||
user_count: nil,
|
|
||||||
status_count: nil,
|
|
||||||
peers: [],
|
|
||||||
interactions: interactions,
|
interactions: interactions,
|
||||||
statuses_seen: statuses_seen,
|
statuses_seen: statuses_seen,
|
||||||
instance_type: :gnusocial
|
instance_type: :gnusocial
|
||||||
}
|
}
|
||||||
end
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec get_interactions(
|
@spec get_interactions(
|
||||||
|
|
|
@ -12,7 +12,7 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
|
||||||
@impl ApiCrawler
|
@impl ApiCrawler
|
||||||
def is_instance_type?(domain, result) do
|
def is_instance_type?(domain, result) do
|
||||||
# We might already know that this is a Pleroma instance from nodeinfo
|
# We might already know that this is a Pleroma instance from nodeinfo
|
||||||
if result != nil and Map.get(result, :instance_type) == :pleroma do
|
if result != nil and (Map.get(result, :instance_type) == :pleroma or Map.get(result, :instance_type) == :smithereen) do
|
||||||
true
|
true
|
||||||
else
|
else
|
||||||
case get_and_decode("https://#{domain}/api/v1/instance") do
|
case get_and_decode("https://#{domain}/api/v1/instance") do
|
||||||
|
@ -34,26 +34,19 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl ApiCrawler
|
@impl ApiCrawler
|
||||||
def crawl(domain, _current_result) do
|
def crawl(domain, nodeinfo) do
|
||||||
instance = get_and_decode!("https://#{domain}/api/v1/instance")
|
instance = get_and_decode!("https://#{domain}/api/v1/instance")
|
||||||
user_count = get_in(instance, ["stats", "user_count"])
|
user_count = get_in(instance, ["stats", "user_count"])
|
||||||
|
|
||||||
if is_above_user_threshold?(user_count) or has_opted_in?(domain) do
|
if is_above_user_threshold?(user_count) or has_opted_in?(domain) do
|
||||||
crawl_large_instance(domain, instance)
|
Map.merge(nodeinfo, crawl_large_instance(domain, instance))
|
||||||
else
|
else
|
||||||
Map.merge(
|
ApiCrawler.get_default()
|
||||||
Map.take(instance["stats"], ["user_count"])
|
|> Map.merge(nodeinfo)
|
||||||
|> convert_keys_to_atoms(),
|
|> Map.merge(%{
|
||||||
%{
|
instance_type: get_instance_type(instance),
|
||||||
peers: [],
|
user_count: get_in(instance, ["stats", "user_count"])
|
||||||
interactions: %{},
|
})
|
||||||
statuses_seen: 0,
|
|
||||||
instance_type: nil,
|
|
||||||
description: nil,
|
|
||||||
version: nil,
|
|
||||||
status_count: nil
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -71,13 +64,6 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
|
||||||
} mentions in #{statuses_seen} statuses."
|
} mentions in #{statuses_seen} statuses."
|
||||||
)
|
)
|
||||||
|
|
||||||
instance_type =
|
|
||||||
cond do
|
|
||||||
Map.get(instance, "version") |> String.downcase() =~ "pleroma" -> :pleroma
|
|
||||||
is_gab?(instance) -> :gab
|
|
||||||
true -> :mastodon
|
|
||||||
end
|
|
||||||
|
|
||||||
Map.merge(
|
Map.merge(
|
||||||
Map.merge(
|
Map.merge(
|
||||||
Map.take(instance, ["version", "description"]),
|
Map.take(instance, ["version", "description"]),
|
||||||
|
@ -88,7 +74,7 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
|
||||||
peers: peers,
|
peers: peers,
|
||||||
interactions: interactions,
|
interactions: interactions,
|
||||||
statuses_seen: statuses_seen,
|
statuses_seen: statuses_seen,
|
||||||
instance_type: instance_type
|
instance_type: get_instance_type(instance)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
@ -240,4 +226,13 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
|
||||||
title_is_gab
|
title_is_gab
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp get_instance_type(instance_stats) do
|
||||||
|
cond do
|
||||||
|
Map.get(instance_stats, "version") |> String.downcase() =~ "pleroma" -> :pleroma
|
||||||
|
Map.get(instance_stats, "version") |> String.downcase() =~ "smithereen" -> :smithereen
|
||||||
|
is_gab?(instance_stats) -> :gab
|
||||||
|
true -> :mastodon
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -35,22 +35,18 @@ defmodule Backend.Crawler.Crawlers.Misskey do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl ApiCrawler
|
@impl ApiCrawler
|
||||||
def crawl(domain, _result) do
|
def crawl(domain, nodeinfo) do
|
||||||
with {:ok, %{"originalUsersCount" => user_count, "originalNotesCount" => status_count}} <-
|
with {:ok, %{"originalUsersCount" => user_count, "originalNotesCount" => status_count}} <-
|
||||||
post_and_decode("https://#{domain}/api/stats") do
|
post_and_decode("https://#{domain}/api/stats") do
|
||||||
if is_above_user_threshold?(user_count) or has_opted_in?(domain) do
|
if is_above_user_threshold?(user_count) or has_opted_in?(domain) do
|
||||||
crawl_large_instance(domain, user_count, status_count)
|
Map.merge(nodeinfo, crawl_large_instance(domain, user_count, status_count))
|
||||||
else
|
else
|
||||||
%{
|
ApiCrawler.get_default()
|
||||||
version: nil,
|
|> Map.merge(nodeinfo)
|
||||||
description: nil,
|
|> Map.merge(%{
|
||||||
user_count: user_count,
|
user_count: user_count,
|
||||||
status_count: nil,
|
type: :misskey
|
||||||
peers: [],
|
})
|
||||||
interactions: %{},
|
|
||||||
statuses_seen: 0,
|
|
||||||
instance_type: nil
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,34 +1,16 @@
|
||||||
defmodule Backend.Crawler.Crawlers.Nodeinfo do
|
defmodule Backend.Crawler.Crawlers.Nodeinfo do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
This module is slightly different from the other crawlers.
|
This module is slightly different from the other crawlers. It's run before all the others and its
|
||||||
It doesn't implement the ApiCrawler spec because it isn't run as a self-contained crawler.
|
result is included in theirs.
|
||||||
Instead, it's run before all the other crawlers.
|
|
||||||
|
|
||||||
This is to get the user count. Some servers don't publish this in other places (e.g. GNU Social, PeerTube) so we need
|
|
||||||
nodeinfo to know whether it's a personal instance or not.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Backend.Crawler.ApiCrawler
|
alias Backend.Crawler.ApiCrawler
|
||||||
require Logger
|
require Logger
|
||||||
import Backend.Util
|
import Backend.Util
|
||||||
import Backend.Crawler.Util
|
import Backend.Crawler.Util
|
||||||
|
@behaviour ApiCrawler
|
||||||
|
|
||||||
defstruct [
|
@impl ApiCrawler
|
||||||
:description,
|
|
||||||
:user_count,
|
|
||||||
:status_count,
|
|
||||||
:instance_type,
|
|
||||||
:version
|
|
||||||
]
|
|
||||||
|
|
||||||
@type t() :: %__MODULE__{
|
|
||||||
description: String.t(),
|
|
||||||
user_count: integer,
|
|
||||||
status_count: integer,
|
|
||||||
instance_type: ApiCrawler.instance_type(),
|
|
||||||
version: String.t()
|
|
||||||
}
|
|
||||||
|
|
||||||
@spec allows_crawling?(String.t()) :: boolean()
|
|
||||||
def allows_crawling?(domain) do
|
def allows_crawling?(domain) do
|
||||||
[
|
[
|
||||||
".well-known/nodeinfo"
|
".well-known/nodeinfo"
|
||||||
|
@ -37,13 +19,19 @@ defmodule Backend.Crawler.Crawlers.Nodeinfo do
|
||||||
|> urls_are_crawlable?()
|
|> urls_are_crawlable?()
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec crawl(String.t()) :: {:ok, t()} | {:error, nil}
|
@impl ApiCrawler
|
||||||
def crawl(domain) do
|
def is_instance_type?(_domain, _nodeinfo) do
|
||||||
|
# This crawler is used slightly differently from the others -- we always check for nodeinfo.
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl ApiCrawler
|
||||||
|
def crawl(domain, _curr_result) do
|
||||||
with {:ok, nodeinfo_url} <- get_nodeinfo_url(domain),
|
with {:ok, nodeinfo_url} <- get_nodeinfo_url(domain),
|
||||||
{:ok, nodeinfo} <- get_nodeinfo(nodeinfo_url) do
|
{:ok, nodeinfo} <- get_nodeinfo(nodeinfo_url) do
|
||||||
{:ok, nodeinfo}
|
nodeinfo
|
||||||
else
|
else
|
||||||
_other -> {:error, nil}
|
_other -> ApiCrawler.get_default()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -65,8 +53,7 @@ defmodule Backend.Crawler.Crawlers.Nodeinfo do
|
||||||
|> Map.get("href")
|
|> Map.get("href")
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec get_nodeinfo(String.t()) ::
|
@spec get_nodeinfo(String.t()) :: ApiCrawler.t()
|
||||||
{:ok, t()} | {:error, Jason.DecodeError.t() | HTTPoison.Error.t()}
|
|
||||||
defp get_nodeinfo(nodeinfo_url) do
|
defp get_nodeinfo(nodeinfo_url) do
|
||||||
case get_and_decode(nodeinfo_url) do
|
case get_and_decode(nodeinfo_url) do
|
||||||
{:ok, nodeinfo} -> {:ok, process_nodeinfo(nodeinfo)}
|
{:ok, nodeinfo} -> {:ok, process_nodeinfo(nodeinfo)}
|
||||||
|
@ -74,7 +61,7 @@ defmodule Backend.Crawler.Crawlers.Nodeinfo do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec process_nodeinfo(any()) :: t()
|
@spec process_nodeinfo(any()) :: ApiCrawler.t()
|
||||||
defp process_nodeinfo(nodeinfo) do
|
defp process_nodeinfo(nodeinfo) do
|
||||||
user_count = get_in(nodeinfo, ["usage", "users", "total"])
|
user_count = get_in(nodeinfo, ["usage", "users", "total"])
|
||||||
|
|
||||||
|
@ -90,21 +77,24 @@ defmodule Backend.Crawler.Crawlers.Nodeinfo do
|
||||||
|
|
||||||
type = nodeinfo |> get_in(["software", "name"]) |> String.downcase() |> String.to_atom()
|
type = nodeinfo |> get_in(["software", "name"]) |> String.downcase() |> String.to_atom()
|
||||||
|
|
||||||
%__MODULE__{
|
Map.merge(
|
||||||
description: description,
|
ApiCrawler.get_default(),
|
||||||
user_count: user_count,
|
%{
|
||||||
status_count: get_in(nodeinfo, ["usage", "localPosts"]),
|
description: description,
|
||||||
instance_type: type,
|
user_count: user_count,
|
||||||
version: get_in(nodeinfo, ["software", "version"])
|
status_count: get_in(nodeinfo, ["usage", "localPosts"]),
|
||||||
}
|
instance_type: type,
|
||||||
|
version: get_in(nodeinfo, ["software", "version"]),
|
||||||
|
federation_restrictions: get_federation_restrictions(nodeinfo)
|
||||||
|
}
|
||||||
|
)
|
||||||
else
|
else
|
||||||
%{
|
Map.merge(
|
||||||
description: nil,
|
ApiCrawler.get_default(),
|
||||||
user_count: user_count,
|
%{
|
||||||
status_count: nil,
|
user_count: user_count
|
||||||
instance_type: nil,
|
}
|
||||||
version: nil
|
)
|
||||||
}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -113,4 +103,37 @@ defmodule Backend.Crawler.Crawlers.Nodeinfo do
|
||||||
version = String.slice(schema_url, (String.length(schema_url) - 3)..-1)
|
version = String.slice(schema_url, (String.length(schema_url) - 3)..-1)
|
||||||
Enum.member?(["1.0", "1.1", "2.0"], version)
|
Enum.member?(["1.0", "1.1", "2.0"], version)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec get_federation_restrictions(any()) :: [ApiCrawler.federation_restriction()]
|
||||||
|
defp get_federation_restrictions(nodeinfo) do
|
||||||
|
mrf_simple = get_in(nodeinfo, ["metadata", "federation", "mrf_simple"])
|
||||||
|
quarantined_domains = get_in(nodeinfo, ["metadata", "federation", "quarantined_instances"])
|
||||||
|
|
||||||
|
quarantined_domains =
|
||||||
|
if quarantined_domains == nil do
|
||||||
|
[]
|
||||||
|
else
|
||||||
|
Enum.map(quarantined_domains, fn domain -> {domain, "quarantine"} end)
|
||||||
|
end
|
||||||
|
|
||||||
|
if mrf_simple != nil do
|
||||||
|
mrf_simple
|
||||||
|
|> Map.take([
|
||||||
|
"report_removal",
|
||||||
|
"reject",
|
||||||
|
"media_removal",
|
||||||
|
"media_nsfw",
|
||||||
|
"federated_timeline_removal",
|
||||||
|
"banner_removal",
|
||||||
|
"avatar_removal",
|
||||||
|
"accept"
|
||||||
|
])
|
||||||
|
|> Enum.flat_map(fn {type, domains} ->
|
||||||
|
Enum.map(domains, fn domain -> {domain, type} end)
|
||||||
|
end)
|
||||||
|
|> Enum.concat(quarantined_domains)
|
||||||
|
else
|
||||||
|
quarantined_domains
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -26,7 +26,7 @@ defmodule Backend.Crawler.StaleInstanceManager do
|
||||||
case instance_count do
|
case instance_count do
|
||||||
# Add m.s. as the seed and schedule the next add
|
# Add m.s. as the seed and schedule the next add
|
||||||
0 ->
|
0 ->
|
||||||
add_to_queue("mastodon.social")
|
add_to_queue("mastodon.ml")
|
||||||
schedule_add()
|
schedule_add()
|
||||||
|
|
||||||
# Start immediately
|
# Start immediately
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
defmodule Backend.FederationRestriction do
|
||||||
|
@moduledoc false
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
schema "federation_restrictions" do
|
||||||
|
belongs_to :source, Backend.Instance,
|
||||||
|
references: :domain,
|
||||||
|
type: :string,
|
||||||
|
foreign_key: :source_domain
|
||||||
|
|
||||||
|
belongs_to :target, Backend.Instance,
|
||||||
|
references: :domain,
|
||||||
|
type: :string,
|
||||||
|
foreign_key: :target_domain
|
||||||
|
|
||||||
|
field :type, :string
|
||||||
|
|
||||||
|
timestamps()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def changeset(federation_restriction, attrs) do
|
||||||
|
federation_restriction
|
||||||
|
|> cast(attrs, [:source, :target, :type])
|
||||||
|
|> validate_required([:source, :target, :type])
|
||||||
|
end
|
||||||
|
end
|
|
@ -32,6 +32,10 @@ defmodule Backend.Instance do
|
||||||
foreign_key: :source_domain,
|
foreign_key: :source_domain,
|
||||||
references: :domain
|
references: :domain
|
||||||
|
|
||||||
|
has_many :federation_restrictions, Backend.FederationRestriction,
|
||||||
|
foreign_key: :source_domain,
|
||||||
|
references: :domain
|
||||||
|
|
||||||
timestamps()
|
timestamps()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ defmodule Backend.Release do
|
||||||
]
|
]
|
||||||
|
|
||||||
# Ecto repos to start, if any
|
# Ecto repos to start, if any
|
||||||
@repos Application.get_env(:backend, :ecto_repos, [])
|
@repos Application.compile_env(:backend, :ecto_repos, [])
|
||||||
# Elasticsearch clusters to start
|
# Elasticsearch clusters to start
|
||||||
@clusters [Backend.Elasticsearch.Cluster]
|
@clusters [Backend.Elasticsearch.Cluster]
|
||||||
# Elasticsearch indexes to build
|
# Elasticsearch indexes to build
|
||||||
|
|
|
@ -4,7 +4,7 @@ defmodule Backend.Repo do
|
||||||
adapter: Ecto.Adapters.Postgres,
|
adapter: Ecto.Adapters.Postgres,
|
||||||
timeout: 25_000
|
timeout: 25_000
|
||||||
|
|
||||||
use Paginator
|
use Scrivener, page_size: 20
|
||||||
|
|
||||||
def init(_type, config) do
|
def init(_type, config) do
|
||||||
{:ok, Keyword.put(config, :url, System.get_env("DATABASE_URL"))}
|
{:ok, Keyword.put(config, :url, System.get_env("DATABASE_URL"))}
|
||||||
|
|
|
@ -3,10 +3,9 @@ defmodule Backend.Scheduler do
|
||||||
This module runs recurring tasks.
|
This module runs recurring tasks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use Appsignal.Instrumentation.Decorators
|
use Quantum, otp_app: :backend
|
||||||
use Quantum.Scheduler, otp_app: :backend
|
|
||||||
|
|
||||||
alias Backend.{Crawl, CrawlInteraction, Edge, Instance, Repo}
|
alias Backend.{Crawl, CrawlInteraction, Edge, FederationRestriction, Instance, Repo}
|
||||||
alias Backend.Mailer.AdminEmail
|
alias Backend.Mailer.AdminEmail
|
||||||
|
|
||||||
import Backend.Util
|
import Backend.Util
|
||||||
|
@ -21,7 +20,6 @@ defmodule Backend.Scheduler do
|
||||||
`unit` must singular, e.g. "second", "minute", "hour", "month", "year", etc...
|
`unit` must singular, e.g. "second", "minute", "hour", "month", "year", etc...
|
||||||
"""
|
"""
|
||||||
@spec prune_crawls(integer, String.t()) :: any
|
@spec prune_crawls(integer, String.t()) :: any
|
||||||
@decorate transaction()
|
|
||||||
def prune_crawls(amount, unit) do
|
def prune_crawls(amount, unit) do
|
||||||
{deleted_num, _} =
|
{deleted_num, _} =
|
||||||
Crawl
|
Crawl
|
||||||
|
@ -39,7 +37,6 @@ defmodule Backend.Scheduler do
|
||||||
Calculates every instance's "insularity score" -- that is, the percentage of mentions that are among users on the
|
Calculates every instance's "insularity score" -- that is, the percentage of mentions that are among users on the
|
||||||
instance, rather than at other instances.
|
instance, rather than at other instances.
|
||||||
"""
|
"""
|
||||||
@decorate transaction()
|
|
||||||
def generate_insularity_scores do
|
def generate_insularity_scores do
|
||||||
now = get_now()
|
now = get_now()
|
||||||
|
|
||||||
|
@ -47,28 +44,51 @@ defmodule Backend.Scheduler do
|
||||||
Crawl
|
Crawl
|
||||||
|> select([c], %{
|
|> select([c], %{
|
||||||
instance_domain: c.instance_domain,
|
instance_domain: c.instance_domain,
|
||||||
|
statuses_seen: sum(c.statuses_seen),
|
||||||
interactions_seen: sum(c.interactions_seen)
|
interactions_seen: sum(c.interactions_seen)
|
||||||
})
|
})
|
||||||
|> group_by([c], c.instance_domain)
|
|> group_by([c], c.instance_domain)
|
||||||
|
|
||||||
scores =
|
self_mentions_subquery =
|
||||||
CrawlInteraction
|
CrawlInteraction
|
||||||
|> join(:left, [ci], c in subquery(crawls_subquery),
|
|
||||||
on: ci.source_domain == c.instance_domain
|
|
||||||
)
|
|
||||||
|> where([ci], ci.source_domain == ci.target_domain)
|
|> where([ci], ci.source_domain == ci.target_domain)
|
||||||
|> group_by([ci], ci.source_domain)
|
|> select([ci], %{
|
||||||
|> select([ci, c], %{
|
|
||||||
domain: ci.source_domain,
|
domain: ci.source_domain,
|
||||||
mentions: sum(ci.mentions),
|
self_mentions: sum(ci.mentions)
|
||||||
# we can take min() because every row is the same
|
})
|
||||||
interactions: min(c.interactions_seen)
|
|> group_by([ci], ci.source_domain)
|
||||||
|
|
||||||
|
scores =
|
||||||
|
Instance
|
||||||
|
|> join(:inner, [i], c in subquery(crawls_subquery), on: i.domain == c.instance_domain)
|
||||||
|
|> join(:left, [i, c], ci in subquery(self_mentions_subquery), on: i.domain == ci.domain)
|
||||||
|
# don't generate insularity scores for instances where we haven't seen any activity
|
||||||
|
# (e.g. server types where the timeline isn't crawled)
|
||||||
|
|> where([i, c, ci], c.statuses_seen > 0)
|
||||||
|
|> select([i, c, ci], %{
|
||||||
|
domain: i.domain,
|
||||||
|
mentions: ci.self_mentions,
|
||||||
|
interactions: c.interactions_seen
|
||||||
})
|
})
|
||||||
|> Repo.all(timeout: :infinity)
|
|> Repo.all(timeout: :infinity)
|
||||||
|> Enum.map(fn %{domain: domain, mentions: mentions, interactions: interactions} ->
|
|> Enum.map(fn %{domain: domain, mentions: mentions, interactions: interactions} ->
|
||||||
|
insularity =
|
||||||
|
cond do
|
||||||
|
# if we haven't seen any self mentions, but there are interactions, it means that users on the instance
|
||||||
|
# only mentions others, i.e. insularity is 0
|
||||||
|
mentions == nil and interactions != 0 ->
|
||||||
|
0.0
|
||||||
|
|
||||||
|
interactions > 0 ->
|
||||||
|
mentions / interactions
|
||||||
|
|
||||||
|
true ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
%{
|
%{
|
||||||
domain: domain,
|
domain: domain,
|
||||||
insularity: mentions / interactions,
|
insularity: insularity,
|
||||||
inserted_at: now,
|
inserted_at: now,
|
||||||
updated_at: now
|
updated_at: now
|
||||||
}
|
}
|
||||||
|
@ -85,7 +105,6 @@ defmodule Backend.Scheduler do
|
||||||
@doc """
|
@doc """
|
||||||
This function calculates the average number of statuses per hour over the last month.
|
This function calculates the average number of statuses per hour over the last month.
|
||||||
"""
|
"""
|
||||||
@decorate transaction()
|
|
||||||
def generate_status_rate do
|
def generate_status_rate do
|
||||||
now = get_now()
|
now = get_now()
|
||||||
# We want the earliest sucessful crawl so that we can exclude it from the statistics.
|
# We want the earliest sucessful crawl so that we can exclude it from the statistics.
|
||||||
|
@ -100,23 +119,24 @@ defmodule Backend.Scheduler do
|
||||||
})
|
})
|
||||||
|
|
||||||
instances =
|
instances =
|
||||||
Crawl
|
Instance
|
||||||
|> join(:inner, [c], c2 in subquery(earliest_crawl_subquery),
|
|> join(:inner, [i], c in Crawl, on: i.domain == c.instance_domain)
|
||||||
on: c.instance_domain == c2.instance_domain
|
|> join(:inner, [i], c2 in subquery(earliest_crawl_subquery),
|
||||||
|
on: i.domain == c2.instance_domain
|
||||||
)
|
)
|
||||||
|> where(
|
|> where(
|
||||||
[c, c2],
|
[i, c, c2],
|
||||||
c.inserted_at > c2.earliest_crawl and not is_nil(c.statuses_seen)
|
c.inserted_at > c2.earliest_crawl and c.statuses_seen > 0
|
||||||
)
|
)
|
||||||
|> select([c], %{
|
|> select([i, c], %{
|
||||||
instance_domain: c.instance_domain,
|
domain: i.domain,
|
||||||
status_count: sum(c.statuses_seen),
|
status_count: sum(c.statuses_seen),
|
||||||
second_earliest_crawl: min(c.inserted_at)
|
second_earliest_crawl: min(c.inserted_at)
|
||||||
})
|
})
|
||||||
|> group_by([c], c.instance_domain)
|
|> group_by([i], i.domain)
|
||||||
|> Repo.all(timeout: :infinity)
|
|> Repo.all(timeout: :infinity)
|
||||||
|> Enum.map(fn %{
|
|> Enum.map(fn %{
|
||||||
instance_domain: domain,
|
domain: domain,
|
||||||
status_count: status_count,
|
status_count: status_count,
|
||||||
second_earliest_crawl: oldest_timestamp
|
second_earliest_crawl: oldest_timestamp
|
||||||
} ->
|
} ->
|
||||||
|
@ -143,9 +163,11 @@ defmodule Backend.Scheduler do
|
||||||
@doc """
|
@doc """
|
||||||
This function aggregates statistics from the interactions in the database.
|
This function aggregates statistics from the interactions in the database.
|
||||||
It calculates the strength of edges between nodes. Self-edges are not generated.
|
It calculates the strength of edges between nodes. Self-edges are not generated.
|
||||||
Edges are only generated if both instances have been succesfully crawled.
|
Edges are only generated if
|
||||||
|
* both instances have been succesfully crawled
|
||||||
|
* neither of the instances have blocked each other
|
||||||
|
* there are interactions in each direction (if :require_bidirectional_edges is true in config)
|
||||||
"""
|
"""
|
||||||
@decorate transaction()
|
|
||||||
def generate_edges do
|
def generate_edges do
|
||||||
now = get_now()
|
now = get_now()
|
||||||
|
|
||||||
|
@ -165,39 +187,51 @@ defmodule Backend.Scheduler do
|
||||||
|> join(:inner, [ci], c_target in subquery(crawls_subquery),
|
|> join(:inner, [ci], c_target in subquery(crawls_subquery),
|
||||||
on: ci.target_domain == c_target.instance_domain
|
on: ci.target_domain == c_target.instance_domain
|
||||||
)
|
)
|
||||||
|> where([ci], ci.source_domain != ci.target_domain)
|
|> join(:inner, [ci], i_source in Instance, on: ci.source_domain == i_source.domain)
|
||||||
|> group_by([ci], [ci.source_domain, ci.target_domain])
|
|> join(:inner, [ci], i_target in Instance, on: ci.target_domain == i_target.domain)
|
||||||
|> select([ci, c_source, c_target], %{
|
|> select([ci, c_source, c_target, i_source, i_target], %{
|
||||||
source_domain: ci.source_domain,
|
source_domain: ci.source_domain,
|
||||||
target_domain: ci.target_domain,
|
target_domain: ci.target_domain,
|
||||||
mentions: sum(ci.mentions),
|
mentions: sum(ci.mentions),
|
||||||
# we can take min() because every row is the same
|
# we can take min() because every row is the same
|
||||||
|
source_type: min(i_source.type),
|
||||||
|
target_type: min(i_target.type),
|
||||||
source_statuses_seen: min(c_source.statuses_seen),
|
source_statuses_seen: min(c_source.statuses_seen),
|
||||||
target_statuses_seen: min(c_target.statuses_seen)
|
target_statuses_seen: min(c_target.statuses_seen)
|
||||||
})
|
})
|
||||||
|
|> where([ci], ci.source_domain != ci.target_domain)
|
||||||
|
|> group_by([ci], [ci.source_domain, ci.target_domain])
|
||||||
|> Repo.all(timeout: :infinity)
|
|> Repo.all(timeout: :infinity)
|
||||||
|
|
||||||
|
federation_blocks =
|
||||||
|
FederationRestriction
|
||||||
|
|> select([fr], {fr.source_domain, fr.target_domain})
|
||||||
|
|> where([fr], fr.type == "reject")
|
||||||
|
|> Repo.all()
|
||||||
|
|> MapSet.new()
|
||||||
|
|
||||||
|
new_edges =
|
||||||
|
interactions
|
||||||
|
|> filter_to_eligible_interactions(federation_blocks)
|
||||||
|
|> combine_mention_directions()
|
||||||
|
|> Enum.map(fn {{source_domain, target_domain}, {mention_count, statuses_seen}} ->
|
||||||
|
%{
|
||||||
|
source_domain: source_domain,
|
||||||
|
target_domain: target_domain,
|
||||||
|
weight: mention_count / statuses_seen,
|
||||||
|
inserted_at: now,
|
||||||
|
updated_at: now
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
# Get edges and their weights
|
# Get edges and their weights
|
||||||
Repo.transaction(
|
Repo.transaction(
|
||||||
fn ->
|
fn ->
|
||||||
Edge
|
Edge
|
||||||
|> Repo.delete_all(timeout: :infinity)
|
|> Repo.delete_all(timeout: :infinity)
|
||||||
|
|
||||||
edges =
|
|
||||||
interactions
|
|
||||||
|> reduce_mention_count()
|
|
||||||
|> Enum.map(fn {{source_domain, target_domain}, {mention_count, statuses_seen}} ->
|
|
||||||
%{
|
|
||||||
source_domain: source_domain,
|
|
||||||
target_domain: target_domain,
|
|
||||||
weight: mention_count / statuses_seen,
|
|
||||||
inserted_at: now,
|
|
||||||
updated_at: now
|
|
||||||
}
|
|
||||||
end)
|
|
||||||
|
|
||||||
Edge
|
Edge
|
||||||
|> Repo.insert_all(edges, timeout: :infinity)
|
|> Repo.insert_all(new_edges, timeout: :infinity)
|
||||||
end,
|
end,
|
||||||
timeout: :infinity
|
timeout: :infinity
|
||||||
)
|
)
|
||||||
|
@ -207,7 +241,6 @@ defmodule Backend.Scheduler do
|
||||||
This function checks to see if a lot of instances on the same base domain have been created recently. If so,
|
This function checks to see if a lot of instances on the same base domain have been created recently. If so,
|
||||||
notifies the server admin over SMS.
|
notifies the server admin over SMS.
|
||||||
"""
|
"""
|
||||||
@decorate transaction()
|
|
||||||
def check_for_spam_instances do
|
def check_for_spam_instances do
|
||||||
hour_range = 3
|
hour_range = 3
|
||||||
|
|
||||||
|
@ -247,7 +280,6 @@ defmodule Backend.Scheduler do
|
||||||
end).()
|
end).()
|
||||||
|
|
||||||
Logger.info(message)
|
Logger.info(message)
|
||||||
send_admin_sms(message)
|
|
||||||
AdminEmail.send("Potential spam", message)
|
AdminEmail.send("Potential spam", message)
|
||||||
else
|
else
|
||||||
Logger.debug("Did not find potential spam instances.")
|
Logger.debug("Did not find potential spam instances.")
|
||||||
|
@ -256,8 +288,7 @@ defmodule Backend.Scheduler do
|
||||||
|
|
||||||
# Takes a list of Interactions
|
# Takes a list of Interactions
|
||||||
# Returns a map of %{{source, target} => {total_mention_count, total_statuses_seen}}
|
# Returns a map of %{{source, target} => {total_mention_count, total_statuses_seen}}
|
||||||
@decorate transaction_event()
|
defp combine_mention_directions(interactions) do
|
||||||
defp reduce_mention_count(interactions) do
|
|
||||||
Enum.reduce(interactions, %{}, fn
|
Enum.reduce(interactions, %{}, fn
|
||||||
%{
|
%{
|
||||||
source_domain: source_domain,
|
source_domain: source_domain,
|
||||||
|
@ -283,4 +314,56 @@ defmodule Backend.Scheduler do
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp filter_to_eligible_interactions(interactions, federation_blocks) do
|
||||||
|
# A map of {source_domain, target_domain} => mention_count. Used to find out whether a mention in the reverse
|
||||||
|
# direction has been seen.
|
||||||
|
mention_directions =
|
||||||
|
interactions
|
||||||
|
|> Enum.reduce(%{}, fn %{source_domain: source, target_domain: target, mentions: mentions},
|
||||||
|
acc ->
|
||||||
|
Map.put(acc, {source, target}, mentions)
|
||||||
|
end)
|
||||||
|
|
||||||
|
interactions
|
||||||
|
|> Enum.filter(&is_eligible_interaction?(&1, mention_directions, federation_blocks))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns true if
|
||||||
|
# * there's no federation block in either direction between the two instances
|
||||||
|
# * there are mentions in both directions (if enabled in configuration)
|
||||||
|
defp is_eligible_interaction?(
|
||||||
|
%{
|
||||||
|
source_domain: source,
|
||||||
|
target_domain: target,
|
||||||
|
mentions: mention_count,
|
||||||
|
source_type: source_type,
|
||||||
|
target_type: target_type
|
||||||
|
},
|
||||||
|
mention_directions,
|
||||||
|
federation_blocks
|
||||||
|
) do
|
||||||
|
mentions_were_seen = mention_count > 0
|
||||||
|
|
||||||
|
# If :require_bidirectional_edges is set to `true` in the config, then an edge is only created if both instances
|
||||||
|
# have mentioned each other
|
||||||
|
opposite_mention_exists =
|
||||||
|
if get_config(:require_bidirectional_mentions) and is_timeline_crawlable_type?(source_type) and
|
||||||
|
is_timeline_crawlable_type?(target_type) do
|
||||||
|
Map.has_key?(mention_directions, {target, source}) and
|
||||||
|
Map.get(mention_directions, {target, source}) > 0
|
||||||
|
else
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
federation_block_exists =
|
||||||
|
MapSet.member?(federation_blocks, {source, target}) or
|
||||||
|
MapSet.member?(federation_blocks, {target, source})
|
||||||
|
|
||||||
|
mentions_were_seen and opposite_mention_exists and not federation_block_exists
|
||||||
|
end
|
||||||
|
|
||||||
|
defp is_timeline_crawlable_type?(type) do
|
||||||
|
Enum.member?(["mastodon", "gab", "pleroma", "gnusocial", "misskey"], type)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -113,21 +113,7 @@ defmodule Backend.Util do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@spec clean_domain(String.t()) :: String.t()
|
||||||
Sends an SMS to the admin phone number if configured.
|
|
||||||
"""
|
|
||||||
def send_admin_sms(body) do
|
|
||||||
if get_config(:admin_phone) != nil and get_config(:twilio_phone) != nil do
|
|
||||||
ExTwilio.Message.create(
|
|
||||||
to: get_config(:admin_phone),
|
|
||||||
from: get_config(:twilio_phone),
|
|
||||||
body: body
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Logger.info("Could not send SMS to admin; not configured.")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def clean_domain(domain) do
|
def clean_domain(domain) do
|
||||||
cleaned =
|
cleaned =
|
||||||
domain
|
domain
|
||||||
|
@ -136,7 +122,7 @@ defmodule Backend.Util do
|
||||||
|> String.trim()
|
|> String.trim()
|
||||||
|> String.downcase()
|
|> String.downcase()
|
||||||
|
|
||||||
Regex.replace(~r/:\d+/, cleaned, "")
|
Regex.replace(~r/(:\d+|\.)$/, cleaned, "")
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_account(username, domain) do
|
def get_account(username, domain) do
|
||||||
|
@ -160,16 +146,18 @@ defmodule Backend.Util do
|
||||||
@doc """
|
@doc """
|
||||||
Gets and decodes a HTTP response.
|
Gets and decodes a HTTP response.
|
||||||
"""
|
"""
|
||||||
@spec get_and_decode(String.t()) ::
|
@spec get_and_decode(String.t(), Atom.t(), Integer.t()) ::
|
||||||
{:ok, any()} | {:error, Jason.DecodeError.t() | HTTPoison.Error.t()}
|
{:ok, any()} | {:error, Jason.DecodeError.t() | HTTPoison.Error.t()}
|
||||||
def get_and_decode(url) do
|
def get_and_decode(url, pool \\ :crawler, timeout \\ 15_000) do
|
||||||
case HTTPoison.get(url, [{"User-Agent", get_config(:user_agent)}],
|
case HTTPoison.get(url, [{"User-Agent", get_config(:user_agent)}],
|
||||||
hackney: [pool: :crawler],
|
hackney: [pool: pool],
|
||||||
recv_timeout: 15_000,
|
recv_timeout: timeout,
|
||||||
timeout: 15_000
|
timeout: timeout
|
||||||
) do
|
) do
|
||||||
{:ok, %{status_code: 200, body: body}} -> Jason.decode(body)
|
{:ok, %{status_code: 200, body: body}} -> Jason.decode(body)
|
||||||
{:ok, _} -> {:error, %HTTPoison.Error{reason: "Non-200 response"}}
|
{:ok, %{status_code: 401}} -> Jason.decode("[]")
|
||||||
|
{:ok, %{status_code: 404}} -> Jason.decode("[]")
|
||||||
|
{:ok, %{body: body}} -> {:error, %HTTPoison.Error{reason: "Non-200 response. Body: #{body}"}}
|
||||||
{:error, err} -> {:error, err}
|
{:error, err} -> {:error, err}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -209,6 +197,6 @@ defmodule Backend.Util do
|
||||||
|
|
||||||
@spec is_valid_domain?(String.t()) :: boolean
|
@spec is_valid_domain?(String.t()) :: boolean
|
||||||
def is_valid_domain?(domain) do
|
def is_valid_domain?(domain) do
|
||||||
Regex.match?(~r/^[\w\.\-_]+$/, domain)
|
Regex.match?(~r/^[\pL\d\.\-_]+\.[a-zA-Z]+$/, domain)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,7 +8,7 @@ defmodule BackendWeb.AdminController do
|
||||||
[token] = get_req_header(conn, "token")
|
[token] = get_req_header(conn, "token")
|
||||||
|
|
||||||
with {:ok, domain} <- Auth.verify_token(token) do
|
with {:ok, domain} <- Auth.verify_token(token) do
|
||||||
instance = Api.get_instance!(domain)
|
instance = Api.get_instance(domain)
|
||||||
render(conn, "show.json", instance: instance)
|
render(conn, "show.json", instance: instance)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -21,7 +21,7 @@ defmodule BackendWeb.AdminController do
|
||||||
|
|
||||||
# Make sure to update ElasticSearch so that the instance is no longer returned in search results
|
# Make sure to update ElasticSearch so that the instance is no longer returned in search results
|
||||||
es_instance =
|
es_instance =
|
||||||
Api.get_instance_with_peers(domain)
|
Api.get_instance(domain)
|
||||||
|> Map.put(:opt_in, opt_in)
|
|> Map.put(:opt_in, opt_in)
|
||||||
|> Map.put(:opt_out, opt_out)
|
|> Map.put(:opt_out, opt_out)
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
defmodule BackendWeb.AdminLoginController do
|
defmodule BackendWeb.AdminLoginController do
|
||||||
use BackendWeb, :controller
|
use BackendWeb, :controller
|
||||||
import Backend.Util
|
import Backend.Util
|
||||||
|
alias Backend.Api
|
||||||
alias Backend.Mailer.UserEmail
|
alias Backend.Mailer.UserEmail
|
||||||
|
alias Mastodon.Messenger
|
||||||
|
|
||||||
action_fallback BackendWeb.FallbackController
|
action_fallback BackendWeb.FallbackController
|
||||||
|
|
||||||
|
@ -11,20 +13,43 @@ defmodule BackendWeb.AdminLoginController do
|
||||||
"""
|
"""
|
||||||
def show(conn, %{"id" => domain}) do
|
def show(conn, %{"id" => domain}) do
|
||||||
cleaned_domain = clean_domain(domain)
|
cleaned_domain = clean_domain(domain)
|
||||||
|
instance = Api.get_instance(domain)
|
||||||
|
|
||||||
instance_data = get_and_decode!("https://#{cleaned_domain}/api/v1/instance")
|
keyword_args =
|
||||||
|
cond do
|
||||||
|
instance == nil or instance.type == nil ->
|
||||||
|
[error: "We have not seen this instance before. Please check for typos."]
|
||||||
|
|
||||||
render(conn, "show.json", instance_data: instance_data, cleaned_domain: cleaned_domain)
|
not Enum.member?(["mastodon", "pleroma", "gab"], instance.type) ->
|
||||||
|
[error: "It is only possible to administer Mastodon and Pleroma instances."]
|
||||||
|
|
||||||
|
true ->
|
||||||
|
case get_and_decode("https://#{cleaned_domain}/api/v1/instance") do
|
||||||
|
{:ok, instance_data} ->
|
||||||
|
[instance_data: instance_data, cleaned_domain: cleaned_domain]
|
||||||
|
|
||||||
|
{:error, _err} ->
|
||||||
|
[error: "Unable to get instance details. Is it currently live?"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render(conn, "show.json", keyword_args)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create(conn, %{"domain" => domain, "type" => type}) do
|
def create(conn, %{"domain" => domain, "type" => type}) do
|
||||||
cleaned_domain = clean_domain(domain)
|
cleaned_domain = clean_domain(domain)
|
||||||
|
|
||||||
instance_data = get_and_decode!("https://#{cleaned_domain}/api/v1/instance")
|
{data_state, instance_data} =
|
||||||
|
get_and_decode("https://#{cleaned_domain}/api/v1/instance",
|
||||||
|
pool: :admin_login,
|
||||||
|
timeout: 20_000
|
||||||
|
)
|
||||||
|
|
||||||
# credo:disable-for-lines:16 Credo.Check.Refactor.CondStatements
|
|
||||||
error =
|
error =
|
||||||
cond do
|
cond do
|
||||||
|
data_state == :error ->
|
||||||
|
"Unable to get instance details. Is it currently live?"
|
||||||
|
|
||||||
type == "email" ->
|
type == "email" ->
|
||||||
email = Map.get(instance_data, "email")
|
email = Map.get(instance_data, "email")
|
||||||
|
|
||||||
|
@ -33,8 +58,10 @@ defmodule BackendWeb.AdminLoginController do
|
||||||
{:error, _} -> "Failed to send email."
|
{:error, _} -> "Failed to send email."
|
||||||
end
|
end
|
||||||
|
|
||||||
# type == "fediverseAccount" ->
|
type == "fediverseAccount" ->
|
||||||
# account = nil
|
username = get_in(instance_data, ["contact_account", "username"])
|
||||||
|
_status = Messenger.dm_login_link(username, cleaned_domain)
|
||||||
|
nil
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
"Invalid account type. Must be 'email' or 'fediverseAccount'."
|
"Invalid account type. Must be 'email' or 'fediverseAccount'."
|
||||||
|
|
|
@ -1,17 +1,95 @@
|
||||||
defmodule BackendWeb.InstanceController do
|
defmodule BackendWeb.InstanceController do
|
||||||
use BackendWeb, :controller
|
use BackendWeb, :controller
|
||||||
|
alias Backend.{Api, Instance, Repo}
|
||||||
alias Graph.Cache
|
alias Graph.Cache
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
action_fallback(BackendWeb.FallbackController)
|
action_fallback(BackendWeb.FallbackController)
|
||||||
|
|
||||||
|
# sobelow_skip ["DOS.StringToAtom"]
|
||||||
|
def index(conn, params) do
|
||||||
|
page = Map.get(params, "page")
|
||||||
|
sort_field = Map.get(params, "sortField")
|
||||||
|
sort_direction = Map.get(params, "sortDirection")
|
||||||
|
|
||||||
|
cond do
|
||||||
|
not Enum.member?([nil, "domain", "userCount", "statusCount", "insularity"], sort_field) ->
|
||||||
|
render(conn, "error.json", error: "Invalid sort field")
|
||||||
|
|
||||||
|
not Enum.member?([nil, "asc", "desc"], sort_direction) ->
|
||||||
|
render(conn, "error.json", error: "Invalid sort direction")
|
||||||
|
|
||||||
|
true ->
|
||||||
|
sort_field =
|
||||||
|
if sort_field != nil do
|
||||||
|
sort_field
|
||||||
|
|> Recase.to_snake()
|
||||||
|
|> String.to_atom()
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
sort_direction =
|
||||||
|
if sort_direction != nil do
|
||||||
|
sort_direction
|
||||||
|
|> Recase.to_snake()
|
||||||
|
|> String.to_atom()
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
%{
|
||||||
|
entries: instances,
|
||||||
|
total_pages: total_pages,
|
||||||
|
page_number: page_number,
|
||||||
|
total_entries: total_entries,
|
||||||
|
page_size: page_size
|
||||||
|
} = Api.get_instances(page, sort_field, sort_direction)
|
||||||
|
|
||||||
|
render(conn, "index.json",
|
||||||
|
instances: instances,
|
||||||
|
total_pages: total_pages,
|
||||||
|
page_number: page_number,
|
||||||
|
total_entries: total_entries,
|
||||||
|
page_size: page_size
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def show(conn, %{"id" => domain}) do
|
def show(conn, %{"id" => domain}) do
|
||||||
instance = Cache.get_instance_with_peers(domain)
|
instance = Cache.get_instance_with_relationships(domain)
|
||||||
|
|
||||||
if instance == nil or instance.opt_out == true do
|
if instance == nil or instance.opt_out == true do
|
||||||
send_resp(conn, 404, "Not found")
|
send_resp(conn, 404, "Not found")
|
||||||
else
|
else
|
||||||
last_crawl = Cache.get_last_crawl(domain)
|
last_crawl = Cache.get_last_crawl(domain)
|
||||||
render(conn, "show.json", instance: instance, crawl: last_crawl)
|
|
||||||
|
restricted_domains =
|
||||||
|
instance.federation_restrictions
|
||||||
|
|> Enum.map(fn %{target_domain: domain} -> domain end)
|
||||||
|
|
||||||
|
opted_out_instances =
|
||||||
|
Instance
|
||||||
|
|> select([i], i.domain)
|
||||||
|
|> where([i], i.opt_out and i.domain in ^restricted_domains)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
# convert from a list of {domain, restriction_type} to a map of %{restriction_type => list_of_domains}
|
||||||
|
federation_restrictions =
|
||||||
|
instance.federation_restrictions
|
||||||
|
|> Enum.filter(fn %{target_domain: domain} ->
|
||||||
|
not Enum.member?(opted_out_instances, domain)
|
||||||
|
end)
|
||||||
|
|> Enum.reduce(%{}, fn %{target_domain: domain, type: type}, acc ->
|
||||||
|
Map.update(acc, type, [domain], fn curr_domains -> [domain | curr_domains] end)
|
||||||
|
end)
|
||||||
|
|> Recase.Enumerable.convert_keys(&Recase.to_camel(&1))
|
||||||
|
|
||||||
|
render(conn, "show.json",
|
||||||
|
instance: instance,
|
||||||
|
crawl: last_crawl,
|
||||||
|
federation_restrictions: federation_restrictions
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -46,7 +46,7 @@ defmodule BackendWeb.Endpoint do
|
||||||
)
|
)
|
||||||
|
|
||||||
plug(Corsica,
|
plug(Corsica,
|
||||||
origins: ["http://localhost:3000", ~r{^https?://(.*\.?)fediverse\.space$}],
|
origins: ["http://localhost:3001", ~r{^https://(.*\.?)index\.community$}, ~r{^https://(.*\.?)fediverse\.space$}],
|
||||||
allow_headers: ["content-type", "token"]
|
allow_headers: ["content-type", "token"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
defmodule BackendWeb.RateLimiter do
|
||||||
|
@moduledoc """
|
||||||
|
Functions used to rate limit:
|
||||||
|
* all endpoints by IP/endpoint
|
||||||
|
* authentication endpoints by domain
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Phoenix.Controller, only: [json: 2]
|
||||||
|
import Plug.Conn, only: [put_status: 2]
|
||||||
|
use Plug.Builder
|
||||||
|
|
||||||
|
def rate_limit(conn, options \\ []) do
|
||||||
|
case check_rate(conn, options) do
|
||||||
|
{:ok, _count} -> conn # Do nothing, allow execution to continue
|
||||||
|
{:error, _count} -> render_error(conn)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def rate_limit_authentication(conn, options \\ []) do
|
||||||
|
domain =
|
||||||
|
if Map.has_key?(conn.params, "id") do
|
||||||
|
Map.get(conn.params, "id")
|
||||||
|
else
|
||||||
|
Map.get(conn.params, "domain")
|
||||||
|
end
|
||||||
|
options = Keyword.put(options, :bucket_name, "authorization: #{domain}")
|
||||||
|
rate_limit(conn, options)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_rate(conn, options) do
|
||||||
|
interval_milliseconds = options[:interval_seconds] * 1000
|
||||||
|
max_requests = options[:max_requests]
|
||||||
|
bucket_name = options[:bucket_name] || bucket_name(conn)
|
||||||
|
|
||||||
|
ExRated.check_rate(bucket_name, interval_milliseconds, max_requests)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Bucket name should be a combination of ip address and request path, like so:
|
||||||
|
#
|
||||||
|
# "127.0.0.1:/api/v1/authorizations"
|
||||||
|
defp bucket_name(conn) do
|
||||||
|
path = Enum.join(conn.path_info, "/")
|
||||||
|
ip = conn.remote_ip |> Tuple.to_list |> Enum.join(".")
|
||||||
|
"#{ip}:#{path}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_error(conn) do
|
||||||
|
conn
|
||||||
|
|> put_status(:forbidden)
|
||||||
|
|> json(%{error: "Rate limit exceeded."})
|
||||||
|
|> halt # Stop execution of further plugs, return response now
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,19 +1,29 @@
|
||||||
defmodule BackendWeb.Router do
|
defmodule BackendWeb.Router do
|
||||||
use BackendWeb, :router
|
use BackendWeb, :router
|
||||||
|
import BackendWeb.RateLimiter
|
||||||
|
|
||||||
pipeline :api do
|
pipeline :api do
|
||||||
plug(:accepts, ["json"])
|
plug(:accepts, ["json"])
|
||||||
|
plug(:rate_limit, max_requests: 5, interval_seconds: 10) # requests to the same endpoint
|
||||||
|
end
|
||||||
|
|
||||||
|
pipeline :api_admin do
|
||||||
|
plug(:rate_limit_authentication, max_requests: 5, interval_seconds: 60)
|
||||||
end
|
end
|
||||||
|
|
||||||
scope "/api", BackendWeb do
|
scope "/api", BackendWeb do
|
||||||
pipe_through(:api)
|
pipe_through(:api)
|
||||||
|
|
||||||
resources("/instances", InstanceController, only: [:show])
|
resources("/instances", InstanceController, only: [:index, :show])
|
||||||
resources("/graph", GraphController, only: [:index, :show])
|
resources("/graph", GraphController, only: [:index, :show])
|
||||||
resources("/search", SearchController, only: [:index])
|
resources("/search", SearchController, only: [:index])
|
||||||
|
|
||||||
resources("/admin/login", AdminLoginController, only: [:show, :create])
|
scope "/admin" do
|
||||||
get "/admin", AdminController, :show
|
pipe_through :api_admin
|
||||||
post "/admin", AdminController, :update
|
|
||||||
|
resources("/login", AdminLoginController, only: [:show, :create])
|
||||||
|
get "/", AdminController, :show
|
||||||
|
post "/", AdminController, :update
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,9 +2,17 @@ defmodule BackendWeb.AdminLoginView do
|
||||||
use BackendWeb, :view
|
use BackendWeb, :view
|
||||||
import Backend.Util
|
import Backend.Util
|
||||||
|
|
||||||
def render("show.json", %{instance_data: instance_data, cleaned_domain: cleaned_domain}) do
|
def render("show.json", %{error: error}) do
|
||||||
username = get_in(instance_data, ["contact_account", "username"])
|
%{
|
||||||
|
error: error
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render("show.json", %{
|
||||||
|
instance_data: instance_data,
|
||||||
|
cleaned_domain: cleaned_domain
|
||||||
|
}) do
|
||||||
|
username = get_in(instance_data, ["contact_account", "username"])
|
||||||
fedi_account = get_account(username, cleaned_domain)
|
fedi_account = get_account(username, cleaned_domain)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
|
|
|
@ -3,7 +3,45 @@ defmodule BackendWeb.InstanceView do
|
||||||
alias BackendWeb.InstanceView
|
alias BackendWeb.InstanceView
|
||||||
import Backend.Util
|
import Backend.Util
|
||||||
|
|
||||||
def render("show.json", %{instance: instance, crawl: crawl}) do
|
def render("index.json", %{
|
||||||
|
instances: instances,
|
||||||
|
total_pages: total_pages,
|
||||||
|
page_number: page_number,
|
||||||
|
total_entries: total_entries,
|
||||||
|
page_size: page_size
|
||||||
|
}) do
|
||||||
|
%{
|
||||||
|
instances: render_many(instances, InstanceView, "index_instance.json"),
|
||||||
|
pageNumber: page_number,
|
||||||
|
totalPages: total_pages,
|
||||||
|
totalEntries: total_entries,
|
||||||
|
pageSize: page_size
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Used when rendering the index of all instances (the difference from show.json is primarily that
|
||||||
|
it does not include peers).
|
||||||
|
"""
|
||||||
|
def render("index_instance.json", %{instance: instance}) do
|
||||||
|
%{
|
||||||
|
name: instance.domain,
|
||||||
|
description: instance.description,
|
||||||
|
version: instance.version,
|
||||||
|
userCount: instance.user_count,
|
||||||
|
insularity: instance.insularity,
|
||||||
|
statusCount: instance.status_count,
|
||||||
|
type: instance.type,
|
||||||
|
statusesPerDay: instance.statuses_per_day,
|
||||||
|
statusesPerUserPerDay: get_statuses_per_user_per_day(instance)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render("show.json", %{
|
||||||
|
instance: instance,
|
||||||
|
crawl: crawl,
|
||||||
|
federation_restrictions: federation_restrictions
|
||||||
|
}) do
|
||||||
user_threshold = get_config(:personal_instance_threshold)
|
user_threshold = get_config(:personal_instance_threshold)
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
|
@ -16,15 +54,22 @@ defmodule BackendWeb.InstanceView do
|
||||||
instance.crawl_error != nil and instance.type == nil ->
|
instance.crawl_error != nil and instance.type == nil ->
|
||||||
render_domain_and_error(instance)
|
render_domain_and_error(instance)
|
||||||
|
|
||||||
|
crawl == nil ->
|
||||||
|
render_domain_and_error(instance)
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
render_instance(instance, crawl)
|
render_instance(instance, crawl, federation_restrictions)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def render("instance.json", %{instance: instance}) do
|
def render("peer.json", %{instance: instance}) do
|
||||||
%{name: instance.domain}
|
%{name: instance.domain}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def render("error.json", %{error: error}) do
|
||||||
|
%{error: error}
|
||||||
|
end
|
||||||
|
|
||||||
defp render_personal_instance(instance) do
|
defp render_personal_instance(instance) do
|
||||||
%{
|
%{
|
||||||
name: instance.domain,
|
name: instance.domain,
|
||||||
|
@ -39,21 +84,13 @@ defmodule BackendWeb.InstanceView do
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp render_instance(instance, crawl) do
|
defp render_instance(instance, crawl, federation_restrictions) do
|
||||||
last_updated = max_datetime(crawl.inserted_at, instance.updated_at)
|
last_updated = max_datetime(crawl.inserted_at, instance.updated_at)
|
||||||
|
|
||||||
filtered_peers =
|
filtered_peers =
|
||||||
instance.peers
|
instance.peers
|
||||||
|> Enum.filter(fn peer -> not peer.opt_out end)
|
|> Enum.filter(fn peer -> not peer.opt_out end)
|
||||||
|
|
||||||
statuses_per_user_per_day =
|
|
||||||
if instance.statuses_per_day != nil and instance.user_count != nil and
|
|
||||||
instance.user_count > 0 do
|
|
||||||
instance.statuses_per_day / instance.user_count
|
|
||||||
else
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
%{
|
%{
|
||||||
name: instance.domain,
|
name: instance.domain,
|
||||||
description: instance.description,
|
description: instance.description,
|
||||||
|
@ -62,12 +99,22 @@ defmodule BackendWeb.InstanceView do
|
||||||
insularity: instance.insularity,
|
insularity: instance.insularity,
|
||||||
statusCount: instance.status_count,
|
statusCount: instance.status_count,
|
||||||
domainCount: length(instance.peers),
|
domainCount: length(instance.peers),
|
||||||
peers: render_many(filtered_peers, InstanceView, "instance.json"),
|
peers: render_many(filtered_peers, InstanceView, "peer.json"),
|
||||||
|
federationRestrictions: federation_restrictions,
|
||||||
lastUpdated: last_updated,
|
lastUpdated: last_updated,
|
||||||
status: "success",
|
status: "success",
|
||||||
type: instance.type,
|
type: instance.type,
|
||||||
statusesPerDay: instance.statuses_per_day,
|
statusesPerDay: instance.statuses_per_day,
|
||||||
statusesPerUserPerDay: statuses_per_user_per_day
|
statusesPerUserPerDay: get_statuses_per_user_per_day(instance)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp get_statuses_per_user_per_day(instance) do
|
||||||
|
if instance.statuses_per_day != nil and instance.user_count != nil and
|
||||||
|
instance.user_count > 0 do
|
||||||
|
instance.statuses_per_day / instance.user_count
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -38,17 +38,17 @@ defmodule Graph.Cache do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec get_instance_with_peers(String.t()) :: Instance.t()
|
@spec get_instance_with_relationships(String.t()) :: Instance.t()
|
||||||
def get_instance_with_peers(domain) do
|
def get_instance_with_relationships(domain) do
|
||||||
key = "instance_" <> domain
|
key = "instance_" <> domain
|
||||||
|
|
||||||
case Cache.get(key) do
|
case Cache.get(key) do
|
||||||
nil ->
|
nil ->
|
||||||
Appsignal.increment_counter("instance_cache.misses", 1)
|
Appsignal.increment_counter("instance_cache.misses", 1)
|
||||||
Logger.debug("Instance cache: miss")
|
Logger.debug("Instance cache: miss")
|
||||||
instance = Api.get_instance_with_peers(domain)
|
instance = Api.get_instance_with_relationships(domain)
|
||||||
# Cache for one minute
|
# Cache for five minutes
|
||||||
Cache.set(key, instance, ttl: 60)
|
Cache.set(key, instance, ttl: 300)
|
||||||
instance
|
instance
|
||||||
|
|
||||||
data ->
|
data ->
|
||||||
|
@ -81,8 +81,8 @@ defmodule Graph.Cache do
|
||||||
)
|
)
|
||||||
|> Repo.one()
|
|> Repo.one()
|
||||||
|
|
||||||
# Cache for one minute
|
# Cache for five minutes
|
||||||
Cache.set(key, crawl, ttl: 60)
|
Cache.set(key, crawl, ttl: 300)
|
||||||
|
|
||||||
data ->
|
data ->
|
||||||
Appsignal.increment_counter("most_recent_crawl_cache.hits", 1)
|
Appsignal.increment_counter("most_recent_crawl_cache.hits", 1)
|
||||||
|
|
|
@ -12,7 +12,7 @@ defmodule Backend.Mailer.AdminEmail do
|
||||||
if admin_email != nil do
|
if admin_email != nil do
|
||||||
new()
|
new()
|
||||||
|> to(admin_email)
|
|> to(admin_email)
|
||||||
|> from("noreply@fediverse.space")
|
|> from("noreply@index.community")
|
||||||
|> subject(subject)
|
|> subject(subject)
|
||||||
|> text_body(body)
|
|> text_body(body)
|
||||||
|> Backend.Mailer.deliver!()
|
|> Backend.Mailer.deliver!()
|
||||||
|
|
|
@ -16,8 +16,8 @@ defmodule Backend.Mailer.UserEmail do
|
||||||
|
|
||||||
new()
|
new()
|
||||||
|> to(address)
|
|> to(address)
|
||||||
|> from("noreply@fediverse.space")
|
|> from("noreply@index.community")
|
||||||
|> subject("Login to fediverse.space")
|
|> subject("Login to index.community")
|
||||||
|> text_body(body)
|
|> text_body(body)
|
||||||
|> Backend.Mailer.deliver()
|
|> Backend.Mailer.deliver()
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
defmodule Mastodon.Messenger do
|
||||||
|
@moduledoc """
|
||||||
|
Module for interacting with a Mastodon account. In our case, it's only used to DM login links.
|
||||||
|
"""
|
||||||
|
import Backend.{Auth, Util}
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
def dm_login_link(username, user_domain) do
|
||||||
|
mastodon_domain = Application.get_env(:backend, __MODULE__)[:domain]
|
||||||
|
token = Application.get_env(:backend, __MODULE__)[:token]
|
||||||
|
frontend_domain = get_config(:frontend_domain)
|
||||||
|
|
||||||
|
conn = Hunter.new(base_url: "https://#{mastodon_domain}", bearer_token: token)
|
||||||
|
Logger.info(inspect(conn))
|
||||||
|
|
||||||
|
status_text =
|
||||||
|
"@#{username}@#{user_domain} " <>
|
||||||
|
"Someone tried to log in to #{user_domain} on https://#{frontend_domain}.\n" <>
|
||||||
|
"If it was you, click here to confirm:\n" <>
|
||||||
|
"#{get_login_link(user_domain)} " <>
|
||||||
|
"This link will be valid for 12 hours."
|
||||||
|
|
||||||
|
Hunter.Status.create_status(conn, status_text, visibility: :direct)
|
||||||
|
end
|
||||||
|
end
|
|
@ -4,7 +4,7 @@ defmodule Backend.MixProject do
|
||||||
def project do
|
def project do
|
||||||
[
|
[
|
||||||
app: :backend,
|
app: :backend,
|
||||||
version: "2.7.1",
|
version: "2.8.2",
|
||||||
elixir: "~> 1.5",
|
elixir: "~> 1.5",
|
||||||
elixirc_paths: elixirc_paths(Mix.env()),
|
elixirc_paths: elixirc_paths(Mix.env()),
|
||||||
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
|
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
|
||||||
|
@ -23,11 +23,11 @@ defmodule Backend.MixProject do
|
||||||
extra_applications: [
|
extra_applications: [
|
||||||
:logger,
|
:logger,
|
||||||
:runtime_tools,
|
:runtime_tools,
|
||||||
:mnesia,
|
|
||||||
:gollum,
|
:gollum,
|
||||||
:ex_twilio,
|
|
||||||
:elasticsearch,
|
:elasticsearch,
|
||||||
:appsignal
|
:appsignal,
|
||||||
|
:swoosh,
|
||||||
|
:gen_smtp
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
@ -41,30 +41,33 @@ defmodule Backend.MixProject do
|
||||||
# Type `mix help deps` for examples and options.
|
# Type `mix help deps` for examples and options.
|
||||||
defp deps do
|
defp deps do
|
||||||
[
|
[
|
||||||
{:phoenix, "~> 1.4.3"},
|
{:phoenix, "~> 1.5"},
|
||||||
{:phoenix_pubsub, "~> 1.1"},
|
{:phoenix_pubsub, "~> 2.0"},
|
||||||
{:phoenix_ecto, "~> 4.0"},
|
{:phoenix_ecto, "~> 4.0"},
|
||||||
{:ecto_sql, "~> 3.0"},
|
{:ecto_sql, "~> 3.0"},
|
||||||
{:postgrex, ">= 0.0.0"},
|
{:postgrex, ">= 0.0.0"},
|
||||||
{:gettext, "~> 0.11"},
|
{:gettext, "~> 0.11"},
|
||||||
{:jason, "~> 1.0"},
|
{:jason, "~> 1.0"},
|
||||||
{:plug_cowboy, "~> 2.0"},
|
{:plug_cowboy, "~> 2.1"},
|
||||||
{:httpoison, "~> 1.5"},
|
{:httpoison, "~> 1.7", override: true},
|
||||||
{:timex, "~> 3.5"},
|
{:timex, "~> 3.5"},
|
||||||
{:honeydew, "~> 1.4.3"},
|
{:honeydew, "~> 1.5.0"},
|
||||||
{:quantum, "~> 2.3"},
|
{:quantum, "~> 3.3"},
|
||||||
{:corsica, "~> 1.1.2"},
|
{:corsica, "~> 1.1.2"},
|
||||||
{:sobelow, "~> 0.8", only: [:dev, :test]},
|
{:sobelow, "~> 0.8", only: [:dev, :test]},
|
||||||
{:gollum, "~> 0.3.2"},
|
{:gollum, "~> 0.3.2"},
|
||||||
{:paginator, "~> 0.6.0"},
|
{:public_suffix, git: "https://github.com/axelson/publicsuffix-elixir"},
|
||||||
{:public_suffix, "~> 0.6.0"},
|
{:swoosh, "~> 1.0"},
|
||||||
{:idna, "~> 5.1.2", override: true},
|
{:gen_smtp, "~> 1.1"},
|
||||||
{:swoosh, "~> 0.23.3"},
|
|
||||||
{:ex_twilio, "~> 0.7.0"},
|
|
||||||
{:elasticsearch, "~> 1.0"},
|
{:elasticsearch, "~> 1.0"},
|
||||||
{:appsignal, "~> 1.10.1"},
|
{:appsignal, "~> 1.0"},
|
||||||
{:credo, "~> 1.1", only: [:dev, :test], runtime: false},
|
{:credo, "~> 1.1", only: [:dev, :test], runtime: false},
|
||||||
{:nebulex, "~> 1.1"}
|
{:nebulex, "~> 1.1"},
|
||||||
|
{:hunter, "~> 0.5.1"},
|
||||||
|
{:scrivener_ecto, "~> 2.2"},
|
||||||
|
{:recase, "~> 0.7"},
|
||||||
|
{:ex_rated, "~> 2.0"},
|
||||||
|
{:html_sanitize_ex, "~> 1.4"}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
124
backend/mix.lock
124
backend/mix.lock
|
@ -1,62 +1,66 @@
|
||||||
%{
|
%{
|
||||||
"appsignal": {:hex, :appsignal, "1.10.11", "5df2546d6ea15e392a4384b175ebc1bb33f4ccf8fe9872c11542d3ae2043ff88", [:make, :mix], [{:decorator, "~> 1.2.3", [hex: :decorator, repo: "hexpm", optional: false]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.2.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, ">= 1.1.0", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, ">= 1.3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
|
"appsignal": {:hex, :appsignal, "1.13.5", "153ebe929fae8f637d43bf66058efecbb4affc4037caa466d31a236cb3f2e788", [:make, :mix], [{:decorator, "~> 1.2.3 or ~> 1.3", [hex: :decorator, repo: "hexpm", optional: false]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, ">= 1.2.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.9", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.1.0", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, ">= 1.3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b8b6847c0d7f8ad03523be0fa6fdd670679ad42d62e2a8b74e599eba0247096c"},
|
||||||
"artificery": {:hex, :artificery, "0.4.2", "3ded6e29e13113af52811c72f414d1e88f711410cac1b619ab3a2666bbd7efd4", [:mix], [], "hexpm"},
|
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
|
||||||
"base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"},
|
"certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"},
|
||||||
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"},
|
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
|
||||||
"certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"},
|
"connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
|
||||||
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"},
|
"corsica": {:hex, :corsica, "1.1.3", "5f1de40bc9285753aa03afbdd10c364dac79b2ddbf2ba9c5c9c47b397ec06f40", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "8156b3a14a114a346262871333a931a1766b2597b56bf994fcfcb65443a348ad"},
|
||||||
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
|
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
|
||||||
"corsica": {:hex, :corsica, "1.1.2", "5ad8b9dcbeeda4762d78a57c0c8c2f88e1eef8741508517c98cb79e0db1f107d", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
|
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"},
|
||||||
"cowboy": {:hex, :cowboy, "2.6.3", "99aa50e94e685557cad82e704457336a453d4abcb77839ad22dbe71f311fcc06", [:rebar3], [{:cowlib, "~> 2.7.3", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"},
|
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
|
||||||
"cowlib": {:hex, :cowlib, "2.7.3", "a7ffcd0917e6d50b4d5fb28e9e2085a0ceb3c97dea310505f7460ff5ed764ce9", [:rebar3], [], "hexpm"},
|
"credo": {:hex, :credo, "1.5.6", "e04cc0fdc236fefbb578e0c04bd01a471081616e741d386909e527ac146016c6", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4b52a3e558bd64e30de62a648518a5ea2b6e3e5d2b164ef5296244753fc7eb17"},
|
||||||
"credo": {:hex, :credo, "1.1.3", "bf31887b8914a4b7e1810ae2b5aab7c657698abbf4cca6a2335a094d57995168", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
|
"crontab": {:hex, :crontab, "1.1.10", "dc9bb1f4299138d47bce38341f5dcbee0aa6c205e864fba7bc847f3b5cb48241", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "1347d889d1a0eda997990876b4894359e34bfbbd688acbb0ba28a2795ca40685"},
|
||||||
"crontab": {:hex, :crontab, "1.1.7", "b9219f0bdc8678b94143655a8f229716c5810c0636a4489f98c0956137e53985", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"},
|
"db_connection": {:hex, :db_connection, "2.4.0", "d04b1b73795dae60cead94189f1b8a51cc9e1f911c234cc23074017c43c031e5", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad416c21ad9f61b3103d254a71b63696ecadb6a917b36f563921e0de00d7d7c8"},
|
||||||
"db_connection": {:hex, :db_connection, "2.1.0", "122e2f62c4906bf2e49554f1e64db5030c19229aa40935f33088e7d543aa79d0", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"},
|
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
|
||||||
"decimal": {:hex, :decimal, "1.8.0", "ca462e0d885f09a1c5a342dbd7c1dcf27ea63548c65a65e67334f4b61803822e", [:mix], [], "hexpm"},
|
"decorator": {:hex, :decorator, "1.4.0", "a57ac32c823ea7e4e67f5af56412d12b33274661bb7640ec7fc882f8d23ac419", [:mix], [], "hexpm", "0a07cedd9083da875c7418dea95b78361197cf2bf3211d743f6f7ce39656597f"},
|
||||||
"decorator": {:hex, :decorator, "1.2.4", "31dfff6143d37f0b68d0bffb3b9f18ace14fea54d4f1b5e4f86ead6f00d9ff6e", [:mix], [], "hexpm"},
|
"ecto": {:hex, :ecto, "3.6.2", "efdf52acfc4ce29249bab5417415bd50abd62db7b0603b8bab0d7b996548c2bc", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "efad6dfb04e6f986b8a3047822b0f826d9affe8e4ebdd2aeedbfcb14fd48884e"},
|
||||||
"distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm"},
|
"ecto_sql": {:hex, :ecto_sql, "3.6.2", "9526b5f691701a5181427634c30655ac33d11e17e4069eff3ae1176c764e0ba3", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.6.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5ec9d7e6f742ea39b63aceaea9ac1d1773d574ea40df5a53ef8afbd9242fdb6b"},
|
||||||
"ecto": {:hex, :ecto, "3.1.7", "fa21d06ef56cdc2fdaa62574e8c3ba34a2751d44ea34c30bc65f0728421043e5", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
|
"elasticsearch": {:hex, :elasticsearch, "1.0.1", "8339538d90af6b280f10ecd02b1eae372f09373e629b336a13461babf7366495", [:mix], [{:httpoison, ">= 0.0.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sigaws, "~> 0.7", [hex: :sigaws, repo: "hexpm", optional: true]}, {:vex, "~> 0.6", [hex: :vex, repo: "hexpm", optional: false]}], "hexpm", "83e7d8b8bee3e7e19a06ab4d357d24845ac1da894e79678227fd52c0b7f71867"},
|
||||||
"ecto_sql": {:hex, :ecto_sql, "3.1.6", "1e80e30d16138a729c717f73dcb938590bcdb3a4502f3012414d0cbb261045d8", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:myxql, "~> 0.2.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0 or ~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
|
"ex2ms": {:hex, :ex2ms, "1.6.0", "f39bbd9ff1b0f27b3f707bab2d167066dd8965e7df1149b962d94c74615d0e09", [:mix], [], "hexpm", "0d1ab5e08421af5cd69146efb408dbb1ff77f38a2f4df5f086f2512dc8cf65bf"},
|
||||||
"elasticsearch": {:hex, :elasticsearch, "1.0.0", "626d3fb8e7554d9c93eb18817ae2a3d22c2a4191cc903c4644b1334469b15374", [:mix], [{:httpoison, ">= 0.0.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sigaws, "~> 0.7", [hex: :sigaws, repo: "hexpm", optional: true]}, {:vex, "~> 0.6.0", [hex: :vex, repo: "hexpm", optional: false]}], "hexpm"},
|
"ex_rated": {:hex, :ex_rated, "2.0.1", "49b4c170039fc62fa93ea28df16e3586e98c2fe0aec10f75e6717fba8039637f", [:mix], [{:ex2ms, "~> 1.5", [hex: :ex2ms, repo: "hexpm", optional: false]}], "hexpm", "2f675b649f74028842ae3d1f0c5090f8a664682df98c82836db6f1d321eaa42a"},
|
||||||
"ex_twilio": {:hex, :ex_twilio, "0.7.0", "d7ce624ef4661311ae28c3e3aa060ecb66a9f4843184d7400c29072f7d3f5a4a", [:mix], [{:httpoison, ">= 0.9.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:inflex, "~> 1.0", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.0", [hex: :joken, repo: "hexpm", optional: false]}, {:poison, "~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
|
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
|
||||||
"gen_stage": {:hex, :gen_stage, "0.14.2", "6a2a578a510c5bfca8a45e6b27552f613b41cf584b58210f017088d3d17d0b14", [:mix], [], "hexpm"},
|
"gen_smtp": {:hex, :gen_smtp, "1.1.1", "bf9303c31735100631b1d708d629e4c65944319d1143b5c9952054f4a1311d85", [:rebar3], [{:hut, "1.3.0", [hex: :hut, repo: "hexpm", optional: false]}, {:ranch, ">= 1.7.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "51bc50cc017efd4a4248cbc39ea30fb60efa7d4a49688986fafad84434ff9ab7"},
|
||||||
"gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"},
|
"gen_stage": {:hex, :gen_stage, "1.1.0", "dd0c0f8d2f3b993fdbd3d58e94abbe65380f4e78bdee3fa93d5618d7d14abe60", [:mix], [], "hexpm", "7f2b36a6d02f7ef2ba410733b540ec423af65ec9c99f3d1083da508aca3b9305"},
|
||||||
"gettext": {:hex, :gettext, "0.17.0", "abe21542c831887a2b16f4c94556db9c421ab301aee417b7c4fbde7fbdbe01ec", [:mix], [], "hexpm"},
|
"gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"},
|
||||||
"gollum": {:hex, :gollum, "0.3.3", "25ebb47700b9236bc4e5382bf91b72e4cdaf9bae3556172eff27e770735a198f", [:mix], [{:httpoison, "~> 1.5.1", [hex: :httpoison, repo: "hexpm", optional: false]}], "hexpm"},
|
"gollum": {:hex, :gollum, "0.3.3", "25ebb47700b9236bc4e5382bf91b72e4cdaf9bae3556172eff27e770735a198f", [:mix], [{:httpoison, "~> 1.5.1", [hex: :httpoison, repo: "hexpm", optional: false]}], "hexpm", "39268eeaf4f0adb6fdebe4f8c36b10a277881ab2eee3419c9b6727759e2f5a5d"},
|
||||||
"hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
|
"hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"},
|
||||||
"honeydew": {:hex, :honeydew, "1.4.3", "f2d976aaf8b9b914a635d2d483f1a71d2f6d8651809474dd5db581953cbebb30", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"},
|
"honeydew": {:hex, :honeydew, "1.5.0", "53088c1d87399efa5c0939adc8d32a9713b8fe6ce00a77c6769d2d363abac6bc", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "f71669e25f6a972e970ecbd79c34c4ad4b28369be78e4f8164fe8d0c5a674907"},
|
||||||
"httpoison": {:hex, :httpoison, "1.5.1", "0f55b5b673b03c5c327dac7015a67cb571b99b631acc0bc1b0b98dcd6b9f2104", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
|
"html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.1", "e8a67da405fe9f0d1be121a40a60f70811192033a5b8d00a95dddd807f5e053e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "68d92656f47cd73598c45ad2394561f025c8c65d146001b955fd7b517858962a"},
|
||||||
"idna": {:hex, :idna, "5.1.2", "e21cb58a09f0228a9e0b95eaa1217f1bcfc31a1aaa6e1fdf2f53a33f7dbd9494", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"},
|
"httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"},
|
||||||
"inflex": {:hex, :inflex, "1.10.0", "8366a7696e70e1813aca102e61274addf85d99f4a072b2f9c7984054ea1b9d29", [:mix], [], "hexpm"},
|
"hunter": {:hex, :hunter, "0.5.1", "374dc4a800e2c340659657f8875e466075c7ea532e0d7a7787665f272b410150", [:mix], [{:httpoison, "~> 1.5", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, "~> 4.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm", "209b2cca7e4d51d5ff7ee4a0ab6cdc4c6ad23ddd61c9e12ceeee6f7ffbeae9c8"},
|
||||||
"jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
|
"hut": {:hex, :hut, "1.3.0", "71f2f054e657c03f959cf1acc43f436ea87580696528ca2a55c8afb1b06c85e7", [:"erlang.mk", :rebar, :rebar3], [], "hexpm", "7e15d28555d8a1f2b5a3a931ec120af0753e4853a4c66053db354f35bf9ab563"},
|
||||||
"joken": {:hex, :joken, "2.1.0", "bf21a73105d82649f617c5e59a7f8919aa47013d2519ebcc39d998d8d12adda9", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm"},
|
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
||||||
"jose": {:hex, :jose, "1.9.0", "4167c5f6d06ffaebffd15cdb8da61a108445ef5e85ab8f5a7ad926fdf3ada154", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"},
|
"inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"},
|
||||||
"libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"},
|
"jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"},
|
||||||
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
|
"joken": {:hex, :joken, "2.3.0", "62a979c46f2c81dcb8ddc9150453b60d3757d1ac393c72bb20fc50a7b0827dc6", [:mix], [{:jose, "~> 1.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "57b263a79c0ec5d536ac02d569c01e6b4de91bd1cb825625fe90eab4feb7bc1e"},
|
||||||
"mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"},
|
"jose": {:hex, :jose, "1.11.1", "59da64010c69aad6cde2f5b9248b896b84472e99bd18f246085b7b9fe435dcdb", [:mix, :rebar3], [], "hexpm", "078f6c9fb3cd2f4cfafc972c814261a7d1e8d2b3685c0a76eb87e158efff1ac5"},
|
||||||
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"},
|
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
|
||||||
"nebulex": {:hex, :nebulex, "1.1.0", "be45cc3a2b7d01eb7da05747d38072d336187d05796ad9ef2d9dad9be430f915", [:mix], [{:shards, "~> 0.6", [hex: :shards, repo: "hexpm", optional: false]}], "hexpm"},
|
"mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"},
|
||||||
"paginator": {:hex, :paginator, "0.6.0", "bc2c01abdd98281ff39b6a7439cf540091122a7927bdaabc167c61d4508f9cbb", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm"},
|
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
|
||||||
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"},
|
"mochiweb": {:hex, :mochiweb, "2.21.0", "3fe5c3403606726d7bc6dabbf36f9d634d5364ce7f33ce73442937fa54feec37", [:rebar3], [], "hexpm", "f848bfa1b75c32d56da9d2730245e34df4b39079c5d45d7b966b072ba53f8a13"},
|
||||||
"phoenix": {:hex, :phoenix, "1.4.9", "746d098e10741c334d88143d3c94cab1756435f94387a63441792e66ec0ee974", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
|
"nebulex": {:hex, :nebulex, "1.2.2", "5b2bb7420a103b2a4278f354c9bd239bc77cd3bbdeddcebc4cc1d6ee656f126c", [:mix], [{:decorator, "~> 1.3", [hex: :decorator, repo: "hexpm", optional: false]}, {:shards, "~> 0.6", [hex: :shards, repo: "hexpm", optional: false]}], "hexpm", "6804ddd7660fd4010a5af5957316ab7471c2db003189dba79dc3dd7b3f0aabf6"},
|
||||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
|
"parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
|
||||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"},
|
"phoenix": {:hex, :phoenix, "1.5.9", "a6368d36cfd59d917b37c44386e01315bc89f7609a10a45a22f47c007edf2597", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7e4bce20a67c012f1fbb0af90e5da49fa7bf0d34e3a067795703b74aef75427d"},
|
||||||
"plug": {:hex, :plug, "1.8.2", "0bcce1daa420f189a6491f3940cc77ea7fb1919761175c9c3b59800d897440fc", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"},
|
"phoenix_ecto": {:hex, :phoenix_ecto, "4.3.0", "2c69a452c2e0ee8c93345ae1cdc1696ef4877ff9cbb15c305def41960c3c4ebf", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "0ac491924217550c8f42c81c1f390b5d81517d12ceaf9abf3e701156760a848e"},
|
||||||
"plug_cowboy": {:hex, :plug_cowboy, "2.1.0", "b75768153c3a8a9e8039d4b25bb9b14efbc58e9c4a6e6a270abff1cd30cbe320", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
|
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"},
|
||||||
"plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"},
|
"plug": {:hex, :plug, "1.12.0", "39dc7f1ef8c46bb1bf6dd8f6a49f526c45b4b92ce553687fd885b559a46d0230", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5282c76e89efdf43f2e04bd268ca99d738039f9518137f02ff468cee3ba78096"},
|
||||||
"poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"},
|
"plug_cowboy": {:hex, :plug_cowboy, "2.5.1", "7cc96ff645158a94cf3ec9744464414f02287f832d6847079adfe0b58761cbd0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "107d0a5865fa92bcb48e631cc0729ae9ccfa0a9f9a1bd8f01acb513abf1c2d64"},
|
||||||
"postgrex": {:hex, :postgrex, "0.14.3", "5754dee2fdf6e9e508cbf49ab138df964278700b764177e8f3871e658b345a1e", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
|
"plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"},
|
||||||
"public_suffix": {:hex, :public_suffix, "0.6.0", "100cfe86f13f9f6f0cf67e743b1b83c78dd1223a2c422fa03ebf4adff514cbc3", [:mix], [{:idna, ">= 1.2.0 and < 6.0.0", [hex: :idna, repo: "hexpm", optional: false]}], "hexpm"},
|
"poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"},
|
||||||
"quantum": {:hex, :quantum, "2.3.4", "72a0e8855e2adc101459eac8454787cb74ab4169de6ca50f670e72142d4960e9", [:mix], [{:calendar, "~> 0.17", [hex: :calendar, repo: "hexpm", optional: true]}, {:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.12", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:swarm, "~> 3.3", [hex: :swarm, repo: "hexpm", optional: false]}, {:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: true]}], "hexpm"},
|
"postgrex": {:hex, :postgrex, "0.15.10", "2809dee1b1d76f7cbabe570b2a9285c2e7b41be60cf792f5f2804a54b838a067", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "1560ca427542f6b213f8e281633ae1a3b31cdbcd84ebd7f50628765b8f6132be"},
|
||||||
"ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"},
|
"public_suffix": {:git, "https://github.com/axelson/publicsuffix-elixir", "89372422ab8b433de508519ef474e39699fd11ca", []},
|
||||||
"shards": {:hex, :shards, "0.6.0", "678d292ad74a4598a872930f9b12251f43e97f6050287f1fb712fbfd3d282f75", [:make, :rebar3], [], "hexpm"},
|
"quantum": {:hex, :quantum, "3.3.0", "e8f6b9479728774288c5f426b11a6e3e8f619f3c226163a7e18bccfe543b714d", [:mix], [{:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.14 or ~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3b83ef137ab3887e783b013418b5ce3e847d66b71c4ef0f233b0321c84b72f67"},
|
||||||
"sobelow": {:hex, :sobelow, "0.8.0", "a3ec73e546dfde19f14818e5000c418e3f305d9edb070e79dd391de0ae1cd1ea", [:mix], [], "hexpm"},
|
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
|
||||||
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"},
|
"recase": {:hex, :recase, "0.7.0", "3f2f719f0886c7a3b7fe469058ec539cb7bbe0023604ae3bce920e186305e5ae", [:mix], [], "hexpm", "36f5756a9f552f4a94b54a695870e32f4e72d5fad9c25e61bc4a3151c08a4e0c"},
|
||||||
"swarm": {:hex, :swarm, "3.4.0", "64f8b30055d74640d2186c66354b33b999438692a91be275bb89cdc7e401f448", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:libring, "~> 1.0", [hex: :libring, repo: "hexpm", optional: false]}], "hexpm"},
|
"scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"},
|
||||||
"swoosh": {:hex, :swoosh, "0.23.3", "750a6d4e2b72e4307e2ff53209fd990cebb46edbf7cb4479678d4e68eb17fe98", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"},
|
"scrivener_ecto": {:hex, :scrivener_ecto, "2.7.0", "cf64b8cb8a96cd131cdbcecf64e7fd395e21aaa1cb0236c42a7c2e34b0dca580", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm", "e809f171687806b0031129034352f5ae44849720c48dd839200adeaf0ac3e260"},
|
||||||
"telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"},
|
"shards": {:hex, :shards, "0.6.2", "e05d05537883220c3b8a8f9d40d5c8ba7ff6064c63ebb6b23046972f6863b2d1", [:make, :rebar3], [], "hexpm", "58afa3712f1f1256a2a15e39fa95b7cd758087aaa7a25beaf786daabd87890f0"},
|
||||||
"timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"},
|
"sobelow": {:hex, :sobelow, "0.11.1", "23438964486f8112b41e743bbfd402da3e5b296fdc9eacab29914b79c48916dd", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9897363a7eff96f4809304a90aad819e2ad5e5d24db547af502885146746a53c"},
|
||||||
"tzdata": {:hex, :tzdata, "1.0.1", "f6027a331af7d837471248e62733c6ebee86a72e57c613aa071ebb1f750fc71a", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
|
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
|
||||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"},
|
"swoosh": {:hex, :swoosh, "1.5.0", "2be4cfc1be10f2203d1854c85b18d8c7be0321445a782efd53ef0b2b88f03ce4", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b53891359e3ddca263ece784051243de84c9244c421a0dee1bff1d52fc5ca420"},
|
||||||
"vex": {:hex, :vex, "0.6.0", "4e79b396b2ec18cd909eed0450b19108d9631842598d46552dc05031100b7a56", [:mix], [], "hexpm"},
|
"telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"},
|
||||||
|
"timex": {:hex, :timex, "3.7.5", "3eca56e23bfa4e0848f0b0a29a92fa20af251a975116c6d504966e8a90516dfd", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "a15608dca680f2ef663d71c95842c67f0af08a0f3b1d00e17bbd22872e2874e4"},
|
||||||
|
"tzdata": {:hex, :tzdata, "1.1.0", "72f5babaa9390d0f131465c8702fa76da0919e37ba32baa90d93c583301a8359", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "18f453739b48d3dc5bcf0e8906d2dc112bb40baafe2c707596d89f3c8dd14034"},
|
||||||
|
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
|
||||||
|
"vex": {:hex, :vex, "0.9.0", "613ea5eb3055662e7178b83e25b2df0975f68c3d8bb67c1645f0573e1a78d606", [:mix], [], "hexpm", "c69fff44d5c8aa3f1faee71bba1dcab05dd36364c5a629df8bb11751240c857f"},
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
defmodule Backend.Repo.Migrations.CreateFederationRestrictions do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:federation_restrictions) do
|
||||||
|
add :source_domain,
|
||||||
|
references(:instances, column: :domain, type: :string, on_delete: :delete_all),
|
||||||
|
null: false
|
||||||
|
|
||||||
|
add :target_domain,
|
||||||
|
references(:instances, column: :domain, type: :string, on_delete: :delete_all),
|
||||||
|
null: false
|
||||||
|
|
||||||
|
add :type, :string, null: false
|
||||||
|
|
||||||
|
timestamps()
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:federation_restrictions, [:source_domain])
|
||||||
|
create index(:federation_restrictions, [:target_domain])
|
||||||
|
end
|
||||||
|
end
|
|
@ -50,7 +50,10 @@ defmodule BackendWeb.InstanceControllerTest do
|
||||||
describe "update instance" do
|
describe "update instance" do
|
||||||
setup [:create_instance]
|
setup [:create_instance]
|
||||||
|
|
||||||
test "renders instance when data is valid", %{conn: conn, instance: %Instance{id: id} = instance} do
|
test "renders instance when data is valid", %{
|
||||||
|
conn: conn,
|
||||||
|
instance: %Instance{id: id} = instance
|
||||||
|
} do
|
||||||
conn = put(conn, Routes.instance_path(conn, :update, instance), instance: @update_attrs)
|
conn = put(conn, Routes.instance_path(conn, :update, instance), instance: @update_attrs)
|
||||||
assert %{"id" => ^id} = json_response(conn, 200)["data"]
|
assert %{"id" => ^id} = json_response(conn, 200)["data"]
|
||||||
|
|
||||||
|
|
|
@ -1,65 +0,0 @@
|
||||||
version: "3"
|
|
||||||
services:
|
|
||||||
db:
|
|
||||||
image: postgres
|
|
||||||
environment:
|
|
||||||
- DATABASE_URL
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
|
||||||
- pgdata:/var/lib/postgresql/data
|
|
||||||
networks:
|
|
||||||
- database_network
|
|
||||||
elasticsearch:
|
|
||||||
image: elasticsearch:6.8.1
|
|
||||||
ports:
|
|
||||||
- "9200:9200"
|
|
||||||
volumes:
|
|
||||||
- esdata:/usr/share/elasticsearch/data
|
|
||||||
networks:
|
|
||||||
- phoenix_network
|
|
||||||
- es_network
|
|
||||||
# Kibana is just for development, really
|
|
||||||
kibana:
|
|
||||||
image: kibana:6.8.1
|
|
||||||
networks:
|
|
||||||
- es_network
|
|
||||||
ports:
|
|
||||||
- "5601:5601"
|
|
||||||
# This is for running the occasional graph layout task. It's in docker-compose.yml so that it's built at the same time
|
|
||||||
# as everything else, but it should be run regularly with a cron job or similar.
|
|
||||||
gephi:
|
|
||||||
environment:
|
|
||||||
- DATABASE_URL
|
|
||||||
build: ./gephi
|
|
||||||
volumes:
|
|
||||||
- gradle-cache:/code/.gradle
|
|
||||||
depends_on:
|
|
||||||
- db
|
|
||||||
networks:
|
|
||||||
- database_network
|
|
||||||
phoenix:
|
|
||||||
build: ./backend
|
|
||||||
networks:
|
|
||||||
- database_network
|
|
||||||
- phoenix_network
|
|
||||||
depends_on:
|
|
||||||
- db
|
|
||||||
ports:
|
|
||||||
- "${PORT}:${PORT}"
|
|
||||||
environment:
|
|
||||||
- DATABASE_URL
|
|
||||||
- SECRET_KEY_BASE
|
|
||||||
- PORT
|
|
||||||
- BACKEND_HOSTNAME
|
|
||||||
volumes:
|
|
||||||
pgdata:
|
|
||||||
esdata:
|
|
||||||
gradle-cache:
|
|
||||||
networks:
|
|
||||||
database_network:
|
|
||||||
driver: bridge
|
|
||||||
phoenix_network:
|
|
||||||
driver: bridge
|
|
||||||
es_network:
|
|
||||||
driver: bridge
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
coverage
|
|
@ -0,0 +1,24 @@
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
parser: "@typescript-eslint/parser",
|
||||||
|
parserOptions: {
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
project: ["./tsconfig.json"],
|
||||||
|
},
|
||||||
|
plugins: ["@typescript-eslint", "prettier"],
|
||||||
|
extends: [
|
||||||
|
"plugin:@typescript-eslint/recommended-requiring-type-checking",
|
||||||
|
"plugin:@typescript-eslint/eslint-recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"plugin:react-hooks/recommended",
|
||||||
|
"prettier/@typescript-eslint",
|
||||||
|
"prettier",
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-explicit-any": 0,
|
||||||
|
"@typescript-eslint/explicit-function-return-type": 0,
|
||||||
|
"react/prop-types": 0,
|
||||||
|
"@typescript-eslint/no-non-null-assertion": 0
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,3 @@
|
||||||
|
module.exports = {
|
||||||
|
printWidth: 100
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,37 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name fediverse.space;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_comp_level 5;
|
||||||
|
gzip_min_length 256;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_vary on;
|
||||||
|
|
||||||
|
gzip_types
|
||||||
|
application/javascript
|
||||||
|
application/vnd.geo+json
|
||||||
|
application/vnd.ms-fontobject
|
||||||
|
application/x-font-ttf
|
||||||
|
application/x-web-app-manifest+json
|
||||||
|
font/opentype
|
||||||
|
image/bmp
|
||||||
|
image/svg+xml
|
||||||
|
image/x-icon
|
||||||
|
text/cache-manifest
|
||||||
|
text/css
|
||||||
|
text/plain
|
||||||
|
text/vcard
|
||||||
|
text/vnd.rim.location.xloc
|
||||||
|
text/vtt
|
||||||
|
text/x-component
|
||||||
|
text/x-cross-domain-policy;
|
||||||
|
|
||||||
|
root /website;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -1,16 +1,18 @@
|
||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.7.1",
|
"version": "2.8.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "NODE_ENV=development react-scripts start",
|
"start": "NODE_ENV=development react-scripts start",
|
||||||
"build": "react-scripts build",
|
"build": "react-scripts build",
|
||||||
"typecheck": "tsc --noemit",
|
"typecheck": "tsc --noemit",
|
||||||
"lint": "yarn typecheck && tslint -p tsconfig.json -c tslint.json \"src/**/*.{ts,tsx}\"",
|
"lint": "yarn typecheck && yarn eslint src/ --ext .js,.jsx,.ts,.tsx",
|
||||||
"lint:fix": "yarn lint --fix",
|
"lint:fix": "yarn lint --fix",
|
||||||
"pretty": "prettier --write \"src/**/*.{ts,tsx}\"",
|
"pretty": "prettier --write \"src/**/*.{ts,tsx}\"",
|
||||||
"test": "yarn lint && react-scripts test --ci",
|
"test": "yarn lint && react-scripts test --ci",
|
||||||
"eject": "react-scripts eject"
|
"eject": "react-scripts eject",
|
||||||
|
"snyk-protect": "snyk protect",
|
||||||
|
"prepare": "yarn run snyk-protect"
|
||||||
},
|
},
|
||||||
"husky": {
|
"husky": {
|
||||||
"hooks": {
|
"hooks": {
|
||||||
|
@ -20,67 +22,74 @@
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"src/**/*.{ts,tsx}": [
|
"src/**/*.{ts,tsx}": [
|
||||||
"yarn pretty",
|
"yarn pretty",
|
||||||
"yarn lint:fix",
|
"yarn lint:fix"
|
||||||
"git add"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"printWidth": 120
|
"printWidth": 120
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@blueprintjs/core": "^3.18.0",
|
"@blueprintjs/core": "^3.33.0",
|
||||||
"@blueprintjs/icons": "^3.10.0",
|
"@blueprintjs/icons": "^3.22.0",
|
||||||
"@blueprintjs/select": "^3.10.0",
|
"@blueprintjs/select": "^3.14.2",
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.2.6",
|
||||||
"connected-react-router": "^6.5.2",
|
"connected-react-router": "^6.5.2",
|
||||||
"cross-fetch": "^3.0.4",
|
"cross-fetch": "^3.0.6",
|
||||||
"cytoscape": "^3.9.0",
|
"cytoscape": "^3.16.1",
|
||||||
"cytoscape-popper": "^1.0.4",
|
"cytoscape-popper": "^1.0.7",
|
||||||
"inflection": "^1.12.0",
|
"inflection": "^1.12.0",
|
||||||
"lodash": "^4.17.15",
|
"lodash": "^4.17.20",
|
||||||
"moment": "^2.22.2",
|
"moment": "^2.29.1",
|
||||||
"normalize.css": "^8.0.0",
|
"normalize.css": "^8.0.0",
|
||||||
"numeral": "^2.0.6",
|
"numeral": "^2.0.6",
|
||||||
"react": "^16.9.0",
|
"react": "^16.10.2",
|
||||||
"react-dom": "^16.9.0",
|
"react-dom": "^16.10.2",
|
||||||
"react-redux": "^7.1.0",
|
"react-redux": "^7.2.1",
|
||||||
"react-router-dom": "^5.0.1",
|
"react-router-dom": "^5.2.0",
|
||||||
"react-scripts": "^3.1.1",
|
"react-sigma": "^1.2.35",
|
||||||
"react-sigma": "^1.2.30",
|
"react-virtualized": "^9.22.2",
|
||||||
"react-virtualized": "^9.21.1",
|
|
||||||
"redux": "^4.0.4",
|
"redux": "^4.0.4",
|
||||||
"redux-thunk": "^2.3.0",
|
"redux-thunk": "^2.3.0",
|
||||||
"sanitize-html": "^1.20.1",
|
"sanitize-html": "^2.0.0",
|
||||||
"styled-components": "^4.3.2",
|
"snyk": "^1.410.1",
|
||||||
|
"styled-components": "^5.2.0",
|
||||||
"tippy.js": "^4.3.5"
|
"tippy.js": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@blueprintjs/tslint-config": "^1.9.0",
|
|
||||||
"@types/classnames": "^2.2.9",
|
"@types/classnames": "^2.2.9",
|
||||||
"@types/cytoscape": "^3.8.1",
|
"@types/cytoscape": "^3.14.7",
|
||||||
"@types/inflection": "^1.5.28",
|
"@types/inflection": "^1.5.28",
|
||||||
"@types/jest": "^24.0.18",
|
"@types/jest": "^26.0.14",
|
||||||
"@types/lodash": "^4.14.137",
|
"@types/lodash": "^4.14.161",
|
||||||
"@types/node": "^12.7.2",
|
"@types/node": "^14.11.5",
|
||||||
"@types/numeral": "^0.0.26",
|
"@types/numeral": "^0.0.28",
|
||||||
"@types/react": "^16.9.2",
|
"@types/react": "^16.9.51",
|
||||||
"@types/react-dom": "^16.9.0",
|
"@types/react-axe": "^3.1.0",
|
||||||
"@types/react-redux": "^7.1.2",
|
"@types/react-dom": "^16.9.8",
|
||||||
"@types/react-router-dom": "^4.3.4",
|
"@types/react-redux": "^7.1.9",
|
||||||
"@types/sanitize-html": "^1.20.1",
|
"@types/react-router-dom": "^5.1.6",
|
||||||
"@types/styled-components": "4.1.18",
|
"@types/sanitize-html": "^1.27.0",
|
||||||
"husky": "^3.0.4",
|
"@types/styled-components": "5.1.3",
|
||||||
"lint-staged": "^9.2.3",
|
"@typescript-eslint/eslint-plugin": "^2.24.0",
|
||||||
"react-axe": "^3.2.0",
|
"@typescript-eslint/parser": "^2.34.0",
|
||||||
"tslint": "^5.18.0",
|
"eslint-config-airbnb-typescript": "^7.2.1",
|
||||||
"tslint-config-security": "^1.16.0",
|
"eslint-config-prettier": "^6.12.0",
|
||||||
"tslint-eslint-rules": "^5.4.0",
|
"eslint-plugin-import": "^2.22.1",
|
||||||
"typescript": "^3.5.3"
|
"eslint-plugin-jsx-a11y": "^6.3.1",
|
||||||
|
"eslint-plugin-prettier": "^3.1.4",
|
||||||
|
"eslint-plugin-react": "^7.21.3",
|
||||||
|
"eslint-plugin-react-hooks": "^4.1.2",
|
||||||
|
"husky": "^4.3.0",
|
||||||
|
"lint-staged": "^10.4.0",
|
||||||
|
"prettier": "^2.1.2",
|
||||||
|
"react-scripts": "3.4.3",
|
||||||
|
"typescript": "^3.9.2"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
">0.2%",
|
">0.2%",
|
||||||
"not dead",
|
"not dead",
|
||||||
"not ie <= 11",
|
"not ie <= 11",
|
||||||
"not op_mini all"
|
"not op_mini all"
|
||||||
]
|
],
|
||||||
|
"snyk": true
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,20 +4,30 @@ import { Classes } from "@blueprintjs/core";
|
||||||
|
|
||||||
import { ConnectedRouter } from "connected-react-router";
|
import { ConnectedRouter } from "connected-react-router";
|
||||||
import { Route } from "react-router-dom";
|
import { Route } from "react-router-dom";
|
||||||
import { Nav } from "./components/organisms/";
|
import { Nav } from "./components/organisms";
|
||||||
import { AboutScreen, AdminScreen, GraphScreen, LoginScreen, VerifyLoginScreen } from "./components/screens/";
|
import {
|
||||||
|
AboutScreen,
|
||||||
|
AdminScreen,
|
||||||
|
GraphScreen,
|
||||||
|
LoginScreen,
|
||||||
|
TableScreen,
|
||||||
|
VerifyLoginScreen,
|
||||||
|
} from "./components/screens";
|
||||||
import { history } from "./index";
|
import { history } from "./index";
|
||||||
|
|
||||||
const AppRouter: React.FC = () => (
|
const AppRouter: React.FC = () => (
|
||||||
<ConnectedRouter history={history}>
|
<ConnectedRouter history={history}>
|
||||||
<div className={`${Classes.DARK} App`}>
|
<div className={`${Classes.DARK} App`}>
|
||||||
<Nav />
|
<Nav />
|
||||||
<Route path="/about" exact={true} component={AboutScreen} />
|
<main role="main">
|
||||||
<Route path="/admin/login" exact={true} component={LoginScreen} />
|
<Route path="/instances" exact component={TableScreen} />
|
||||||
<Route path="/admin/verify" exact={true} component={VerifyLoginScreen} />
|
<Route path="/about" exact component={AboutScreen} />
|
||||||
<Route path="/admin" exact={true} component={AdminScreen} />
|
<Route path="/admin/login" exact component={LoginScreen} />
|
||||||
{/* We always want the GraphScreen to be rendered (since un- and re-mounting it is expensive */}
|
<Route path="/admin/verify" exact component={VerifyLoginScreen} />
|
||||||
<GraphScreen />
|
<Route path="/admin" exact component={AdminScreen} />
|
||||||
|
{/* We always want the GraphScreen to be rendered (since un- and re-mounting it is expensive */}
|
||||||
|
<GraphScreen />
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</ConnectedRouter>
|
</ConnectedRouter>
|
||||||
);
|
);
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 5.2 KiB |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 170.08 170.08"><defs><style>.cls-1{fill:#04246e;}.cls-2{fill:#fff;}</style></defs><title>square-mark-white</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><rect class="cls-1" width="170.08" height="170.08"/><path class="cls-2" d="M127.34,58c-8.4,0-14.41,7.4-20.6,15.65-3.84-17.51-8.11-35-21.7-35s-17.86,17.5-21.71,35C57.15,65.37,51.13,58,42.74,58c-5.35,0-14.39,3.63-14.39,17.25L28.41,100c0,13.36,7.14,16.64,11.42,17.76h0c7.39,1.91,25.17,3.69,45.18,3.69s37.8-1.78,45.18-3.69h0c4.28-1.12,11.42-4.4,11.42-17.76l.06-24.79c0-13.62-9-17.25-14.39-17.25M85,46.33c8.15,0,11.24,16.06,15.57,35.33C95.86,87.5,90.83,92.21,85,92.21S74.21,87.5,69.47,81.66c4-18,7.17-35.33,15.57-35.33M36.13,100l-.06-24.79c0-8.6,4.67-9.53,6.67-9.53,5.92,0,12.28,9.88,18.36,17.83-4.29,18-8.83,29-19.56,26.72-2.78-.77-5.41-2.53-5.41-10.23m21,12.53c4.75-5.16,7.78-13.21,10.08-21.73,5,5.19,10.69,9.13,17.8,9.13S97.86,96,102.83,90.81c2.31,8.52,5.33,16.57,10.07,21.73-8.29.75-18.28,1.21-27.86,1.21s-19.59-.46-27.88-1.21M134,100c0,7.7-2.63,9.46-5.42,10.23-10.73,2.27-15.26-8.72-19.56-26.72,6.08-7.94,12.45-17.83,18.37-17.83,2,0,6.67.93,6.67,9.53Z"/></g></g></svg>
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 11 KiB |
|
@ -8,10 +8,10 @@ const FloatingCardRow = styled.div`
|
||||||
`;
|
`;
|
||||||
const FloatingCardElement = styled(Card)`
|
const FloatingCardElement = styled(Card)`
|
||||||
margin: 0 0 10px 10px;
|
margin: 0 0 10px 10px;
|
||||||
z-index: 20;
|
z-index: 2;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const FloatingCard: React.FC<ICardProps> = props => (
|
const FloatingCard: React.FC<ICardProps> = (props) => (
|
||||||
<FloatingCardRow>
|
<FloatingCardRow>
|
||||||
<FloatingCardElement elevation={Elevation.ONE} {...props} />
|
<FloatingCardElement elevation={Elevation.ONE} {...props} />
|
||||||
</FloatingCardRow>
|
</FloatingCardRow>
|
||||||
|
|
|
@ -7,13 +7,13 @@ const StyledSwitch = styled(Switch)`
|
||||||
margin: 0;
|
margin: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface IGraphHideEdgesButtonProps {
|
interface GraphHideEdgesButtonProps {
|
||||||
isShowingEdges: boolean;
|
isShowingEdges: boolean;
|
||||||
toggleEdges: () => void;
|
toggleEdges: () => void;
|
||||||
}
|
}
|
||||||
const GraphHideEdgesButton: React.FC<IGraphHideEdgesButtonProps> = ({ isShowingEdges, toggleEdges }) => (
|
const GraphHideEdgesButton: React.FC<GraphHideEdgesButtonProps> = ({ isShowingEdges, toggleEdges }) => (
|
||||||
<FloatingCard>
|
<FloatingCard>
|
||||||
<StyledSwitch checked={isShowingEdges} label="Show connections" onChange={toggleEdges} />
|
<StyledSwitch checked={isShowingEdges} label="Show connections" onChange={toggleEdges} tabIndex={-1} />
|
||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
);
|
);
|
||||||
export default GraphHideEdgesButton;
|
export default GraphHideEdgesButton;
|
||||||
|
|
|
@ -6,9 +6,9 @@ import React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { FloatingCard, InstanceType } from ".";
|
import { FloatingCard, InstanceType } from ".";
|
||||||
import { QUANTITATIVE_COLOR_SCHEME } from "../../constants";
|
import { QUANTITATIVE_COLOR_SCHEME } from "../../constants";
|
||||||
import { IColorScheme } from "../../types";
|
import { ColorScheme } from "../../types";
|
||||||
|
|
||||||
const ColorSchemeSelect = Select.ofType<IColorScheme>();
|
const ColorSchemeSelect = Select.ofType<ColorScheme>();
|
||||||
|
|
||||||
const StyledLi = styled.li`
|
const StyledLi = styled.li`
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
|
@ -27,12 +27,12 @@ const ColorBarContainer = styled.div`
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
`;
|
`;
|
||||||
interface IColorBarProps {
|
interface ColorBarProps {
|
||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
const ColorBar = styled.div<IColorBarProps>`
|
const ColorBar = styled.div<ColorBarProps>`
|
||||||
width: 10px;
|
width: 10px;
|
||||||
background-color: ${props => props.color};
|
background-color: ${(props) => props.color};
|
||||||
flex: 1;
|
flex: 1;
|
||||||
`;
|
`;
|
||||||
const TextContainer = styled.div`
|
const TextContainer = styled.div`
|
||||||
|
@ -41,13 +41,46 @@ const TextContainer = styled.div`
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface IGraphKeyProps {
|
const renderItem: ItemRenderer<ColorScheme> = (colorScheme, { handleClick, modifiers }) => {
|
||||||
current?: IColorScheme;
|
if (!modifiers.matchesPredicate) {
|
||||||
colorSchemes: IColorScheme[];
|
return null;
|
||||||
|
}
|
||||||
|
return <MenuItem active={modifiers.active} key={colorScheme.name} onClick={handleClick} text={colorScheme.name} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderQualitativeKey = (values: string[]) => (
|
||||||
|
<ul className={Classes.LIST_UNSTYLED}>
|
||||||
|
{values.map((v) => (
|
||||||
|
<StyledLi key={v}>
|
||||||
|
<InstanceType type={v} />
|
||||||
|
</StyledLi>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderQuantitativeKey = (range: number[]) => {
|
||||||
|
const [min, max] = range;
|
||||||
|
return (
|
||||||
|
<ColorKeyContainer>
|
||||||
|
<ColorBarContainer>
|
||||||
|
{QUANTITATIVE_COLOR_SCHEME.map((color) => (
|
||||||
|
<ColorBar color={color} key={color} />
|
||||||
|
))}
|
||||||
|
</ColorBarContainer>
|
||||||
|
<TextContainer>
|
||||||
|
<span className={Classes.TEXT_SMALL}>{numeral.default(min).format("0")}</span>
|
||||||
|
<span className={Classes.TEXT_SMALL}>{numeral.default(max).format("0")}</span>
|
||||||
|
</TextContainer>
|
||||||
|
</ColorKeyContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
interface GraphKeyProps {
|
||||||
|
current?: ColorScheme;
|
||||||
|
colorSchemes: ColorScheme[];
|
||||||
ranges?: { [key: string]: [number, number] };
|
ranges?: { [key: string]: [number, number] };
|
||||||
onItemSelect: (colorScheme?: IColorScheme) => void;
|
onItemSelect: (colorScheme?: ColorScheme) => void;
|
||||||
}
|
}
|
||||||
const GraphKey: React.FC<IGraphKeyProps> = ({ current, colorSchemes, ranges, onItemSelect }) => {
|
const GraphKey: React.FC<GraphKeyProps> = ({ current, colorSchemes, ranges, onItemSelect }) => {
|
||||||
const unsetColorScheme = () => {
|
const unsetColorScheme = () => {
|
||||||
onItemSelect(undefined);
|
onItemSelect(undefined);
|
||||||
};
|
};
|
||||||
|
@ -74,8 +107,9 @@ const GraphKey: React.FC<IGraphKeyProps> = ({ current, colorSchemes, ranges, onI
|
||||||
text={(current && current.name) || "Select..."}
|
text={(current && current.name) || "Select..."}
|
||||||
icon={IconNames.TINT}
|
icon={IconNames.TINT}
|
||||||
rightIcon={IconNames.CARET_DOWN}
|
rightIcon={IconNames.CARET_DOWN}
|
||||||
|
tabIndex={-1}
|
||||||
/>
|
/>
|
||||||
<Button icon={IconNames.SMALL_CROSS} minimal={true} onClick={unsetColorScheme} disabled={!current} />
|
<Button icon={IconNames.SMALL_CROSS} minimal onClick={unsetColorScheme} disabled={!current} tabIndex={-1} />
|
||||||
</ColorSchemeSelect>
|
</ColorSchemeSelect>
|
||||||
<br />
|
<br />
|
||||||
{!!current && !!key && (
|
{!!current && !!key && (
|
||||||
|
@ -88,38 +122,4 @@ const GraphKey: React.FC<IGraphKeyProps> = ({ current, colorSchemes, ranges, onI
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderItem: ItemRenderer<IColorScheme> = (colorScheme, { handleClick, modifiers }) => {
|
|
||||||
if (!modifiers.matchesPredicate) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return <MenuItem active={modifiers.active} key={colorScheme.name} onClick={handleClick} text={colorScheme.name} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderQualitativeKey = (values: string[]) => (
|
|
||||||
<ul className={Classes.LIST_UNSTYLED}>
|
|
||||||
{values.map(v => (
|
|
||||||
<StyledLi key={v}>
|
|
||||||
<InstanceType type={v} />
|
|
||||||
</StyledLi>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderQuantitativeKey = (range: number[]) => {
|
|
||||||
const [min, max] = range;
|
|
||||||
return (
|
|
||||||
<ColorKeyContainer>
|
|
||||||
<ColorBarContainer>
|
|
||||||
{QUANTITATIVE_COLOR_SCHEME.map((color, idx) => (
|
|
||||||
<ColorBar color={color} key={color} />
|
|
||||||
))}
|
|
||||||
</ColorBarContainer>
|
|
||||||
<TextContainer>
|
|
||||||
<span className={Classes.TEXT_SMALL}>{numeral.default(min).format("0")}</span>
|
|
||||||
<span className={Classes.TEXT_SMALL}>{numeral.default(max).format("0")}</span>
|
|
||||||
</TextContainer>
|
|
||||||
</ColorKeyContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default GraphKey;
|
export default GraphKey;
|
||||||
|
|
|
@ -2,12 +2,12 @@ import { Button } from "@blueprintjs/core";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import FloatingCard from "./FloatingCard";
|
import FloatingCard from "./FloatingCard";
|
||||||
|
|
||||||
interface IGraphResetButtonProps {
|
interface GraphResetButtonProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
const GraphResetButton: React.FC<IGraphResetButtonProps> = ({ onClick }) => (
|
const GraphResetButton: React.FC<GraphResetButtonProps> = ({ onClick }) => (
|
||||||
<FloatingCard>
|
<FloatingCard>
|
||||||
<Button icon="compass" title="Reset graph view" onClick={onClick} />
|
<Button icon="compass" title="Reset graph view" onClick={onClick} tabIndex={-1} />
|
||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
);
|
);
|
||||||
export default GraphResetButton;
|
export default GraphResetButton;
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { QUALITATIVE_COLOR_SCHEME } from "../../constants";
|
||||||
import { typeColorScheme } from "../../types";
|
import { typeColorScheme } from "../../types";
|
||||||
import { getTypeDisplayString } from "../../util";
|
import { getTypeDisplayString } from "../../util";
|
||||||
|
|
||||||
interface IInstanceTypeProps {
|
interface InstanceTypeProps {
|
||||||
type: string;
|
type: string;
|
||||||
colorAfterName?: boolean;
|
colorAfterName?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -13,9 +13,9 @@ interface IInstanceTypeProps {
|
||||||
* By default, renders the color followed by the name of the instance type.
|
* By default, renders the color followed by the name of the instance type.
|
||||||
* You can change this by passing `colorAfterName={true}`.
|
* You can change this by passing `colorAfterName={true}`.
|
||||||
*/
|
*/
|
||||||
const InstanceType: React.FC<IInstanceTypeProps> = ({ type, colorAfterName }) => {
|
const InstanceType: React.FC<InstanceTypeProps> = ({ type, colorAfterName }) => {
|
||||||
const idx = typeColorScheme.values.indexOf(type);
|
const idx = typeColorScheme.values.indexOf(type);
|
||||||
const name = " " + getTypeDisplayString(type);
|
const name = ` ${getTypeDisplayString(type)}`;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!!colorAfterName && name}
|
{!!colorAfterName && name}
|
||||||
|
|
|
@ -8,18 +8,24 @@ const Backdrop = styled.div`
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
background-color: #293742;
|
background-color: #293742;
|
||||||
z-index: 100;
|
z-index: 3;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Container = styled.div`
|
interface ContainerProps {
|
||||||
max-width: 800px;
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
const Container = styled.div<ContainerProps>`
|
||||||
|
max-width: ${(props) => (props.fullWidth ? "100%" : "800px")};
|
||||||
margin: auto;
|
margin: auto;
|
||||||
padding: 2em;
|
padding: 2em;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Page: React.FC = ({ children }) => (
|
interface PageProps {
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
const Page: React.FC<PageProps> = ({ children, fullWidth }) => (
|
||||||
<Backdrop>
|
<Backdrop>
|
||||||
<Container>{children}</Container>
|
<Container fullWidth={fullWidth}>{children}</Container>
|
||||||
</Backdrop>
|
</Backdrop>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -10,9 +10,9 @@ import {
|
||||||
QUALITATIVE_COLOR_SCHEME,
|
QUALITATIVE_COLOR_SCHEME,
|
||||||
QUANTITATIVE_COLOR_SCHEME,
|
QUANTITATIVE_COLOR_SCHEME,
|
||||||
SEARCH_RESULT_COLOR,
|
SEARCH_RESULT_COLOR,
|
||||||
SELECTED_NODE_COLOR
|
SELECTED_NODE_COLOR,
|
||||||
} from "../../constants";
|
} from "../../constants";
|
||||||
import { IColorScheme } from "../../types";
|
import { ColorScheme } from "../../types";
|
||||||
import { getBuckets, getTypeDisplayString } from "../../util";
|
import { getBuckets, getTypeDisplayString } from "../../util";
|
||||||
|
|
||||||
const CytoscapeContainer = styled.div`
|
const CytoscapeContainer = styled.div`
|
||||||
|
@ -21,8 +21,8 @@ const CytoscapeContainer = styled.div`
|
||||||
flex: 1;
|
flex: 1;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface ICytoscapeProps {
|
interface CytoscapeProps {
|
||||||
colorScheme?: IColorScheme;
|
colorScheme?: ColorScheme;
|
||||||
currentNodeId: string | null;
|
currentNodeId: string | null;
|
||||||
elements: cytoscape.ElementsDefinition;
|
elements: cytoscape.ElementsDefinition;
|
||||||
hoveringOver?: string;
|
hoveringOver?: string;
|
||||||
|
@ -32,28 +32,29 @@ interface ICytoscapeProps {
|
||||||
navigateToInstancePath?: (domain: string) => void;
|
navigateToInstancePath?: (domain: string) => void;
|
||||||
navigateToRoot?: () => void;
|
navigateToRoot?: () => void;
|
||||||
}
|
}
|
||||||
class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
class Cytoscape extends React.PureComponent<CytoscapeProps> {
|
||||||
private cy?: cytoscape.Core;
|
private cy?: cytoscape.Core;
|
||||||
|
|
||||||
public componentDidMount() {
|
public componentDidMount() {
|
||||||
|
// eslint-disable-next-line react/no-find-dom-node
|
||||||
const container = ReactDOM.findDOMNode(this);
|
const container = ReactDOM.findDOMNode(this);
|
||||||
this.cy = cytoscape({
|
this.cy = cytoscape({
|
||||||
autoungrabify: true,
|
autoungrabify: true,
|
||||||
container: container as any,
|
container: container as any,
|
||||||
elements: this.props.elements,
|
elements: this.cleanElements(this.props.elements),
|
||||||
hideEdgesOnViewport: true,
|
hideEdgesOnViewport: true,
|
||||||
hideLabelsOnViewport: true,
|
hideLabelsOnViewport: true,
|
||||||
layout: {
|
layout: {
|
||||||
name: "preset"
|
name: "preset",
|
||||||
},
|
},
|
||||||
maxZoom: 2,
|
maxZoom: 2,
|
||||||
minZoom: 0.01,
|
minZoom: 0.01,
|
||||||
pixelRatio: 1.0,
|
pixelRatio: 1.0,
|
||||||
selectionType: "single"
|
selectionType: "single",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Setup node tooltip on hover
|
// Setup node tooltip on hover
|
||||||
this.cy.nodes().forEach(n => {
|
this.cy.nodes().forEach((n) => {
|
||||||
const tooltipContent = `${n.data("id")} (${getTypeDisplayString(n.data("type"))})`;
|
const tooltipContent = `${n.data("id")} (${getTypeDisplayString(n.data("type"))})`;
|
||||||
const ref = (n as any).popperRef();
|
const ref = (n as any).popperRef();
|
||||||
const t = tippy(ref, {
|
const t = tippy(ref, {
|
||||||
|
@ -61,12 +62,12 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
||||||
animation: "fade",
|
animation: "fade",
|
||||||
content: tooltipContent,
|
content: tooltipContent,
|
||||||
duration: 100,
|
duration: 100,
|
||||||
trigger: "manual"
|
trigger: "manual",
|
||||||
});
|
});
|
||||||
n.on("mouseover", e => {
|
n.on("mouseover", () => {
|
||||||
(t as Instance).show();
|
(t as Instance).show();
|
||||||
});
|
});
|
||||||
n.on("mouseout", e => {
|
n.on("mouseout", () => {
|
||||||
(t as Instance).hide();
|
(t as Instance).hide();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -78,25 +79,25 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
||||||
.style({
|
.style({
|
||||||
"curve-style": "haystack", // fast edges
|
"curve-style": "haystack", // fast edges
|
||||||
"line-color": DEFAULT_NODE_COLOR,
|
"line-color": DEFAULT_NODE_COLOR,
|
||||||
width: "mapData(weight, 0, 0.5, 1, 20)"
|
width: "mapData(weight, 0, 0.5, 1, 20)",
|
||||||
})
|
})
|
||||||
.selector("node[label]")
|
.selector("node[label]")
|
||||||
.style({
|
.style({
|
||||||
color: DEFAULT_NODE_COLOR,
|
color: DEFAULT_NODE_COLOR,
|
||||||
"font-size": "mapData(size, 1, 6, 10, 100)",
|
"font-size": "mapData(size, 1, 6, 10, 100)",
|
||||||
"min-zoomed-font-size": 16
|
"min-zoomed-font-size": 16,
|
||||||
})
|
})
|
||||||
.selector(".hidden") // used to hide nodes not in the neighborhood of the selected, or to hide edges
|
.selector(".hidden") // used to hide nodes not in the neighborhood of the selected, or to hide edges
|
||||||
.style({
|
.style({
|
||||||
display: "none"
|
display: "none",
|
||||||
})
|
})
|
||||||
.selector(".thickEdge") // when a node is selected, make edges thicker so you can actually see them
|
.selector(".thickEdge") // when a node is selected, make edges thicker so you can actually see them
|
||||||
.style({
|
.style({
|
||||||
width: 2
|
width: 2,
|
||||||
});
|
});
|
||||||
this.resetNodeColorScheme(style); // this function also called `update()`
|
this.resetNodeColorScheme(style); // this function also called `update()`
|
||||||
|
|
||||||
this.cy.nodes().on("select", e => {
|
this.cy.nodes().on("select", (e) => {
|
||||||
const instanceId = e.target.data("id");
|
const instanceId = e.target.data("id");
|
||||||
if (instanceId && instanceId !== this.props.currentNodeId) {
|
if (instanceId && instanceId !== this.props.currentNodeId) {
|
||||||
if (this.props.navigateToInstancePath) {
|
if (this.props.navigateToInstancePath) {
|
||||||
|
@ -110,21 +111,19 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
||||||
this.cy!.nodes().removeClass("hidden");
|
this.cy!.nodes().removeClass("hidden");
|
||||||
this.cy!.edges().removeClass("thickEdge");
|
this.cy!.edges().removeClass("thickEdge");
|
||||||
// Then hide everything except neighborhood
|
// Then hide everything except neighborhood
|
||||||
this.cy!.nodes()
|
this.cy!.nodes().diff(neighborhood).left.addClass("hidden");
|
||||||
.diff(neighborhood)
|
|
||||||
.left.addClass("hidden");
|
|
||||||
neighborhood.connectedEdges().addClass("thickEdge");
|
neighborhood.connectedEdges().addClass("thickEdge");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
this.cy.nodes().on("unselect", e => {
|
this.cy.nodes().on("unselect", () => {
|
||||||
this.cy!.batch(() => {
|
this.cy!.batch(() => {
|
||||||
this.cy!.nodes().removeClass("hidden");
|
this.cy!.nodes().removeClass("hidden");
|
||||||
this.cy!.edges().removeClass("thickEdge");
|
this.cy!.edges().removeClass("thickEdge");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
this.cy.on("click", e => {
|
this.cy.on("click", (e) => {
|
||||||
// Clicking on the background should also deselect
|
// Clicking on the background should also deselect
|
||||||
const target = e.target;
|
const { target } = e;
|
||||||
if (!target || target === this.cy || target.isEdge()) {
|
if (!target || target === this.cy || target.isEdge()) {
|
||||||
if (this.props.navigateToRoot) {
|
if (this.props.navigateToRoot) {
|
||||||
// Go to the URL "/"
|
// Go to the URL "/"
|
||||||
|
@ -136,7 +135,7 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
||||||
this.setNodeSelection();
|
this.setNodeSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentDidUpdate(prevProps: ICytoscapeProps) {
|
public componentDidUpdate(prevProps: CytoscapeProps) {
|
||||||
this.setNodeSelection(prevProps.currentNodeId);
|
this.setNodeSelection(prevProps.currentNodeId);
|
||||||
if (prevProps.colorScheme !== this.props.colorScheme) {
|
if (prevProps.colorScheme !== this.props.colorScheme) {
|
||||||
this.updateColorScheme();
|
this.updateColorScheme();
|
||||||
|
@ -174,12 +173,12 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
||||||
if (currentNodeId) {
|
if (currentNodeId) {
|
||||||
this.cy.zoom({
|
this.cy.zoom({
|
||||||
level: 0.2,
|
level: 0.2,
|
||||||
position: this.cy.$id(currentNodeId).position()
|
position: this.cy.$id(currentNodeId).position(),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.cy.zoom({
|
this.cy.zoom({
|
||||||
level: 0.2,
|
level: 0.2,
|
||||||
position: { x: 0, y: 0 }
|
position: { x: 0, y: 0 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -221,7 +220,7 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
||||||
// quite good as it is, so...
|
// quite good as it is, so...
|
||||||
height: "mapData(size, 1, 6, 20, 200)",
|
height: "mapData(size, 1, 6, 20, 200)",
|
||||||
label: "data(id)",
|
label: "data(id)",
|
||||||
width: "mapData(size, 1, 6, 20, 200)"
|
width: "mapData(size, 1, 6, 20, 200)",
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setNodeSearchColorScheme(style);
|
this.setNodeSearchColorScheme(style);
|
||||||
|
@ -240,16 +239,16 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
||||||
"background-color": SEARCH_RESULT_COLOR,
|
"background-color": SEARCH_RESULT_COLOR,
|
||||||
"border-color": SEARCH_RESULT_COLOR,
|
"border-color": SEARCH_RESULT_COLOR,
|
||||||
"border-opacity": 0.7,
|
"border-opacity": 0.7,
|
||||||
"border-width": 250
|
"border-width": 250,
|
||||||
})
|
})
|
||||||
.selector("node.hovered")
|
.selector("node.hovered")
|
||||||
.style({
|
.style({
|
||||||
"border-color": HOVERED_NODE_COLOR,
|
"border-color": HOVERED_NODE_COLOR,
|
||||||
"border-width": 1000
|
"border-width": 1000,
|
||||||
})
|
})
|
||||||
.selector("node:selected")
|
.selector("node:selected")
|
||||||
.style({
|
.style({
|
||||||
"background-color": SELECTED_NODE_COLOR
|
"background-color": SELECTED_NODE_COLOR,
|
||||||
})
|
})
|
||||||
.update();
|
.update();
|
||||||
};
|
};
|
||||||
|
@ -263,10 +262,11 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
||||||
if (!colorScheme) {
|
if (!colorScheme) {
|
||||||
this.resetNodeColorScheme();
|
this.resetNodeColorScheme();
|
||||||
return;
|
return;
|
||||||
} else if (colorScheme.type === "qualitative") {
|
}
|
||||||
|
if (colorScheme.type === "qualitative") {
|
||||||
colorScheme.values.forEach((v, idx) => {
|
colorScheme.values.forEach((v, idx) => {
|
||||||
style = style.selector(`node[${colorScheme.cytoscapeDataKey} = '${v}']`).style({
|
style = style.selector(`node[${colorScheme.cytoscapeDataKey} = '${v}']`).style({
|
||||||
"background-color": QUALITATIVE_COLOR_SCHEME[idx]
|
"background-color": QUALITATIVE_COLOR_SCHEME[idx],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else if (colorScheme.type === "quantitative") {
|
} else if (colorScheme.type === "quantitative") {
|
||||||
|
@ -284,7 +284,7 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
||||||
const max = idx === QUANTITATIVE_COLOR_SCHEME.length - 1 ? maxVal + 1 : buckets[idx + 1];
|
const max = idx === QUANTITATIVE_COLOR_SCHEME.length - 1 ? maxVal + 1 : buckets[idx + 1];
|
||||||
const selector = `node[${dataKey} >= ${min}][${dataKey} < ${max}]`;
|
const selector = `node[${dataKey} >= ${min}][${dataKey} < ${max}]`;
|
||||||
style = style.selector(selector).style({
|
style = style.selector(selector).style({
|
||||||
"background-color": color
|
"background-color": color,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -304,10 +304,10 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
||||||
}
|
}
|
||||||
const { hoveringOver } = this.props;
|
const { hoveringOver } = this.props;
|
||||||
|
|
||||||
if (!!prevHoveredId) {
|
if (prevHoveredId) {
|
||||||
this.cy.$id(prevHoveredId).removeClass("hovered");
|
this.cy.$id(prevHoveredId).removeClass("hovered");
|
||||||
}
|
}
|
||||||
if (!!hoveringOver) {
|
if (hoveringOver) {
|
||||||
this.cy.$id(hoveringOver).addClass("hovered");
|
this.cy.$id(hoveringOver).addClass("hovered");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -322,7 +322,7 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
||||||
this.cy!.nodes().removeClass("searchResult");
|
this.cy!.nodes().removeClass("searchResult");
|
||||||
|
|
||||||
if (!!searchResultIds && searchResultIds.length > 0) {
|
if (!!searchResultIds && searchResultIds.length > 0) {
|
||||||
const currentResultSelector = searchResultIds.map(id => `node[id = "${id}"]`).join(", ");
|
const currentResultSelector = searchResultIds.map((id) => `node[id = "${id}"]`).join(", ");
|
||||||
this.cy!.$(currentResultSelector).addClass("searchResult");
|
this.cy!.$(currentResultSelector).addClass("searchResult");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -341,6 +341,16 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
||||||
}
|
}
|
||||||
this.cy.edges().addClass("hidden");
|
this.cy.edges().addClass("hidden");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Helper function to remove edges if source or target node is missing */
|
||||||
|
private cleanElements = (elements: cytoscape.ElementsDefinition): cytoscape.ElementsDefinition => {
|
||||||
|
const domains = new Set(elements.nodes.map((n) => n.data.id));
|
||||||
|
const edges = elements.edges.filter((e) => domains.has(e.data.source) && domains.has(e.data.target));
|
||||||
|
return {
|
||||||
|
edges,
|
||||||
|
nodes: elements.nodes,
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Cytoscape;
|
export default Cytoscape;
|
||||||
|
|
|
@ -2,11 +2,11 @@ import { NonIdealState } from "@blueprintjs/core";
|
||||||
import { IconNames } from "@blueprintjs/icons";
|
import { IconNames } from "@blueprintjs/icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
interface IErrorStateProps {
|
interface ErrorStateProps {
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
const ErrorState: React.FC<IErrorStateProps> = ({ description }) => (
|
const ErrorState: React.FC<ErrorStateProps> = ({ description }) => (
|
||||||
<NonIdealState icon={IconNames.ERROR} title={"Something went wrong."} description={description} />
|
<NonIdealState icon={IconNames.ERROR} title="Something went wrong." description={description} />
|
||||||
);
|
);
|
||||||
|
|
||||||
export default ErrorState;
|
export default ErrorState;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { IColorScheme } from "../../types";
|
import { ColorScheme } from "../../types";
|
||||||
import { GraphHideEdgesButton, GraphKey, GraphResetButton } from "../atoms";
|
import { GraphHideEdgesButton, GraphKey, GraphResetButton } from "../atoms";
|
||||||
|
|
||||||
const GraphToolsContainer = styled.div`
|
const GraphToolsContainer = styled.div`
|
||||||
|
@ -11,35 +11,33 @@ const GraphToolsContainer = styled.div`
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface IGraphToolsProps {
|
interface GraphToolsProps {
|
||||||
currentColorScheme?: IColorScheme;
|
currentColorScheme?: ColorScheme;
|
||||||
colorSchemes: IColorScheme[];
|
colorSchemes: ColorScheme[];
|
||||||
isShowingEdges: boolean;
|
isShowingEdges: boolean;
|
||||||
ranges?: { [key: string]: [number, number] };
|
ranges?: { [key: string]: [number, number] };
|
||||||
onColorSchemeSelect: (colorScheme?: IColorScheme) => void;
|
onColorSchemeSelect: (colorScheme?: ColorScheme) => void;
|
||||||
onResetButtonClick: () => void;
|
onResetButtonClick: () => void;
|
||||||
toggleEdges: () => void;
|
toggleEdges: () => void;
|
||||||
}
|
}
|
||||||
const GraphTools: React.FC<IGraphToolsProps> = ({
|
const GraphTools: React.FC<GraphToolsProps> = ({
|
||||||
currentColorScheme,
|
currentColorScheme,
|
||||||
colorSchemes,
|
colorSchemes,
|
||||||
isShowingEdges,
|
isShowingEdges,
|
||||||
ranges,
|
ranges,
|
||||||
onColorSchemeSelect,
|
onColorSchemeSelect,
|
||||||
onResetButtonClick,
|
onResetButtonClick,
|
||||||
toggleEdges
|
toggleEdges,
|
||||||
}) => {
|
}) => (
|
||||||
return (
|
<GraphToolsContainer>
|
||||||
<GraphToolsContainer>
|
<GraphResetButton onClick={onResetButtonClick} />
|
||||||
<GraphResetButton onClick={onResetButtonClick} />
|
<GraphHideEdgesButton isShowingEdges={isShowingEdges} toggleEdges={toggleEdges} />
|
||||||
<GraphHideEdgesButton isShowingEdges={isShowingEdges} toggleEdges={toggleEdges} />
|
<GraphKey
|
||||||
<GraphKey
|
current={currentColorScheme}
|
||||||
current={currentColorScheme}
|
colorSchemes={colorSchemes}
|
||||||
colorSchemes={colorSchemes}
|
onItemSelect={onColorSchemeSelect}
|
||||||
onItemSelect={onColorSchemeSelect}
|
ranges={ranges}
|
||||||
ranges={ranges}
|
/>
|
||||||
/>
|
</GraphToolsContainer>
|
||||||
</GraphToolsContainer>
|
);
|
||||||
);
|
|
||||||
};
|
|
||||||
export default GraphTools;
|
export default GraphTools;
|
||||||
|
|
|
@ -4,7 +4,7 @@ import * as numeral from "numeral";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import sanitize from "sanitize-html";
|
import sanitize from "sanitize-html";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { ISearchResultInstance } from "../../redux/types";
|
import { SearchResultInstance } from "../../redux/types";
|
||||||
import { InstanceType } from "../atoms";
|
import { InstanceType } from "../atoms";
|
||||||
|
|
||||||
const StyledCard = styled(Card)`
|
const StyledCard = styled(Card)`
|
||||||
|
@ -32,18 +32,18 @@ const StyledUserCount = styled.div`
|
||||||
const StyledDescription = styled.div`
|
const StyledDescription = styled.div`
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
`;
|
`;
|
||||||
interface ISearchResultProps {
|
interface SearchResultProps {
|
||||||
result: ISearchResultInstance;
|
result: SearchResultInstance;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
onMouseEnter: () => void;
|
onMouseEnter: () => void;
|
||||||
onMouseLeave: () => void;
|
onMouseLeave: () => void;
|
||||||
}
|
}
|
||||||
const SearchResult: React.FC<ISearchResultProps> = ({ result, onClick, onMouseEnter, onMouseLeave }) => {
|
const SearchResult: React.FC<SearchResultProps> = ({ result, onClick, onMouseEnter, onMouseLeave }) => {
|
||||||
let shortenedDescription;
|
let shortenedDescription;
|
||||||
if (result.description) {
|
if (result.description) {
|
||||||
shortenedDescription = result.description && sanitize(result.description);
|
shortenedDescription = result.description && sanitize(result.description);
|
||||||
if (shortenedDescription.length > 100) {
|
if (shortenedDescription.length > 100) {
|
||||||
shortenedDescription = shortenedDescription.substring(0, 100) + "...";
|
shortenedDescription = `${shortenedDescription.substring(0, 100)}...`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,7 +59,7 @@ const SearchResult: React.FC<ISearchResultProps> = ({ result, onClick, onMouseEn
|
||||||
return (
|
return (
|
||||||
<StyledCard
|
<StyledCard
|
||||||
elevation={Elevation.ONE}
|
elevation={Elevation.ONE}
|
||||||
interactive={true}
|
interactive
|
||||||
key={result.name}
|
key={result.name}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onMouseEnter={onMouseEnter}
|
onMouseEnter={onMouseEnter}
|
||||||
|
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { Classes, H3 } from "@blueprintjs/core";
|
||||||
|
import React from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { FederationRestrictions } from "../../redux/types";
|
||||||
|
|
||||||
|
const maybeGetList = (domains?: string[]) =>
|
||||||
|
domains && (
|
||||||
|
<ul>
|
||||||
|
{domains.sort().map((domain) => (
|
||||||
|
<li key={domain}>
|
||||||
|
<Link to={`/instance/${domain}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button">
|
||||||
|
{domain}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface FederationTabProps {
|
||||||
|
restrictions?: FederationRestrictions;
|
||||||
|
}
|
||||||
|
const FederationTab: React.FC<FederationTabProps> = ({ restrictions }) => {
|
||||||
|
if (!restrictions) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportsRemovalList = maybeGetList(restrictions.reportRemoval);
|
||||||
|
const rejectsList = maybeGetList(restrictions.reject);
|
||||||
|
const mediaRemovalsList = maybeGetList(restrictions.mediaRemoval);
|
||||||
|
const mediaNsfwsList = maybeGetList(restrictions.mediaNsfw);
|
||||||
|
const federatedTimelineRemovalsList = maybeGetList(restrictions.federatedTimelineRemoval);
|
||||||
|
const bannerRemovalsList = maybeGetList(restrictions.bannerRemoval);
|
||||||
|
const avatarRemovalsList = maybeGetList(restrictions.avatarRemoval);
|
||||||
|
const acceptedList = maybeGetList(restrictions.accept);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{rejectsList && (
|
||||||
|
<>
|
||||||
|
<H3>Blocked instances</H3>
|
||||||
|
{rejectsList}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{reportsRemovalList && (
|
||||||
|
<>
|
||||||
|
<H3>Reports ignored</H3>
|
||||||
|
{reportsRemovalList}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{mediaRemovalsList && (
|
||||||
|
<>
|
||||||
|
<H3>Media removed</H3>
|
||||||
|
{mediaRemovalsList}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{mediaNsfwsList && (
|
||||||
|
<>
|
||||||
|
<H3>Media marked as NSFW</H3>
|
||||||
|
{mediaNsfwsList}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{federatedTimelineRemovalsList && (
|
||||||
|
<>
|
||||||
|
<H3>Hidden from federated timeline</H3>
|
||||||
|
{federatedTimelineRemovalsList}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{bannerRemovalsList && (
|
||||||
|
<>
|
||||||
|
<H3>Banners removed</H3>
|
||||||
|
{bannerRemovalsList}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{avatarRemovalsList && (
|
||||||
|
<>
|
||||||
|
<H3>Avatars removed</H3>
|
||||||
|
{avatarRemovalsList}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{acceptedList && (
|
||||||
|
<>
|
||||||
|
<H3>Whitelisted</H3>
|
||||||
|
{acceptedList}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default FederationTab;
|
|
@ -6,33 +6,33 @@ import { push } from "connected-react-router";
|
||||||
import { Dispatch } from "redux";
|
import { Dispatch } from "redux";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { fetchGraph } from "../../redux/actions";
|
import { fetchGraph } from "../../redux/actions";
|
||||||
import { IAppState, IGraphResponse } from "../../redux/types";
|
import { AppState, GraphResponse } from "../../redux/types";
|
||||||
import { colorSchemes, IColorScheme } from "../../types";
|
import { colorSchemes, ColorScheme } from "../../types";
|
||||||
import { domainMatchSelector } from "../../util";
|
import { domainMatchSelector } from "../../util";
|
||||||
import { Cytoscape, ErrorState, GraphTools } from "../molecules/";
|
import { Cytoscape, ErrorState, GraphTools } from "../molecules";
|
||||||
|
|
||||||
const GraphDiv = styled.div`
|
const GraphDiv = styled.div`
|
||||||
flex: 2;
|
flex: 2;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface IGraphProps {
|
interface GraphProps {
|
||||||
currentInstanceName: string | null;
|
currentInstanceName: string | null;
|
||||||
fetchGraph: () => void;
|
fetchGraph: () => void;
|
||||||
graphResponse?: IGraphResponse;
|
graphResponse?: GraphResponse;
|
||||||
graphLoadError: boolean;
|
graphLoadError: boolean;
|
||||||
hoveringOverResult?: string;
|
hoveringOverResult?: string;
|
||||||
isLoadingGraph: boolean;
|
isLoadingGraph: boolean;
|
||||||
searchResultDomains: string[];
|
searchResultDomains: string[];
|
||||||
navigate: (path: string) => void;
|
navigate: (path: string) => void;
|
||||||
}
|
}
|
||||||
interface IGraphState {
|
interface GraphState {
|
||||||
colorScheme?: IColorScheme;
|
colorScheme?: ColorScheme;
|
||||||
isShowingEdges: boolean;
|
isShowingEdges: boolean;
|
||||||
}
|
}
|
||||||
class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
|
class GraphImpl extends React.PureComponent<GraphProps, GraphState> {
|
||||||
private cytoscapeComponent: React.RefObject<Cytoscape>;
|
private cytoscapeComponent: React.RefObject<Cytoscape>;
|
||||||
|
|
||||||
public constructor(props: IGraphProps) {
|
public constructor(props: GraphProps) {
|
||||||
super(props);
|
super(props);
|
||||||
this.cytoscapeComponent = React.createRef();
|
this.cytoscapeComponent = React.createRef();
|
||||||
this.state = { colorScheme: undefined, isShowingEdges: true };
|
this.state = { colorScheme: undefined, isShowingEdges: true };
|
||||||
|
@ -76,7 +76,7 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <GraphDiv>{content}</GraphDiv>;
|
return <GraphDiv aria-hidden>{content}</GraphDiv>;
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadGraph = () => {
|
private loadGraph = () => {
|
||||||
|
@ -95,7 +95,7 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
|
||||||
this.setState({ isShowingEdges: !this.state.isShowingEdges });
|
this.setState({ isShowingEdges: !this.state.isShowingEdges });
|
||||||
};
|
};
|
||||||
|
|
||||||
private setColorScheme = (colorScheme?: IColorScheme) => {
|
private setColorScheme = (colorScheme?: ColorScheme) => {
|
||||||
this.setState({ colorScheme });
|
this.setState({ colorScheme });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -107,23 +107,20 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
|
||||||
this.props.navigate("/");
|
this.props.navigate("/");
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const mapStateToProps = (state: IAppState) => {
|
const mapStateToProps = (state: AppState) => {
|
||||||
const match = domainMatchSelector(state);
|
const match = domainMatchSelector(state);
|
||||||
return {
|
return {
|
||||||
currentInstanceName: match && match.params.domain,
|
currentInstanceName: match && match.params.domain,
|
||||||
graphLoadError: state.data.error,
|
graphLoadError: state.data.graphLoadError,
|
||||||
graphResponse: state.data.graphResponse,
|
graphResponse: state.data.graphResponse,
|
||||||
hoveringOverResult: state.search.hoveringOverResult,
|
hoveringOverResult: state.search.hoveringOverResult,
|
||||||
isLoadingGraph: state.data.isLoadingGraph,
|
isLoadingGraph: state.data.isLoadingGraph,
|
||||||
searchResultDomains: state.search.results.map(r => r.name)
|
searchResultDomains: state.search.results.map((r) => r.name),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||||
fetchGraph: () => dispatch(fetchGraph() as any),
|
fetchGraph: () => dispatch(fetchGraph() as any),
|
||||||
navigate: (path: string) => dispatch(push(path))
|
navigate: (path: string) => dispatch(push(path)),
|
||||||
});
|
});
|
||||||
const Graph = connect(
|
const Graph = connect(mapStateToProps, mapDispatchToProps)(GraphImpl);
|
||||||
mapStateToProps,
|
|
||||||
mapDispatchToProps
|
|
||||||
)(GraphImpl);
|
|
||||||
export default Graph;
|
export default Graph;
|
||||||
|
|
|
@ -0,0 +1,232 @@
|
||||||
|
import { Button, ButtonGroup, Code, HTMLTable, Intent, NonIdealState, Spinner } from "@blueprintjs/core";
|
||||||
|
import { IconNames } from "@blueprintjs/icons";
|
||||||
|
import { push } from "connected-react-router";
|
||||||
|
import { range, sortBy, sortedUniq, zip } from "lodash";
|
||||||
|
import * as numeral from "numeral";
|
||||||
|
import React from "react";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import { Dispatch } from "redux";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { loadInstanceList } from "../../redux/actions";
|
||||||
|
import { AppState, InstanceListResponse, InstanceSort, SortField, InstanceDetails } from "../../redux/types";
|
||||||
|
import { InstanceType } from "../atoms";
|
||||||
|
import { ErrorState } from "../molecules";
|
||||||
|
|
||||||
|
const StyledTable = styled(HTMLTable)`
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
const PaginationContainer = styled.div`
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
const InstanceColumn = styled.th`
|
||||||
|
width: 15%;
|
||||||
|
`;
|
||||||
|
const ServerColumn = styled.th`
|
||||||
|
width: 20%;
|
||||||
|
`;
|
||||||
|
const VersionColumn = styled.th`
|
||||||
|
width: 20%;
|
||||||
|
`;
|
||||||
|
const UserCountColumn = styled.th`
|
||||||
|
width: 15%;
|
||||||
|
`;
|
||||||
|
const StatusCountColumn = styled.th`
|
||||||
|
width: 15%;
|
||||||
|
`;
|
||||||
|
const InsularityColumn = styled.th`
|
||||||
|
width: 15%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface InstanceTableProps {
|
||||||
|
loadError: boolean;
|
||||||
|
instancesResponse?: InstanceListResponse;
|
||||||
|
instanceListSort: InstanceSort;
|
||||||
|
isLoading: boolean;
|
||||||
|
loadInstanceList: (page?: number, sort?: InstanceSort) => void;
|
||||||
|
navigate: (path: string) => void;
|
||||||
|
}
|
||||||
|
class InstanceTable extends React.PureComponent<InstanceTableProps> {
|
||||||
|
public componentDidMount() {
|
||||||
|
const { isLoading, instancesResponse, loadError } = this.props;
|
||||||
|
if (!isLoading && !instancesResponse && !loadError) {
|
||||||
|
this.props.loadInstanceList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
const { isLoading, instancesResponse, loadError } = this.props;
|
||||||
|
if (loadError) {
|
||||||
|
return <ErrorState />;
|
||||||
|
}
|
||||||
|
if (isLoading || !instancesResponse) {
|
||||||
|
return <NonIdealState icon={<Spinner />} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { instances, pageNumber: currentPage, totalPages, totalEntries, pageSize } = instancesResponse;
|
||||||
|
const pagesToDisplay = this.getPagesToDisplay(totalPages, currentPage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StyledTable striped bordered interactive>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<InstanceColumn>
|
||||||
|
Instance
|
||||||
|
<Button
|
||||||
|
minimal
|
||||||
|
icon={this.getSortIcon("domain")}
|
||||||
|
onClick={this.sortByFactory("domain")}
|
||||||
|
intent={this.getSortIntent("domain")}
|
||||||
|
/>
|
||||||
|
</InstanceColumn>
|
||||||
|
<ServerColumn>Server type</ServerColumn>
|
||||||
|
<VersionColumn>Version</VersionColumn>
|
||||||
|
<UserCountColumn>
|
||||||
|
Users
|
||||||
|
<Button
|
||||||
|
minimal
|
||||||
|
icon={this.getSortIcon("userCount")}
|
||||||
|
onClick={this.sortByFactory("userCount")}
|
||||||
|
intent={this.getSortIntent("userCount")}
|
||||||
|
/>
|
||||||
|
</UserCountColumn>
|
||||||
|
<StatusCountColumn>
|
||||||
|
Statuses
|
||||||
|
<Button
|
||||||
|
minimal
|
||||||
|
icon={this.getSortIcon("statusCount")}
|
||||||
|
onClick={this.sortByFactory("statusCount")}
|
||||||
|
intent={this.getSortIntent("statusCount")}
|
||||||
|
/>
|
||||||
|
</StatusCountColumn>
|
||||||
|
<InsularityColumn>
|
||||||
|
Insularity
|
||||||
|
<Button
|
||||||
|
minimal
|
||||||
|
icon={this.getSortIcon("insularity")}
|
||||||
|
onClick={this.sortByFactory("insularity")}
|
||||||
|
intent={this.getSortIntent("insularity")}
|
||||||
|
/>
|
||||||
|
</InsularityColumn>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{instances.map((i: InstanceDetails) => (
|
||||||
|
<tr key={i.name} onClick={this.goToInstanceFactory(i.name)}>
|
||||||
|
<td>{i.name}</td>
|
||||||
|
<td>{i.type && <InstanceType type={i.type} />}</td>
|
||||||
|
<td>{i.version && <Code>{i.version}</Code>}</td>
|
||||||
|
<td>{i.userCount && numeral.default(i.userCount).format("0,0")}</td>
|
||||||
|
<td>{i.statusCount && numeral.default(i.statusCount).format("0,0")}</td>
|
||||||
|
<td>{i.insularity && numeral.default(i.insularity).format("0.0%")}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</StyledTable>
|
||||||
|
|
||||||
|
<PaginationContainer>
|
||||||
|
<p>
|
||||||
|
Showing {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, totalEntries)} of{" "}
|
||||||
|
{totalEntries} known active instances
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ButtonGroup>
|
||||||
|
{zip(pagesToDisplay, pagesToDisplay.slice(1)).map(([page, nextPage]) => {
|
||||||
|
if (page === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const isCurrentPage = currentPage === page;
|
||||||
|
const isEndOfSection = nextPage !== undefined && page + 1 !== nextPage && page !== totalPages;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={page}>
|
||||||
|
<Button
|
||||||
|
key={page}
|
||||||
|
onClick={this.loadPageFactory(page)}
|
||||||
|
disabled={isCurrentPage}
|
||||||
|
intent={isCurrentPage ? Intent.PRIMARY : undefined}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</Button>
|
||||||
|
{isEndOfSection && (
|
||||||
|
<Button disabled key="...">
|
||||||
|
...
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ButtonGroup>
|
||||||
|
</PaginationContainer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sortByFactory = (field: SortField) => () => {
|
||||||
|
const { instancesResponse, instanceListSort } = this.props;
|
||||||
|
|
||||||
|
const page = (instancesResponse && instancesResponse.pageNumber) || 1;
|
||||||
|
const nextSortDirection =
|
||||||
|
instanceListSort.field === field && instanceListSort.direction === "desc" ? "asc" : "desc";
|
||||||
|
|
||||||
|
this.props.loadInstanceList(page, { field, direction: nextSortDirection });
|
||||||
|
};
|
||||||
|
|
||||||
|
private loadPageFactory = (page: number) => () => {
|
||||||
|
this.props.loadInstanceList(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
private goToInstanceFactory = (domain: string) => () => {
|
||||||
|
this.props.navigate(`/instance/${domain}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
private getSortIcon = (field: SortField) => {
|
||||||
|
const { instanceListSort } = this.props;
|
||||||
|
if (instanceListSort.field !== field) {
|
||||||
|
return IconNames.SORT;
|
||||||
|
}
|
||||||
|
if (instanceListSort.direction === "asc") {
|
||||||
|
return IconNames.SORT_ASC;
|
||||||
|
}
|
||||||
|
return IconNames.SORT_DESC;
|
||||||
|
};
|
||||||
|
|
||||||
|
private getSortIntent = (field: SortField) => {
|
||||||
|
const { instanceListSort } = this.props;
|
||||||
|
if (instanceListSort.field === field) {
|
||||||
|
return Intent.PRIMARY;
|
||||||
|
}
|
||||||
|
return Intent.NONE;
|
||||||
|
};
|
||||||
|
|
||||||
|
private getPagesToDisplay = (totalPages: number, currentPage: number) => {
|
||||||
|
if (totalPages < 10) {
|
||||||
|
return range(1, totalPages + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstPages = range(1, 3);
|
||||||
|
const surroundingPages = range(Math.max(currentPage - 1, 1), Math.min(currentPage + 2, totalPages));
|
||||||
|
const lastPages = range(totalPages - 1, totalPages + 1);
|
||||||
|
|
||||||
|
const pagesToDisplay = firstPages.concat(surroundingPages).concat(lastPages);
|
||||||
|
|
||||||
|
return sortedUniq(sortBy(pagesToDisplay, (n) => n));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapStateToProps = (state: AppState) => ({
|
||||||
|
instanceListSort: state.data.instanceListSort,
|
||||||
|
instancesResponse: state.data.instancesResponse,
|
||||||
|
isLoading: state.data.isLoadingInstanceList,
|
||||||
|
loadError: state.data.instanceListLoadError,
|
||||||
|
});
|
||||||
|
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||||
|
loadInstanceList: (page?: number, sort?: InstanceSort) => dispatch(loadInstanceList(page, sort) as any),
|
||||||
|
navigate: (path: string) => dispatch(push(path)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(InstanceTable);
|
|
@ -1,21 +1,19 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { Alignment, Navbar } from "@blueprintjs/core";
|
import { Alignment, Navbar, Classes } from "@blueprintjs/core";
|
||||||
import { IconNames } from "@blueprintjs/icons";
|
import { IconNames } from "@blueprintjs/icons";
|
||||||
|
|
||||||
import { Classes } from "@blueprintjs/core";
|
|
||||||
import { match, NavLink } from "react-router-dom";
|
import { match, NavLink } from "react-router-dom";
|
||||||
import { IInstanceDomainPath } from "../../constants";
|
import { InstanceDomainPath } from "../../constants";
|
||||||
|
|
||||||
interface INavState {
|
interface NavState {
|
||||||
aboutIsOpen: boolean;
|
aboutIsOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const linkIsActive = (currMatch: match<IInstanceDomainPath>, location: Location) => {
|
const graphIsActive = (currMatch: match<InstanceDomainPath>, location: Location) =>
|
||||||
return location.pathname === "/" || location.pathname.startsWith("/instance/");
|
location.pathname === "/" || location.pathname.startsWith("/instance/");
|
||||||
};
|
|
||||||
|
|
||||||
class Nav extends React.Component<{}, INavState> {
|
class Nav extends React.Component<{}, NavState> {
|
||||||
constructor(props: any) {
|
constructor(props: any) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { aboutIsOpen: false };
|
this.state = { aboutIsOpen: false };
|
||||||
|
@ -23,37 +21,46 @@ class Nav extends React.Component<{}, INavState> {
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
return (
|
return (
|
||||||
<Navbar fixedToTop={true}>
|
<nav role="navigation">
|
||||||
<Navbar.Group align={Alignment.LEFT}>
|
<Navbar fixedToTop={true}>
|
||||||
<Navbar.Heading>fediverse.space</Navbar.Heading>
|
<Navbar.Group align={Alignment.LEFT}>
|
||||||
<Navbar.Divider />
|
<Navbar.Heading>fediverse.space</Navbar.Heading>
|
||||||
<NavLink
|
<Navbar.Divider />
|
||||||
to="/"
|
<NavLink
|
||||||
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.GLOBE_NETWORK}`}
|
to="/"
|
||||||
activeClassName={Classes.INTENT_PRIMARY}
|
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.GLOBE_NETWORK}`}
|
||||||
isActive={linkIsActive as any}
|
isActive={graphIsActive as any}
|
||||||
>
|
activeClassName="current-navbar-item"
|
||||||
Home
|
>
|
||||||
</NavLink>
|
Home
|
||||||
<NavLink
|
</NavLink>
|
||||||
to="/about"
|
<NavLink
|
||||||
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.INFO_SIGN}`}
|
to="/instances"
|
||||||
activeClassName={Classes.INTENT_PRIMARY}
|
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.TH}`}
|
||||||
exact={true}
|
activeClassName="current-navbar-item"
|
||||||
>
|
>
|
||||||
About
|
Instances
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</Navbar.Group>
|
<NavLink
|
||||||
<Navbar.Group align={Alignment.RIGHT}>
|
to="/about"
|
||||||
<NavLink
|
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.INFO_SIGN}`}
|
||||||
to="/admin"
|
activeClassName="current-navbar-item"
|
||||||
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.COG}`}
|
exact={true}
|
||||||
activeClassName={Classes.INTENT_PRIMARY}
|
>
|
||||||
>
|
About
|
||||||
Administration
|
</NavLink>
|
||||||
</NavLink>
|
</Navbar.Group>
|
||||||
</Navbar.Group>
|
<Navbar.Group align={Alignment.RIGHT}>
|
||||||
</Navbar>
|
<NavLink
|
||||||
|
to="/admin"
|
||||||
|
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.COG}`}
|
||||||
|
activeClassName="current-navbar-item"
|
||||||
|
>
|
||||||
|
Administration
|
||||||
|
</NavLink>
|
||||||
|
</Navbar.Group>
|
||||||
|
</Navbar>
|
||||||
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { IconNames } from "@blueprintjs/icons";
|
||||||
import React, { MouseEvent } from "react";
|
import React, { MouseEvent } from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { INSTANCE_TYPES } from "../../constants";
|
import { INSTANCE_TYPES } from "../../constants";
|
||||||
import { getSearchFilterDisplayValue, ISearchFilter } from "../../searchFilters";
|
import { getSearchFilterDisplayValue, SearchFilter } from "../../searchFilters";
|
||||||
import { getTypeDisplayString } from "../../util";
|
import { getTypeDisplayString } from "../../util";
|
||||||
|
|
||||||
const SearchFilterContainer = styled.div`
|
const SearchFilterContainer = styled.div`
|
||||||
|
@ -19,30 +19,30 @@ const StyledTag = styled(Tag)`
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface ISearchFiltersProps {
|
interface SearchFiltersProps {
|
||||||
selectedFilters: ISearchFilter[];
|
selectedFilters: SearchFilter[];
|
||||||
selectFilter: (filter: ISearchFilter) => void;
|
selectFilter: (filter: SearchFilter) => void;
|
||||||
deselectFilter: (e: MouseEvent<HTMLButtonElement>, props: ITagProps) => void;
|
deselectFilter: (e: MouseEvent<HTMLButtonElement>, props: ITagProps) => void;
|
||||||
}
|
}
|
||||||
const SearchFilters: React.FC<ISearchFiltersProps> = ({ selectedFilters, selectFilter, deselectFilter }) => {
|
const SearchFilters: React.FC<SearchFiltersProps> = ({ selectedFilters, selectFilter, deselectFilter }) => {
|
||||||
const hasInstanceTypeFilter = selectedFilters.some(sf => sf.field === "type");
|
const hasInstanceTypeFilter = selectedFilters.some((sf) => sf.field === "type");
|
||||||
|
|
||||||
const handleSelectInstanceType = (e: MouseEvent<HTMLElement>) => {
|
const handleSelectInstanceType = (e: MouseEvent<HTMLElement>) => {
|
||||||
const field = "type";
|
const field = "type";
|
||||||
const relation = "eq";
|
const relation = "eq";
|
||||||
const value = e.currentTarget.innerText.toLowerCase().replace(" ", "");
|
const value = e.currentTarget.innerText.toLowerCase().replace(" ", "");
|
||||||
const filter: ISearchFilter = {
|
const filter: SearchFilter = {
|
||||||
displayValue: getSearchFilterDisplayValue(field, relation, value),
|
displayValue: getSearchFilterDisplayValue(field, relation, value),
|
||||||
field,
|
field,
|
||||||
relation,
|
relation,
|
||||||
value
|
value,
|
||||||
};
|
};
|
||||||
selectFilter(filter);
|
selectFilter(filter);
|
||||||
};
|
};
|
||||||
const renderMenu = () => (
|
const renderMenu = () => (
|
||||||
<Menu>
|
<Menu>
|
||||||
<MenuItem icon={IconNames.SYMBOL_CIRCLE} text="Instance type" disabled={hasInstanceTypeFilter}>
|
<MenuItem icon={IconNames.SYMBOL_CIRCLE} text="Instance type" disabled={hasInstanceTypeFilter}>
|
||||||
{INSTANCE_TYPES.map(t => (
|
{INSTANCE_TYPES.map((t) => (
|
||||||
<MenuItem key={t} text={getTypeDisplayString(t)} onClick={handleSelectInstanceType} />
|
<MenuItem key={t} text={getTypeDisplayString(t)} onClick={handleSelectInstanceType} />
|
||||||
))}
|
))}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
@ -51,15 +51,15 @@ const SearchFilters: React.FC<ISearchFiltersProps> = ({ selectedFilters, selectF
|
||||||
return (
|
return (
|
||||||
<SearchFilterContainer>
|
<SearchFilterContainer>
|
||||||
<TagContainer>
|
<TagContainer>
|
||||||
{selectedFilters.map(filter => (
|
{selectedFilters.map((filter) => (
|
||||||
<StyledTag key={filter.displayValue} minimal={true} onRemove={deselectFilter}>
|
<StyledTag key={filter.displayValue} minimal onRemove={deselectFilter}>
|
||||||
{filter.displayValue}
|
{filter.displayValue}
|
||||||
</StyledTag>
|
</StyledTag>
|
||||||
))}
|
))}
|
||||||
</TagContainer>
|
</TagContainer>
|
||||||
<Popover autoFocus={false} content={renderMenu()} position={Position.BOTTOM}>
|
<Popover autoFocus={false} content={renderMenu()} position={Position.BOTTOM}>
|
||||||
<Button minimal={true} icon={IconNames.FILTER}>
|
<Button minimal icon={IconNames.FILTER}>
|
||||||
{"Add filter"}
|
Add filter
|
||||||
</Button>
|
</Button>
|
||||||
</Popover>
|
</Popover>
|
||||||
</SearchFilterContainer>
|
</SearchFilterContainer>
|
||||||
|
|
|
@ -17,11 +17,9 @@ const StyledCard = styled(Card)`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
`;
|
`;
|
||||||
const SidebarContainer: React.FC = ({ children }) => {
|
const SidebarContainer: React.FC = ({ children }) => (
|
||||||
return (
|
<RightDiv>
|
||||||
<RightDiv>
|
<StyledCard elevation={Elevation.TWO}>{children}</StyledCard>
|
||||||
<StyledCard elevation={Elevation.TWO}>{children}</StyledCard>
|
</RightDiv>
|
||||||
</RightDiv>
|
);
|
||||||
);
|
|
||||||
};
|
|
||||||
export default SidebarContainer;
|
export default SidebarContainer;
|
||||||
|
|
|
@ -2,3 +2,5 @@ export { default as Graph } from "./Graph";
|
||||||
export { default as Nav } from "./Nav";
|
export { default as Nav } from "./Nav";
|
||||||
export { default as SidebarContainer } from "./SidebarContainer";
|
export { default as SidebarContainer } from "./SidebarContainer";
|
||||||
export { default as SearchFilters } from "./SearchFilters";
|
export { default as SearchFilters } from "./SearchFilters";
|
||||||
|
export { default as InstanceTable } from "./InstanceTable";
|
||||||
|
export { default as FederationTab } from "./FederationTab";
|
||||||
|
|
|
@ -1,7 +1,16 @@
|
||||||
import { Classes, Code, H1, H2, H4 } from "@blueprintjs/core";
|
import { Classes, Code, H1, H2, H3 } from "@blueprintjs/core";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import nlnetLogo from "../../assets/nlnet.png";
|
import styled from "styled-components";
|
||||||
import { Page } from "../atoms/";
|
import * as nlnetLogo from "../../assets/nlnet.png";
|
||||||
|
import { Page } from "../atoms";
|
||||||
|
|
||||||
|
const SponsorContainer = styled.div`
|
||||||
|
margin-bottom: 20px;
|
||||||
|
`;
|
||||||
|
const Sponsor = styled.div`
|
||||||
|
margin: 10px 40px 10px 0;
|
||||||
|
display: inline-block;
|
||||||
|
`;
|
||||||
|
|
||||||
const AboutScreen: React.FC = () => (
|
const AboutScreen: React.FC = () => (
|
||||||
<Page>
|
<Page>
|
||||||
|
@ -16,36 +25,41 @@ const AboutScreen: React.FC = () => (
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
You can follow the project on{" "}
|
You can follow the project on{" "}
|
||||||
<a href="https://x0r.be/@fediversespace" target="_blank" rel="noopener noreferrer">
|
<a href="https://social.inex.rocks/@indexCommunity" target="_blank" rel="noopener noreferrer">
|
||||||
Mastodon
|
Mastodon
|
||||||
</a>
|
</a>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This is a fork of the original fediverse.space by{" "}
|
||||||
|
<a href="https://www.btao.org" target="_blank" rel="noopener noreferrer">
|
||||||
|
Tao Bojlén
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
<H2>FAQ</H2>
|
<H2>FAQ</H2>
|
||||||
|
|
||||||
<H4>Why can't I see details about my instance?</H4>
|
<H3>Why can't I see details about my instance?</H3>
|
||||||
<p className={Classes.RUNNING_TEXT}>
|
<p className={Classes.RUNNING_TEXT}>
|
||||||
fediverse.space only supports servers using the Mastodon API, the Misskey API, the GNU Social API, or Nodeinfo.
|
fediverse.space only supports servers using the Mastodon API, the Misskey API, the GNU Social API, or Nodeinfo.
|
||||||
Instances with 10 or fewer users won't be scraped -- it's a tool for understanding communities, not individuals.
|
Instances with 10 or fewer users won't be crawled -- it's a tool for understanding communities, not
|
||||||
|
individuals.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<H4>
|
<H3>
|
||||||
When is <Code>$OTHER_FEDIVERSE_SERVER</Code> going to be added?
|
When is <Code>$OTHER_FEDIVERSE_SERVER</Code> going to be added?
|
||||||
</H4>
|
</H3>
|
||||||
<p className={Classes.RUNNING_TEXT}>
|
<p className={Classes.RUNNING_TEXT}>
|
||||||
Check out{" "}
|
We are in the early forking-out phase, and don't provide any support yet. Check back later.
|
||||||
<a href="https://gitlab.com/taobojlen/fediverse.space/issues/24" target="_blank" rel="noopener noreferrer">
|
|
||||||
this GitLab issue
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<H4>How do I add my personal instance?</H4>
|
<H3>How do I add my personal instance?</H3>
|
||||||
<p className={Classes.RUNNING_TEXT}>Click on the Administration link in the top right to opt-in.</p>
|
<p className={Classes.RUNNING_TEXT}>Click on the Administration link in the top right to opt-in.</p>
|
||||||
|
|
||||||
<H4>How do you calculate the strength of relationships between instances?</H4>
|
<H3>How do you calculate the strength of relationships between instances?</H3>
|
||||||
<p className={Classes.RUNNING_TEXT}>
|
<p className={Classes.RUNNING_TEXT}>
|
||||||
fediverse.space looks at public statuses from within the last month on the public timeline of each instance. It
|
fediverse.space looks at public statuses from within the last month on the public timeline of each instance. It
|
||||||
calculates at the ratio of
|
calculates at the ratio of
|
||||||
|
@ -53,22 +67,35 @@ const AboutScreen: React.FC = () => (
|
||||||
to reflect that smaller instances can play a large role in a community.
|
to reflect that smaller instances can play a large role in a community.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<br />
|
<H3>Who maintains this instance?</H3>
|
||||||
<H2>Credits</H2>
|
|
||||||
|
|
||||||
<a href="https://nlnet.nl/project/fediverse_space/" target="_blank" rel="noopener noreferrer">
|
|
||||||
<img src={nlnetLogo} alt="NLnet logo" width={160} height={60} />
|
|
||||||
</a>
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<p className={Classes.RUNNING_TEXT}>
|
<p className={Classes.RUNNING_TEXT}>
|
||||||
This project is proudly supported by{" "}
|
index.community (fork domain) is an{" "}
|
||||||
<a href="https://nlnet.nl/project/fediverse_space/" target="_blank" rel="noopener noreferrer">
|
<a href="https://innereq.org" target="_blank" rel="noopener noreferrer">
|
||||||
NLnet
|
InnerEq.org
|
||||||
|
</a>{" "}
|
||||||
|
project maintained by Inex Code. You can help cover the cost of the server by becoming a{" "}
|
||||||
|
<a href="https://www.patreon.com/inexcode" target="_blank" rel="noopener noreferrer">
|
||||||
|
patron
|
||||||
|
</a>{" "}
|
||||||
|
or donating some{" "}
|
||||||
|
<a href="https://inex.rocks/#donate" target="_blank" rel="noopener noreferrer">
|
||||||
|
crypto
|
||||||
</a>
|
</a>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
<H2>Special thanks</H2>
|
||||||
|
|
||||||
|
<SponsorContainer>
|
||||||
|
<Sponsor>
|
||||||
|
<a href="https://nlnet.nl/project/fediverse_space/" target="_blank" rel="noopener noreferrer">
|
||||||
|
<img src={nlnetLogo} alt="NLnet logo" height={80} />
|
||||||
|
</a>
|
||||||
|
</Sponsor>
|
||||||
|
<br />
|
||||||
|
</SponsorContainer>
|
||||||
|
|
||||||
<p className={Classes.RUNNING_TEXT}>Inspiration for this site comes from several places:</p>
|
<p className={Classes.RUNNING_TEXT}>Inspiration for this site comes from several places:</p>
|
||||||
<ul className={Classes.LIST}>
|
<ul className={Classes.LIST}>
|
||||||
<li>
|
<li>
|
||||||
|
@ -97,7 +124,7 @@ const AboutScreen: React.FC = () => (
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
The source code for fediverse.space is available on{" "}
|
The source code for fediverse.space is available on{" "}
|
||||||
<a href="https://gitlab.com/taobojlen/fediverse.space" target="_blank" rel="noopener noreferrer">
|
<a href="https://gitlab.com/fediverse.space/fediverse.space" target="_blank" rel="noopener noreferrer">
|
||||||
GitLab
|
GitLab
|
||||||
</a>
|
</a>
|
||||||
; issues and pull requests are welcome!
|
; issues and pull requests are welcome!
|
||||||
|
|
|
@ -17,7 +17,7 @@ const ButtonContainer = styled.div`
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface IAdminSettings {
|
interface AdminSettings {
|
||||||
domain: string;
|
domain: string;
|
||||||
optIn: boolean;
|
optIn: boolean;
|
||||||
optOut: boolean;
|
optOut: boolean;
|
||||||
|
@ -25,26 +25,26 @@ interface IAdminSettings {
|
||||||
statusCount: number;
|
statusCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IAdminScreenProps {
|
interface AdminScreenProps {
|
||||||
navigate: (path: string) => void;
|
navigate: (path: string) => void;
|
||||||
}
|
}
|
||||||
interface IAdminScreenState {
|
interface AdminScreenState {
|
||||||
settings?: IAdminSettings;
|
settings?: AdminSettings;
|
||||||
isUpdating: boolean;
|
isUpdating: boolean;
|
||||||
}
|
}
|
||||||
class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenState> {
|
class AdminScreen extends React.PureComponent<AdminScreenProps, AdminScreenState> {
|
||||||
private authToken = getAuthToken();
|
private authToken = getAuthToken();
|
||||||
|
|
||||||
public constructor(props: IAdminScreenProps) {
|
public constructor(props: AdminScreenProps) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { isUpdating: false };
|
this.state = { isUpdating: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentDidMount() {
|
public componentDidMount() {
|
||||||
// Load instance settings from server
|
// Load instance settings from server
|
||||||
if (!!this.authToken) {
|
if (this.authToken) {
|
||||||
getFromApi(`admin`, this.authToken!)
|
getFromApi(`admin`, this.authToken)
|
||||||
.then(response => {
|
.then((response) => {
|
||||||
this.setState({ settings: response });
|
this.setState({ settings: response });
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
@ -52,8 +52,9 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
|
||||||
icon: IconNames.ERROR,
|
icon: IconNames.ERROR,
|
||||||
intent: Intent.DANGER,
|
intent: Intent.DANGER,
|
||||||
message: "Failed to load settings.",
|
message: "Failed to load settings.",
|
||||||
timeout: 0
|
timeout: 0,
|
||||||
});
|
});
|
||||||
|
unsetAuthToken();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -77,7 +78,7 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
|
||||||
<Switch
|
<Switch
|
||||||
id="opt-in-switch"
|
id="opt-in-switch"
|
||||||
checked={!!settings.optIn}
|
checked={!!settings.optIn}
|
||||||
large={true}
|
large
|
||||||
label="Opt in"
|
label="Opt in"
|
||||||
disabled={!!isUpdating}
|
disabled={!!isUpdating}
|
||||||
onChange={this.updateOptIn}
|
onChange={this.updateOptIn}
|
||||||
|
@ -88,7 +89,7 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
|
||||||
<Switch
|
<Switch
|
||||||
id="opt-out-switch"
|
id="opt-out-switch"
|
||||||
checked={!!settings.optOut}
|
checked={!!settings.optOut}
|
||||||
large={true}
|
large
|
||||||
label="Opt out"
|
label="Opt out"
|
||||||
disabled={!!isUpdating}
|
disabled={!!isUpdating}
|
||||||
onChange={this.updateOptOut}
|
onChange={this.updateOptOut}
|
||||||
|
@ -115,9 +116,9 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateOptIn = (e: React.FormEvent<HTMLInputElement>) => {
|
private updateOptIn = (e: React.FormEvent<HTMLInputElement>) => {
|
||||||
const settings = this.state.settings as IAdminSettings;
|
const settings = this.state.settings as AdminSettings;
|
||||||
const optIn = e.currentTarget.checked;
|
const optIn = e.currentTarget.checked;
|
||||||
let optOut = settings.optOut;
|
let { optOut } = settings;
|
||||||
if (optIn) {
|
if (optIn) {
|
||||||
optOut = false;
|
optOut = false;
|
||||||
}
|
}
|
||||||
|
@ -125,9 +126,9 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
|
||||||
};
|
};
|
||||||
|
|
||||||
private updateOptOut = (e: React.FormEvent<HTMLInputElement>) => {
|
private updateOptOut = (e: React.FormEvent<HTMLInputElement>) => {
|
||||||
const settings = this.state.settings as IAdminSettings;
|
const settings = this.state.settings as AdminSettings;
|
||||||
const optOut = e.currentTarget.checked;
|
const optOut = e.currentTarget.checked;
|
||||||
let optIn = settings.optIn;
|
let { optIn } = settings;
|
||||||
if (optOut) {
|
if (optOut) {
|
||||||
optIn = false;
|
optIn = false;
|
||||||
}
|
}
|
||||||
|
@ -139,15 +140,15 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
|
||||||
this.setState({ isUpdating: true });
|
this.setState({ isUpdating: true });
|
||||||
const body = {
|
const body = {
|
||||||
optIn: this.state.settings!.optIn,
|
optIn: this.state.settings!.optIn,
|
||||||
optOut: this.state.settings!.optOut
|
optOut: this.state.settings!.optOut,
|
||||||
};
|
};
|
||||||
postToApi(`admin`, body, this.authToken!)
|
postToApi(`admin`, body, this.authToken!)
|
||||||
.then(response => {
|
.then((response) => {
|
||||||
this.setState({ settings: response, isUpdating: false });
|
this.setState({ settings: response, isUpdating: false });
|
||||||
AppToaster.show({
|
AppToaster.show({
|
||||||
icon: IconNames.TICK,
|
icon: IconNames.TICK,
|
||||||
intent: Intent.SUCCESS,
|
intent: Intent.SUCCESS,
|
||||||
message: "Successfully updated settings."
|
message: "Successfully updated settings.",
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
@ -158,14 +159,15 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
|
||||||
|
|
||||||
private logout = () => {
|
private logout = () => {
|
||||||
unsetAuthToken();
|
unsetAuthToken();
|
||||||
|
AppToaster.show({
|
||||||
|
icon: IconNames.LOG_OUT,
|
||||||
|
message: "Logged out.",
|
||||||
|
});
|
||||||
this.props.navigate("/admin/login");
|
this.props.navigate("/admin/login");
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||||
navigate: (path: string) => dispatch(push(path))
|
navigate: (path: string) => dispatch(push(path)),
|
||||||
});
|
});
|
||||||
export default connect(
|
export default connect(undefined, mapDispatchToProps)(AdminScreen);
|
||||||
undefined,
|
|
||||||
mapDispatchToProps
|
|
||||||
)(AdminScreen);
|
|
||||||
|
|
|
@ -7,9 +7,9 @@ import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
|
||||||
import { InstanceScreen, SearchScreen } from ".";
|
import { InstanceScreen, SearchScreen } from ".";
|
||||||
import { INSTANCE_DOMAIN_PATH } from "../../constants";
|
import { INSTANCE_DOMAIN_PATH } from "../../constants";
|
||||||
import { loadInstance } from "../../redux/actions";
|
import { loadInstance } from "../../redux/actions";
|
||||||
import { IAppState } from "../../redux/types";
|
import { AppState } from "../../redux/types";
|
||||||
import { domainMatchSelector, isSmallScreen } from "../../util";
|
import { domainMatchSelector, isSmallScreen } from "../../util";
|
||||||
import { Graph, SidebarContainer } from "../organisms/";
|
import { Graph, SidebarContainer } from "../organisms";
|
||||||
|
|
||||||
const GraphContainer = styled.div`
|
const GraphContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -24,13 +24,13 @@ const FullDiv = styled.div`
|
||||||
right: 0;
|
right: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface IGraphScreenProps extends RouteComponentProps {
|
interface GraphScreenProps extends RouteComponentProps {
|
||||||
currentInstanceName: string | null;
|
currentInstanceName: string | null;
|
||||||
pathname: string;
|
pathname: string;
|
||||||
graphLoadError: boolean;
|
graphLoadError: boolean;
|
||||||
loadInstance: (domain: string | null) => void;
|
loadInstance: (domain: string | null) => void;
|
||||||
}
|
}
|
||||||
interface IGraphScreenState {
|
interface GraphScreenState {
|
||||||
hasBeenViewed: boolean;
|
hasBeenViewed: boolean;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
@ -41,8 +41,8 @@ interface IGraphScreenState {
|
||||||
* However, if it's not the first page viewed (e.g. if someone opens directly on /about) we don't want to render the
|
* However, if it's not the first page viewed (e.g. if someone opens directly on /about) we don't want to render the
|
||||||
* graph since it slows down everything else!
|
* graph since it slows down everything else!
|
||||||
*/
|
*/
|
||||||
class GraphScreenImpl extends React.Component<IGraphScreenProps, IGraphScreenState> {
|
class GraphScreenImpl extends React.Component<GraphScreenProps, GraphScreenState> {
|
||||||
public constructor(props: IGraphScreenProps) {
|
public constructor(props: GraphScreenProps) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { hasBeenViewed: false };
|
this.state = { hasBeenViewed: false };
|
||||||
}
|
}
|
||||||
|
@ -56,7 +56,7 @@ class GraphScreenImpl extends React.Component<IGraphScreenProps, IGraphScreenSta
|
||||||
this.loadCurrentInstance();
|
this.loadCurrentInstance();
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentDidUpdate(prevProps: IGraphScreenProps) {
|
public componentDidUpdate(prevProps: GraphScreenProps) {
|
||||||
this.setHasBeenViewed();
|
this.setHasBeenViewed();
|
||||||
this.loadCurrentInstance(prevProps.currentInstanceName);
|
this.loadCurrentInstance(prevProps.currentInstanceName);
|
||||||
}
|
}
|
||||||
|
@ -72,7 +72,7 @@ class GraphScreenImpl extends React.Component<IGraphScreenProps, IGraphScreenSta
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private renderRoutes = ({ location }: RouteComponentProps) => (
|
private renderRoutes = () => (
|
||||||
<FullDiv>
|
<FullDiv>
|
||||||
<GraphContainer>
|
<GraphContainer>
|
||||||
{/* Smaller screens never load the entire graph. Instead, `InstanceScreen` shows only the neighborhood. */}
|
{/* Smaller screens never load the entire graph. Instead, `InstanceScreen` shows only the neighborhood. */}
|
||||||
|
@ -80,7 +80,7 @@ class GraphScreenImpl extends React.Component<IGraphScreenProps, IGraphScreenSta
|
||||||
<SidebarContainer>
|
<SidebarContainer>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path={INSTANCE_DOMAIN_PATH} component={InstanceScreen} />
|
<Route path={INSTANCE_DOMAIN_PATH} component={InstanceScreen} />
|
||||||
<Route exact={true} path="/" component={SearchScreen} />
|
<Route exact path="/" component={SearchScreen} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</SidebarContainer>
|
</SidebarContainer>
|
||||||
</GraphContainer>
|
</GraphContainer>
|
||||||
|
@ -94,19 +94,16 @@ class GraphScreenImpl extends React.Component<IGraphScreenProps, IGraphScreenSta
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapStateToProps = (state: IAppState) => {
|
const mapStateToProps = (state: AppState) => {
|
||||||
const match = domainMatchSelector(state);
|
const match = domainMatchSelector(state);
|
||||||
return {
|
return {
|
||||||
currentInstanceName: match && match.params.domain,
|
currentInstanceName: match && match.params.domain,
|
||||||
graphLoadError: state.data.error,
|
graphLoadError: state.data.graphLoadError,
|
||||||
pathname: state.router.location.pathname
|
pathname: state.router.location.pathname,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||||
loadInstance: (domain: string | null) => dispatch(loadInstance(domain) as any)
|
loadInstance: (domain: string | null) => dispatch(loadInstance(domain) as any),
|
||||||
});
|
});
|
||||||
const GraphScreen = connect(
|
const GraphScreen = connect(mapStateToProps, mapDispatchToProps)(GraphScreenImpl);
|
||||||
mapStateToProps,
|
|
||||||
mapDispatchToProps
|
|
||||||
)(GraphScreenImpl);
|
|
||||||
export default withRouter(GraphScreen);
|
export default withRouter(GraphScreen);
|
||||||
|
|
|
@ -20,7 +20,7 @@ import {
|
||||||
Spinner,
|
Spinner,
|
||||||
Tab,
|
Tab,
|
||||||
Tabs,
|
Tabs,
|
||||||
Tooltip
|
Tooltip,
|
||||||
} from "@blueprintjs/core";
|
} from "@blueprintjs/core";
|
||||||
import { IconNames } from "@blueprintjs/icons";
|
import { IconNames } from "@blueprintjs/icons";
|
||||||
|
|
||||||
|
@ -28,10 +28,11 @@ import { push } from "connected-react-router";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { Dispatch } from "redux";
|
import { Dispatch } from "redux";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { IAppState, IGraph, IGraphResponse, IInstanceDetails } from "../../redux/types";
|
import { AppState, Graph, GraphResponse, InstanceDetails, Peer } from "../../redux/types";
|
||||||
import { domainMatchSelector, getFromApi, isSmallScreen } from "../../util";
|
import { domainMatchSelector, getFromApi, isSmallScreen } from "../../util";
|
||||||
import { InstanceType } from "../atoms";
|
import { InstanceType } from "../atoms";
|
||||||
import { Cytoscape, ErrorState } from "../molecules/";
|
import { Cytoscape, ErrorState } from "../molecules";
|
||||||
|
import { FederationTab } from "../organisms";
|
||||||
|
|
||||||
const InstanceScreenContainer = styled.div`
|
const InstanceScreenContainer = styled.div`
|
||||||
margin-bottom: auto;
|
margin-bottom: auto;
|
||||||
|
@ -67,7 +68,11 @@ const StyledCallout = styled(Callout)`
|
||||||
margin: 10px 20px;
|
margin: 10px 20px;
|
||||||
width: auto;
|
width: auto;
|
||||||
`;
|
`;
|
||||||
const StyledTabs = styled(Tabs)`
|
const NeighborsCallout = styled(Callout)`
|
||||||
|
margin: 10px 0;
|
||||||
|
width: auto;
|
||||||
|
`;
|
||||||
|
const StyledTabs = (styled as any)(Tabs)`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
`;
|
`;
|
||||||
|
@ -77,25 +82,25 @@ const StyledGraphContainer = styled.div`
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
`;
|
`;
|
||||||
interface IInstanceScreenProps {
|
interface InstanceScreenProps {
|
||||||
graph?: IGraph;
|
graph?: Graph;
|
||||||
instanceName: string | null;
|
instanceName: string | null;
|
||||||
instanceLoadError: boolean;
|
instanceLoadError: boolean;
|
||||||
instanceDetails: IInstanceDetails | null;
|
instanceDetails: InstanceDetails | null;
|
||||||
isLoadingInstanceDetails: boolean;
|
isLoadingInstanceDetails: boolean;
|
||||||
navigateToRoot: () => void;
|
navigateToRoot: () => void;
|
||||||
navigateToInstance: (domain: string) => void;
|
navigateToInstance: (domain: string) => void;
|
||||||
}
|
}
|
||||||
interface IInstanceScreenState {
|
interface InstanceScreenState {
|
||||||
neighbors?: string[];
|
neighbors?: string[];
|
||||||
isProcessingNeighbors: boolean;
|
isProcessingNeighbors: boolean;
|
||||||
// Local (neighborhood) graph. Used only on small screens (mobile devices).
|
// Local (neighborhood) graph. Used only on small screens (mobile devices).
|
||||||
isLoadingLocalGraph: boolean;
|
isLoadingLocalGraph: boolean;
|
||||||
localGraph?: IGraph;
|
localGraph?: Graph;
|
||||||
localGraphLoadError?: boolean;
|
localGraphLoadError?: boolean;
|
||||||
}
|
}
|
||||||
class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInstanceScreenState> {
|
class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, InstanceScreenState> {
|
||||||
public constructor(props: IInstanceScreenProps) {
|
public constructor(props: InstanceScreenProps) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { isProcessingNeighbors: false, isLoadingLocalGraph: false, localGraphLoadError: false };
|
this.state = { isProcessingNeighbors: false, isLoadingLocalGraph: false, localGraphLoadError: false };
|
||||||
}
|
}
|
||||||
|
@ -104,11 +109,16 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
||||||
let content;
|
let content;
|
||||||
if (this.props.isLoadingInstanceDetails || this.state.isProcessingNeighbors || this.state.isLoadingLocalGraph) {
|
if (this.props.isLoadingInstanceDetails || this.state.isProcessingNeighbors || this.state.isLoadingLocalGraph) {
|
||||||
content = this.renderLoadingState();
|
content = this.renderLoadingState();
|
||||||
} else if (this.props.instanceLoadError || this.state.localGraphLoadError || !this.props.instanceDetails) {
|
} else if (
|
||||||
|
this.props.instanceLoadError ||
|
||||||
|
this.state.localGraphLoadError ||
|
||||||
|
!this.props.instanceDetails ||
|
||||||
|
!this.props.instanceDetails.status
|
||||||
|
) {
|
||||||
content = <ErrorState />;
|
content = <ErrorState />;
|
||||||
} else if (this.props.instanceDetails.status.toLowerCase().indexOf("personal instance") > -1) {
|
} else if (this.props.instanceDetails.status.toLowerCase().includes("personal instance")) {
|
||||||
content = this.renderPersonalInstanceErrorState();
|
content = this.renderPersonalInstanceErrorState();
|
||||||
} else if (this.props.instanceDetails.status.toLowerCase().indexOf("robots.txt") > -1) {
|
} else if (this.props.instanceDetails.status.toLowerCase().includes("robots.txt")) {
|
||||||
content = this.renderRobotsTxtState();
|
content = this.renderRobotsTxtState();
|
||||||
} else if (this.props.instanceDetails.status !== "success") {
|
} else if (this.props.instanceDetails.status !== "success") {
|
||||||
content = this.renderMissingDataState();
|
content = this.renderMissingDataState();
|
||||||
|
@ -120,7 +130,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
||||||
<HeadingContainer>
|
<HeadingContainer>
|
||||||
<StyledHeadingH2>{this.props.instanceName}</StyledHeadingH2>
|
<StyledHeadingH2>{this.props.instanceName}</StyledHeadingH2>
|
||||||
<StyledHeadingTooltip content="Open link in new tab" position={Position.TOP} className={Classes.DARK}>
|
<StyledHeadingTooltip content="Open link in new tab" position={Position.TOP} className={Classes.DARK}>
|
||||||
<AnchorButton icon={IconNames.LINK} minimal={true} onClick={this.openInstanceLink} />
|
<AnchorButton icon={IconNames.LINK} minimal onClick={this.openInstanceLink} />
|
||||||
</StyledHeadingTooltip>
|
</StyledHeadingTooltip>
|
||||||
<StyledCloseButton icon={IconNames.CROSS} onClick={this.props.navigateToRoot} />
|
<StyledCloseButton icon={IconNames.CROSS} onClick={this.props.navigateToRoot} />
|
||||||
</HeadingContainer>
|
</HeadingContainer>
|
||||||
|
@ -135,7 +145,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
||||||
this.processEdgesToFindNeighbors();
|
this.processEdgesToFindNeighbors();
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentDidUpdate(prevProps: IInstanceScreenProps, prevState: IInstanceScreenState) {
|
public componentDidUpdate(prevProps: InstanceScreenProps, prevState: InstanceScreenState) {
|
||||||
const isNewInstance = prevProps.instanceName !== this.props.instanceName;
|
const isNewInstance = prevProps.instanceName !== this.props.instanceName;
|
||||||
const receivedNewEdges = !!this.props.graph && !this.state.isProcessingNeighbors && !this.state.neighbors;
|
const receivedNewEdges = !!this.props.graph && !this.state.isProcessingNeighbors && !this.state.neighbors;
|
||||||
const receivedNewLocalGraph = !!this.state.localGraph && !prevState.localGraph;
|
const receivedNewLocalGraph = !!this.state.localGraph && !prevState.localGraph;
|
||||||
|
@ -154,10 +164,13 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
||||||
}
|
}
|
||||||
this.setState({ isProcessingNeighbors: true });
|
this.setState({ isProcessingNeighbors: true });
|
||||||
|
|
||||||
const graphToUse = !!graph ? graph : localGraph;
|
const graphToUse = graph || localGraph;
|
||||||
const edges = graphToUse!.edges.filter(e => [e.data.source, e.data.target].indexOf(instanceName!) > -1);
|
if (!graphToUse) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const edges = graphToUse.edges.filter((e) => [e.data.source, e.data.target].includes(instanceName));
|
||||||
const neighbors: any[] = [];
|
const neighbors: any[] = [];
|
||||||
edges.forEach(e => {
|
edges.forEach((e) => {
|
||||||
if (e.data.source === instanceName) {
|
if (e.data.source === instanceName) {
|
||||||
neighbors.push({ neighbor: e.data.target, weight: e.data.weight });
|
neighbors.push({ neighbor: e.data.target, weight: e.data.weight });
|
||||||
} else {
|
} else {
|
||||||
|
@ -173,51 +186,58 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
||||||
}
|
}
|
||||||
this.setState({ isLoadingLocalGraph: true });
|
this.setState({ isLoadingLocalGraph: true });
|
||||||
getFromApi(`graph/${this.props.instanceName}`)
|
getFromApi(`graph/${this.props.instanceName}`)
|
||||||
.then((response: IGraphResponse) => {
|
.then((response: GraphResponse) => {
|
||||||
// We do some processing of edges here to make sure that every edge's source and target are in the neighborhood
|
// We do some processing of edges here to make sure that every edge's source and target are in the neighborhood
|
||||||
// We could (and should) be doing this in the backend, but I don't want to mess around with complex SQL
|
// We could (and should) be doing this in the backend, but I don't want to mess around with complex SQL
|
||||||
// queries.
|
// queries.
|
||||||
// TODO: think more about moving the backend to a graph database that would make this easier.
|
// TODO: think more about moving the backend to a graph database that would make this easier.
|
||||||
const graph = response.graph;
|
const { graph } = response;
|
||||||
const nodeIds = new Set(graph.nodes.map(n => n.data.id));
|
const nodeIds = new Set(graph.nodes.map((n) => n.data.id));
|
||||||
const edges = graph.edges.filter(e => nodeIds.has(e.data.source) && nodeIds.has(e.data.target));
|
const edges = graph.edges.filter((e) => nodeIds.has(e.data.source) && nodeIds.has(e.data.target));
|
||||||
this.setState({ isLoadingLocalGraph: false, localGraph: { ...graph, edges } });
|
this.setState({ isLoadingLocalGraph: false, localGraph: { ...graph, edges } });
|
||||||
})
|
})
|
||||||
.catch(() => this.setState({ isLoadingLocalGraph: false, localGraphLoadError: true }));
|
.catch(() => this.setState({ isLoadingLocalGraph: false, localGraphLoadError: true }));
|
||||||
};
|
};
|
||||||
|
|
||||||
private renderTabs = () => {
|
private renderTabs = () => {
|
||||||
|
const { instanceDetails } = this.props;
|
||||||
const hasNeighbors = this.state.neighbors && this.state.neighbors.length > 0;
|
const hasNeighbors = this.state.neighbors && this.state.neighbors.length > 0;
|
||||||
|
const federationRestrictions = instanceDetails && instanceDetails.federationRestrictions;
|
||||||
|
|
||||||
const hasLocalGraph =
|
const hasLocalGraph =
|
||||||
!!this.state.localGraph && this.state.localGraph.nodes.length > 0 && this.state.localGraph.edges.length > 0;
|
!!this.state.localGraph && this.state.localGraph.nodes.length > 0 && this.state.localGraph.edges.length > 0;
|
||||||
const insularCallout =
|
const insularCallout =
|
||||||
this.props.graph && !this.state.isProcessingNeighbors && !hasNeighbors && !hasLocalGraph ? (
|
this.props.graph && !this.state.isProcessingNeighbors && !hasNeighbors && !hasLocalGraph ? (
|
||||||
<StyledCallout icon={IconNames.INFO_SIGN} title="Insular instance">
|
<StyledCallout icon={IconNames.INFO_SIGN} title="Insular instance">
|
||||||
<p>This instance doesn't have any neighbors that we know of, so it's hidden from the graph.</p>
|
<p>This instance doesn't have any neighbors that we know of, so it's hidden from the graph.</p>
|
||||||
</StyledCallout>
|
</StyledCallout>
|
||||||
) : (
|
) : undefined;
|
||||||
undefined
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{insularCallout}
|
{insularCallout}
|
||||||
{this.maybeRenderLocalGraph()}
|
{this.maybeRenderLocalGraph()}
|
||||||
<StyledTabs>
|
<StyledTabs>
|
||||||
{this.props.instanceDetails!.description && (
|
{instanceDetails && instanceDetails.description && (
|
||||||
<Tab id="description" title="Description" panel={this.renderDescription()} />
|
<Tab id="description" title="Description" panel={this.renderDescription()} />
|
||||||
)}
|
)}
|
||||||
{this.shouldRenderStats() && <Tab id="stats" title="Details" panel={this.renderVersionAndCounts()} />}
|
{this.shouldRenderStats() && <Tab id="stats" title="Details" panel={this.renderVersionAndCounts()} />}
|
||||||
|
{federationRestrictions && Object.keys(federationRestrictions).length > 0 && (
|
||||||
|
<Tab
|
||||||
|
id="federationRestrictions"
|
||||||
|
title="Federation"
|
||||||
|
panel={<FederationTab restrictions={federationRestrictions} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Tab id="neighbors" title="Neighbors" panel={this.renderNeighbors()} />
|
<Tab id="neighbors" title="Neighbors" panel={this.renderNeighbors()} />
|
||||||
<Tab id="peers" title="Known peers" panel={this.renderPeers()} />
|
<Tab id="peers" title="Known peers" panel={this.renderPeers()} />
|
||||||
</StyledTabs>
|
</StyledTabs>
|
||||||
<StyledLinkToFdNetwork>
|
<StyledLinkToFdNetwork>
|
||||||
<AnchorButton
|
<AnchorButton
|
||||||
href={`https://fediverse.network/${this.props.instanceName}`}
|
href={`https://fedidb.org/network/instance?domain=${this.props.instanceName}`}
|
||||||
minimal={true}
|
minimal
|
||||||
rightIcon={IconNames.SHARE}
|
rightIcon={IconNames.SHARE}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
text="See more statistics at fediverse.network"
|
text="See more statistics at fedidb.org"
|
||||||
/>
|
/>
|
||||||
</StyledLinkToFdNetwork>
|
</StyledLinkToFdNetwork>
|
||||||
</>
|
</>
|
||||||
|
@ -226,18 +246,17 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
||||||
|
|
||||||
private maybeRenderLocalGraph = () => {
|
private maybeRenderLocalGraph = () => {
|
||||||
const { localGraph } = this.state;
|
const { localGraph } = this.state;
|
||||||
const hasLocalGraph =
|
const hasLocalGraph = !!localGraph && localGraph.nodes.length > 0 && localGraph.edges.length > 0;
|
||||||
!!this.state.localGraph && this.state.localGraph.nodes.length > 0 && this.state.localGraph.edges.length > 0;
|
if (!hasLocalGraph || !localGraph) {
|
||||||
if (!hasLocalGraph) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<StyledGraphContainer>
|
<StyledGraphContainer aria-hidden>
|
||||||
<Cytoscape
|
<Cytoscape
|
||||||
elements={localGraph!}
|
elements={localGraph}
|
||||||
currentNodeId={this.props.instanceName}
|
currentNodeId={this.props.instanceName}
|
||||||
navigateToInstancePath={this.props.navigateToInstance}
|
navigateToInstancePath={this.props.navigateToInstance}
|
||||||
showEdges={true}
|
showEdges
|
||||||
/>
|
/>
|
||||||
<Divider />
|
<Divider />
|
||||||
</StyledGraphContainer>
|
</StyledGraphContainer>
|
||||||
|
@ -250,7 +269,11 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
||||||
};
|
};
|
||||||
|
|
||||||
private renderDescription = () => {
|
private renderDescription = () => {
|
||||||
const description = this.props.instanceDetails!.description;
|
const { instanceDetails } = this.props;
|
||||||
|
if (!instanceDetails) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { description } = instanceDetails;
|
||||||
if (!description) {
|
if (!description) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -270,10 +293,10 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
||||||
insularity,
|
insularity,
|
||||||
type,
|
type,
|
||||||
statusesPerDay,
|
statusesPerDay,
|
||||||
statusesPerUserPerDay
|
statusesPerUserPerDay,
|
||||||
} = this.props.instanceDetails;
|
} = this.props.instanceDetails;
|
||||||
return (
|
return (
|
||||||
<StyledHTMLTable small={true} striped={true}>
|
<StyledHTMLTable small striped>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Version</td>
|
<td>Version</td>
|
||||||
|
@ -281,7 +304,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Instance type</td>
|
<td>Instance type</td>
|
||||||
<td>{(type && <InstanceType type={type} colorAfterName={true} />) || "Unknown"}</td>
|
<td>{(type && <InstanceType type={type} colorAfterName />) || "Unknown"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Users</td>
|
<td>Users</td>
|
||||||
|
@ -293,7 +316,8 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
Insularity{" "}
|
Insularity
|
||||||
|
{" "}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={
|
content={
|
||||||
<span>
|
<span>
|
||||||
|
@ -312,7 +336,8 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
Statuses / day{" "}
|
Statuses / day
|
||||||
|
{" "}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={
|
content={
|
||||||
<span>
|
<span>
|
||||||
|
@ -331,7 +356,8 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
Statuses / person / day{" "}
|
Statuses / person / day
|
||||||
|
{" "}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={
|
content={
|
||||||
<span>
|
<span>
|
||||||
|
@ -354,7 +380,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Last updated</td>
|
<td>Last updated</td>
|
||||||
<td>{moment(lastUpdated + "Z").fromNow() || "Unknown"}</td>
|
<td>{moment(`${lastUpdated}Z`).fromNow() || "Unknown"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</StyledHTMLTable>
|
</StyledHTMLTable>
|
||||||
|
@ -383,11 +409,18 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<NeighborsCallout icon={IconNames.INFO_SIGN} title="Warning">
|
||||||
|
<p>
|
||||||
|
Instances that {this.props.instanceName} has blocked may appear on this list and vice versa. This can happen
|
||||||
|
if users attempt to mention someone on an instance that has blocked them.
|
||||||
|
</p>
|
||||||
|
</NeighborsCallout>
|
||||||
<p className={Classes.TEXT_MUTED}>
|
<p className={Classes.TEXT_MUTED}>
|
||||||
The mention ratio is the average of how many times the two instances mention each other per status. A mention
|
The mention ratio is how often people on the two instances mention each other per status. A mention ratio of 1
|
||||||
ratio of 1 would mean that every single status contained a mention of a user on the other instance.
|
would mean that every single status on {this.props.instanceName} contained a mention of someone on the other
|
||||||
|
instance, and vice versa.
|
||||||
</p>
|
</p>
|
||||||
<StyledHTMLTable small={true} striped={true} interactive={false}>
|
<StyledHTMLTable small striped interactive={false}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Instance</th>
|
<th>Instance</th>
|
||||||
|
@ -401,11 +434,15 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
||||||
};
|
};
|
||||||
|
|
||||||
private renderPeers = () => {
|
private renderPeers = () => {
|
||||||
const peers = this.props.instanceDetails!.peers;
|
const { instanceDetails } = this.props;
|
||||||
|
if (!instanceDetails) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { peers } = instanceDetails;
|
||||||
if (!peers || peers.length === 0) {
|
if (!peers || peers.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const peerRows = peers.map(instance => (
|
const peerRows = peers.map((instance: Peer) => (
|
||||||
<tr key={instance.name}>
|
<tr key={instance.name}>
|
||||||
<td>
|
<td>
|
||||||
<Link to={`/instance/${instance.name}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button">
|
<Link to={`/instance/${instance.name}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button">
|
||||||
|
@ -419,7 +456,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
||||||
<p className={Classes.TEXT_MUTED}>
|
<p className={Classes.TEXT_MUTED}>
|
||||||
All the instances, past and present, that {this.props.instanceName} knows about.
|
All the instances, past and present, that {this.props.instanceName} knows about.
|
||||||
</p>
|
</p>
|
||||||
<StyledHTMLTable small={true} striped={true} interactive={false} className="fediverse-sidebar-table">
|
<StyledHTMLTable small striped interactive={false} className="fediverse-sidebar-table">
|
||||||
<tbody>{peerRows}</tbody>
|
<tbody>{peerRows}</tbody>
|
||||||
</StyledHTMLTable>
|
</StyledHTMLTable>
|
||||||
</div>
|
</div>
|
||||||
|
@ -428,71 +465,62 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
||||||
|
|
||||||
private renderLoadingState = () => <NonIdealState icon={<Spinner />} />;
|
private renderLoadingState = () => <NonIdealState icon={<Spinner />} />;
|
||||||
|
|
||||||
private renderPersonalInstanceErrorState = () => {
|
private renderPersonalInstanceErrorState = () => (
|
||||||
return (
|
<NonIdealState
|
||||||
<NonIdealState
|
icon={IconNames.BLOCKED_PERSON}
|
||||||
icon={IconNames.BLOCKED_PERSON}
|
title="No data"
|
||||||
title="No data"
|
description="This instance has fewer than 10 users. It was not crawled in order to protect their privacy, but if it's your instance you can opt in."
|
||||||
description="This instance has fewer than 10 users. It was not crawled in order to protect their privacy, but if it's your instance you can opt in."
|
action={
|
||||||
action={
|
<Link to="/admin" className={Classes.BUTTON} role="button">
|
||||||
<Link to={"/admin"} className={Classes.BUTTON} role="button">
|
Opt in
|
||||||
{"Opt in"}
|
</Link>
|
||||||
</Link>
|
}
|
||||||
}
|
/>
|
||||||
/>
|
);
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
private renderMissingDataState = () => {
|
private renderMissingDataState = () => (
|
||||||
return (
|
<>
|
||||||
<>
|
<NonIdealState
|
||||||
<NonIdealState
|
icon={IconNames.ERROR}
|
||||||
icon={IconNames.ERROR}
|
title="No data"
|
||||||
title="No data"
|
description="This instance could not be crawled. Either it was down or it's an instance type we don't support yet."
|
||||||
description="This instance could not be crawled. Either it was down or it's an instance type we don't support yet."
|
/>
|
||||||
/>
|
<span className="sidebar-hidden-instance-status" style={{ display: "none" }}>
|
||||||
<span className="sidebar-hidden-instance-status" style={{ display: "none" }}>
|
{this.props.instanceDetails && this.props.instanceDetails.status}
|
||||||
{this.props.instanceDetails && this.props.instanceDetails.status}
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
private renderRobotsTxtState = () => (
|
||||||
|
<NonIdealState
|
||||||
|
icon={
|
||||||
|
<span role="img" aria-label="robot">
|
||||||
|
🤖
|
||||||
</span>
|
</span>
|
||||||
</>
|
}
|
||||||
);
|
title="No data"
|
||||||
};
|
description="This instance was not crawled because its robots.txt did not allow us to."
|
||||||
|
/>
|
||||||
private renderRobotsTxtState = () => {
|
);
|
||||||
return (
|
|
||||||
<NonIdealState
|
|
||||||
icon={
|
|
||||||
<span role="img" aria-label="robot">
|
|
||||||
🤖
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
title="No data"
|
|
||||||
description="This instance was not crawled because its robots.txt did not allow us to."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
private openInstanceLink = () => {
|
private openInstanceLink = () => {
|
||||||
window.open("https://" + this.props.instanceName, "_blank");
|
window.open(`https://${this.props.instanceName}`, "_blank");
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapStateToProps = (state: IAppState) => {
|
const mapStateToProps = (state: AppState) => {
|
||||||
const match = domainMatchSelector(state);
|
const match = domainMatchSelector(state);
|
||||||
return {
|
return {
|
||||||
graph: state.data.graphResponse && state.data.graphResponse.graph,
|
graph: state.data.graphResponse && state.data.graphResponse.graph,
|
||||||
instanceDetails: state.currentInstance.currentInstanceDetails,
|
instanceDetails: state.currentInstance.currentInstanceDetails,
|
||||||
instanceLoadError: state.currentInstance.error,
|
instanceLoadError: state.currentInstance.error,
|
||||||
instanceName: match && match.params.domain,
|
instanceName: match && match.params.domain,
|
||||||
isLoadingInstanceDetails: state.currentInstance.isLoadingInstanceDetails
|
isLoadingInstanceDetails: state.currentInstance.isLoadingInstanceDetails,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||||
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)),
|
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)),
|
||||||
navigateToRoot: () => dispatch(push("/"))
|
navigateToRoot: () => dispatch(push("/")),
|
||||||
});
|
});
|
||||||
const InstanceScreen = connect(
|
const InstanceScreen = connect(mapStateToProps, mapDispatchToProps)(InstanceScreenImpl);
|
||||||
mapStateToProps,
|
|
||||||
mapDispatchToProps
|
|
||||||
)(InstanceScreenImpl);
|
|
||||||
export default InstanceScreen;
|
export default InstanceScreen;
|
||||||
|
|
|
@ -1,17 +1,18 @@
|
||||||
import { Button, Classes, FormGroup, H1, H4, Icon, InputGroup, Intent } from "@blueprintjs/core";
|
import { Button, Classes, FormGroup, H1, H2, Icon, InputGroup, Intent } from "@blueprintjs/core";
|
||||||
import { IconNames } from "@blueprintjs/icons";
|
import { IconNames } from "@blueprintjs/icons";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Redirect } from "react-router";
|
import { Redirect } from "react-router";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
import AppToaster from "../../toaster";
|
||||||
import { getAuthToken, getFromApi, postToApi } from "../../util";
|
import { getAuthToken, getFromApi, postToApi } from "../../util";
|
||||||
import { Page } from "../atoms";
|
import { Page } from "../atoms";
|
||||||
import { ErrorState } from "../molecules";
|
import { ErrorState } from "../molecules";
|
||||||
|
|
||||||
interface IFormContainerProps {
|
interface FormContainerProps {
|
||||||
error: boolean;
|
error: boolean;
|
||||||
}
|
}
|
||||||
const FormContainer = styled.div<IFormContainerProps>`
|
const FormContainer = styled.div<FormContainerProps>`
|
||||||
${props => (props.error ? "margin: 20px auto 0 auto;" : "margin-top: 20px;")}
|
${(props) => (props.error ? "margin: 20px auto 0 auto;" : "margin-top: 20px;")}
|
||||||
`;
|
`;
|
||||||
const LoginTypeContainer = styled.div`
|
const LoginTypeContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -30,23 +31,28 @@ const StyledIcon = styled(Icon)`
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface ILoginTypes {
|
interface LoginTypes {
|
||||||
domain: string;
|
domain: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
fediverseAccount?: string;
|
fediverseAccount?: string;
|
||||||
}
|
}
|
||||||
interface ILoginScreenState {
|
interface LoginScreenState {
|
||||||
domain: string;
|
domain: string;
|
||||||
isGettingLoginTypes: boolean;
|
isGettingLoginTypes: boolean;
|
||||||
isSendingLoginRequest: boolean;
|
isSendingLoginRequest: boolean;
|
||||||
loginTypes?: ILoginTypes;
|
loginTypes?: LoginTypes;
|
||||||
selectedLoginType?: "email" | "fediverseAccount";
|
selectedLoginType?: "email" | "fediverseAccount";
|
||||||
error: boolean;
|
error: boolean;
|
||||||
}
|
}
|
||||||
class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
|
class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
|
||||||
public constructor(props: any) {
|
public constructor(props: any) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { domain: "", error: false, isGettingLoginTypes: false, isSendingLoginRequest: false };
|
this.state = {
|
||||||
|
domain: "",
|
||||||
|
error: false,
|
||||||
|
isGettingLoginTypes: false,
|
||||||
|
isSendingLoginRequest: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
|
@ -58,13 +64,13 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
|
||||||
const { error, loginTypes, isSendingLoginRequest, selectedLoginType } = this.state;
|
const { error, loginTypes, isSendingLoginRequest, selectedLoginType } = this.state;
|
||||||
|
|
||||||
let content;
|
let content;
|
||||||
if (!!error) {
|
if (error) {
|
||||||
content = (
|
content = (
|
||||||
<ErrorState description="This could be because the instance is down. If not, please reload the page and try again." />
|
<ErrorState description="This could be because the instance is down. If not, please reload the page and try again." />
|
||||||
);
|
);
|
||||||
} else if (!!selectedLoginType && !isSendingLoginRequest) {
|
} else if (!!selectedLoginType && !isSendingLoginRequest) {
|
||||||
content = this.renderPostLogin();
|
content = this.renderPostLogin();
|
||||||
} else if (!!loginTypes) {
|
} else if (loginTypes) {
|
||||||
content = this.renderChooseLoginType();
|
content = this.renderChooseLoginType();
|
||||||
} else {
|
} else {
|
||||||
content = this.renderChooseInstance();
|
content = this.renderChooseInstance();
|
||||||
|
@ -73,11 +79,14 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<H1>Login</H1>
|
<H1>Login</H1>
|
||||||
|
<p className={Classes.RUNNING_TEXT}>You must be the instance admin to manage how fediverse.space</p>
|
||||||
<p className={Classes.RUNNING_TEXT}>
|
<p className={Classes.RUNNING_TEXT}>
|
||||||
To manage how fediverse.space interacts with your instance, you must be the instance admin.
|
It's currently only possible to administrate Mastodon and Pleroma instances. If you want to login with a
|
||||||
|
direct message, your instance must federate with social.inex.rocks and vice versa.
|
||||||
</p>
|
</p>
|
||||||
<p className={Classes.RUNNING_TEXT}>
|
<p className={Classes.RUNNING_TEXT}>
|
||||||
Note that it's currently only possible to administrate Mastodon and Pleroma instances.
|
If you run another server type, you can manually opt in or out by writing to{" "}
|
||||||
|
<a href="https://social.inex.rocks/@indexCommunity">@indexCommunity</a>.
|
||||||
</p>
|
</p>
|
||||||
<FormContainer error={this.state.error}>{content}</FormContainer>
|
<FormContainer error={this.state.error}>{content}</FormContainer>
|
||||||
</Page>
|
</Page>
|
||||||
|
@ -89,7 +98,7 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
|
||||||
const onButtonClick = () => this.getLoginTypes();
|
const onButtonClick = () => this.getLoginTypes();
|
||||||
return (
|
return (
|
||||||
<form onSubmit={this.getLoginTypes}>
|
<form onSubmit={this.getLoginTypes}>
|
||||||
<FormGroup label="Instance domain" labelFor="domain-input" disabled={isGettingLoginTypes} inline={true}>
|
<FormGroup label="Instance domain" labelFor="domain-input" disabled={isGettingLoginTypes} inline>
|
||||||
<InputGroup
|
<InputGroup
|
||||||
disabled={isGettingLoginTypes}
|
disabled={isGettingLoginTypes}
|
||||||
id="domain-input"
|
id="domain-input"
|
||||||
|
@ -98,7 +107,7 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
|
||||||
rightElement={
|
rightElement={
|
||||||
<Button
|
<Button
|
||||||
intent={Intent.PRIMARY}
|
intent={Intent.PRIMARY}
|
||||||
minimal={true}
|
minimal
|
||||||
rightIcon={IconNames.ARROW_RIGHT}
|
rightIcon={IconNames.ARROW_RIGHT}
|
||||||
title="submit"
|
title="submit"
|
||||||
loading={isGettingLoginTypes}
|
loading={isGettingLoginTypes}
|
||||||
|
@ -118,31 +127,26 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const loginWithEmail = () => this.login("email");
|
const loginWithEmail = () => this.login("email");
|
||||||
// const loginWithDm = () => this.login("fediverseAccount");
|
const loginWithDm = () => this.login("fediverseAccount");
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<H4>Choose an authentication method</H4>
|
<H2>Choose an authentication method</H2>
|
||||||
<LoginTypeContainer>
|
<LoginTypeContainer>
|
||||||
{loginTypes.email && (
|
{loginTypes.email && (
|
||||||
<LoginTypeButton
|
<LoginTypeButton large icon={IconNames.ENVELOPE} onClick={loginWithEmail} loading={!!isSendingLoginRequest}>
|
||||||
large={true}
|
|
||||||
icon={IconNames.ENVELOPE}
|
|
||||||
onClick={loginWithEmail}
|
|
||||||
loading={!!isSendingLoginRequest}
|
|
||||||
>
|
|
||||||
{`Email ${loginTypes.email}`}
|
{`Email ${loginTypes.email}`}
|
||||||
</LoginTypeButton>
|
</LoginTypeButton>
|
||||||
)}
|
)}
|
||||||
{/* {loginTypes.fediverseAccount && (
|
{loginTypes.fediverseAccount && (
|
||||||
<LoginTypeButton
|
<LoginTypeButton
|
||||||
large={true}
|
large
|
||||||
icon={IconNames.GLOBE_NETWORK}
|
icon={IconNames.GLOBE_NETWORK}
|
||||||
onClick={loginWithDm}
|
onClick={loginWithDm}
|
||||||
loading={!!isSendingLoginRequest}
|
loading={!!isSendingLoginRequest}
|
||||||
>
|
>
|
||||||
{`DM ${loginTypes.fediverseAccount}`}
|
{`DM ${loginTypes.fediverseAccount}`}
|
||||||
</LoginTypeButton>
|
</LoginTypeButton>
|
||||||
)} */}
|
)}
|
||||||
</LoginTypeContainer>
|
</LoginTypeContainer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -169,7 +173,7 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
|
||||||
};
|
};
|
||||||
|
|
||||||
private getLoginTypes = (e?: React.FormEvent<HTMLFormElement>) => {
|
private getLoginTypes = (e?: React.FormEvent<HTMLFormElement>) => {
|
||||||
if (!!e) {
|
if (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
this.setState({ isGettingLoginTypes: true });
|
this.setState({ isGettingLoginTypes: true });
|
||||||
|
@ -177,24 +181,29 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
|
||||||
if (domain.startsWith("https://")) {
|
if (domain.startsWith("https://")) {
|
||||||
domain = domain.slice(8);
|
domain = domain.slice(8);
|
||||||
}
|
}
|
||||||
getFromApi(`admin/login/${domain}`)
|
getFromApi(`admin/login/${domain.trim()}`)
|
||||||
.then(response => {
|
.then((response) => {
|
||||||
if ("error" in response || "errors" in response) {
|
if (response.error) {
|
||||||
// Go to catch() below
|
// Go to catch() below
|
||||||
throw new Error();
|
throw new Error(response.error);
|
||||||
} else {
|
} else {
|
||||||
this.setState({ loginTypes: response, isGettingLoginTypes: false });
|
this.setState({ loginTypes: response, isGettingLoginTypes: false });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((err: Error) => {
|
||||||
this.setState({ error: true });
|
AppToaster.show({
|
||||||
|
icon: IconNames.ERROR,
|
||||||
|
intent: Intent.DANGER,
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
|
this.setState({ isGettingLoginTypes: false });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
private login = (type: "email" | "fediverseAccount") => {
|
private login = (type: "email" | "fediverseAccount") => {
|
||||||
this.setState({ isSendingLoginRequest: true, selectedLoginType: type });
|
this.setState({ isSendingLoginRequest: true, selectedLoginType: type });
|
||||||
postToApi("admin/login", { domain: this.state.loginTypes!.domain, type })
|
postToApi("admin/login", { domain: this.state.loginTypes!.domain, type })
|
||||||
.then(response => {
|
.then((response) => {
|
||||||
if ("error" in response || "errors" in response) {
|
if ("error" in response || "errors" in response) {
|
||||||
// Go to catch() below
|
// Go to catch() below
|
||||||
throw new Error();
|
throw new Error();
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Button, Callout, H2, InputGroup, Intent, NonIdealState, Spinner } from "@blueprintjs/core";
|
import { Button, Callout, H1, InputGroup, Intent, NonIdealState, Spinner } from "@blueprintjs/core";
|
||||||
import { IconNames } from "@blueprintjs/icons";
|
import { IconNames } from "@blueprintjs/icons";
|
||||||
import { push } from "connected-react-router";
|
import { push } from "connected-react-router";
|
||||||
import { get, isEqual } from "lodash";
|
import { get, isEqual } from "lodash";
|
||||||
|
@ -7,20 +7,20 @@ import { connect } from "react-redux";
|
||||||
import { Dispatch } from "redux";
|
import { Dispatch } from "redux";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { setResultHover, updateSearch } from "../../redux/actions";
|
import { setResultHover, updateSearch } from "../../redux/actions";
|
||||||
import { IAppState, ISearchResultInstance } from "../../redux/types";
|
import { AppState, SearchResultInstance } from "../../redux/types";
|
||||||
import { ISearchFilter } from "../../searchFilters";
|
import { SearchFilter } from "../../searchFilters";
|
||||||
import { isSmallScreen } from "../../util";
|
import { isSmallScreen } from "../../util";
|
||||||
import { SearchResult } from "../molecules";
|
import { SearchResult } from "../molecules";
|
||||||
import { SearchFilters } from "../organisms";
|
import { SearchFilters } from "../organisms";
|
||||||
|
|
||||||
interface ISearchBarContainerProps {
|
interface SearchBarContainerProps {
|
||||||
hasSearchResults: boolean;
|
hasSearchResults: boolean;
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
}
|
}
|
||||||
const SearchBarContainer = styled.div<ISearchBarContainerProps>`
|
const SearchBarContainer = styled.div<SearchBarContainerProps>`
|
||||||
width: 80%;
|
width: 80%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: ${props => (props.hasSearchResults || props.hasError ? "0 auto" : "auto")};
|
margin: ${(props) => (props.hasSearchResults || props.hasError ? "0 auto" : "auto")};
|
||||||
align-self: center;
|
align-self: center;
|
||||||
`;
|
`;
|
||||||
const SearchResults = styled.div`
|
const SearchResults = styled.div`
|
||||||
|
@ -38,22 +38,22 @@ const CalloutContainer = styled.div`
|
||||||
text-align: left;
|
text-align: left;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface ISearchScreenProps {
|
interface SearchScreenProps {
|
||||||
error: boolean;
|
error: boolean;
|
||||||
isLoadingResults: boolean;
|
isLoadingResults: boolean;
|
||||||
query: string;
|
query: string;
|
||||||
hasMoreResults: boolean;
|
hasMoreResults: boolean;
|
||||||
results: ISearchResultInstance[];
|
results: SearchResultInstance[];
|
||||||
handleSearch: (query: string, filters: ISearchFilter[]) => void;
|
handleSearch: (query: string, filters: SearchFilter[]) => void;
|
||||||
navigateToInstance: (domain: string) => void;
|
navigateToInstance: (domain: string) => void;
|
||||||
setIsHoveringOver: (domain?: string) => void;
|
setIsHoveringOver: (domain?: string) => void;
|
||||||
}
|
}
|
||||||
interface ISearchScreenState {
|
interface SearchScreenState {
|
||||||
currentQuery: string;
|
currentQuery: string;
|
||||||
searchFilters: ISearchFilter[];
|
searchFilters: SearchFilter[];
|
||||||
}
|
}
|
||||||
class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreenState> {
|
class SearchScreen extends React.PureComponent<SearchScreenProps, SearchScreenState> {
|
||||||
public constructor(props: ISearchScreenProps) {
|
public constructor(props: SearchScreenProps) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { currentQuery: "", searchFilters: [] };
|
this.state = { currentQuery: "", searchFilters: [] };
|
||||||
}
|
}
|
||||||
|
@ -81,7 +81,7 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
|
||||||
} else if (!!results && results.length > 0) {
|
} else if (!!results && results.length > 0) {
|
||||||
content = (
|
content = (
|
||||||
<SearchResults>
|
<SearchResults>
|
||||||
{results.map(result => (
|
{results.map((result) => (
|
||||||
<SearchResult
|
<SearchResult
|
||||||
result={result}
|
result={result}
|
||||||
key={result.name}
|
key={result.name}
|
||||||
|
@ -92,7 +92,7 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
|
||||||
))}
|
))}
|
||||||
{isLoadingResults && <StyledSpinner size={Spinner.SIZE_SMALL} />}
|
{isLoadingResults && <StyledSpinner size={Spinner.SIZE_SMALL} />}
|
||||||
{!isLoadingResults && hasMoreResults && (
|
{!isLoadingResults && hasMoreResults && (
|
||||||
<Button onClick={this.search} minimal={true}>
|
<Button onClick={this.search} minimal>
|
||||||
Load more results
|
Load more results
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
@ -104,11 +104,11 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
|
||||||
if (isLoadingResults) {
|
if (isLoadingResults) {
|
||||||
rightSearchBarElement = <Spinner size={Spinner.SIZE_SMALL} />;
|
rightSearchBarElement = <Spinner size={Spinner.SIZE_SMALL} />;
|
||||||
} else if (query || error) {
|
} else if (query || error) {
|
||||||
rightSearchBarElement = <Button minimal={true} icon={IconNames.CROSS} onClick={this.clearQuery} />;
|
rightSearchBarElement = <Button minimal icon={IconNames.CROSS} onClick={this.clearQuery} aria-label="Search" />;
|
||||||
} else {
|
} else {
|
||||||
rightSearchBarElement = (
|
rightSearchBarElement = (
|
||||||
<Button
|
<Button
|
||||||
minimal={true}
|
minimal
|
||||||
icon={IconNames.ARROW_RIGHT}
|
icon={IconNames.ARROW_RIGHT}
|
||||||
intent={Intent.PRIMARY}
|
intent={Intent.PRIMARY}
|
||||||
onClick={this.search}
|
onClick={this.search}
|
||||||
|
@ -121,12 +121,13 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
|
||||||
<>
|
<>
|
||||||
{isSmallScreen && results.length === 0 && this.renderMobileWarning()}
|
{isSmallScreen && results.length === 0 && this.renderMobileWarning()}
|
||||||
<SearchBarContainer hasSearchResults={!!query && !!results} hasError={!!error}>
|
<SearchBarContainer hasSearchResults={!!query && !!results} hasError={!!error}>
|
||||||
<H2>Find an instance</H2>
|
<H1>Find an instance</H1>
|
||||||
<InputGroup
|
<InputGroup
|
||||||
leftIcon={IconNames.SEARCH}
|
leftIcon={IconNames.SEARCH}
|
||||||
rightElement={rightSearchBarElement}
|
rightElement={rightSearchBarElement}
|
||||||
large={true}
|
large
|
||||||
placeholder="Search instance names and descriptions"
|
placeholder="Search instance names and descriptions"
|
||||||
|
aria-label="Search instance names and descriptions"
|
||||||
type="search"
|
type="search"
|
||||||
value={this.state.currentQuery}
|
value={this.state.currentQuery}
|
||||||
onChange={this.handleInputChange}
|
onChange={this.handleInputChange}
|
||||||
|
@ -161,10 +162,10 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
|
||||||
this.setState({ currentQuery: "" }, () => this.props.handleSearch("", []));
|
this.setState({ currentQuery: "" }, () => this.props.handleSearch("", []));
|
||||||
};
|
};
|
||||||
|
|
||||||
private selectSearchFilter = (filter: ISearchFilter) => {
|
private selectSearchFilter = (filter: SearchFilter) => {
|
||||||
const { searchFilters } = this.state;
|
const { searchFilters } = this.state;
|
||||||
// Don't add the same filters twice
|
// Don't add the same filters twice
|
||||||
if (searchFilters.some(sf => isEqual(sf, filter))) {
|
if (searchFilters.some((sf) => isEqual(sf, filter))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.setState({ searchFilters: [...searchFilters, filter] }, this.search);
|
this.setState({ searchFilters: [...searchFilters, filter] }, this.search);
|
||||||
|
@ -173,9 +174,9 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
|
||||||
private deselectSearchFilter = (e: MouseEvent<HTMLButtonElement>) => {
|
private deselectSearchFilter = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
const { searchFilters } = this.state;
|
const { searchFilters } = this.state;
|
||||||
const displayValueToRemove = get(e, "currentTarget.parentElement.innerText", "");
|
const displayValueToRemove = get(e, "currentTarget.parentElement.innerText", "");
|
||||||
if (!!displayValueToRemove) {
|
if (displayValueToRemove) {
|
||||||
this.setState(
|
this.setState(
|
||||||
{ searchFilters: searchFilters.filter(sf => sf.displayValue !== displayValueToRemove) },
|
{ searchFilters: searchFilters.filter((sf) => sf.displayValue !== displayValueToRemove) },
|
||||||
this.search
|
this.search
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -204,19 +205,16 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapStateToProps = (state: IAppState) => ({
|
const mapStateToProps = (state: AppState) => ({
|
||||||
error: state.search.error,
|
error: state.search.error,
|
||||||
hasMoreResults: !!state.search.next,
|
hasMoreResults: !!state.search.next,
|
||||||
isLoadingResults: state.search.isLoadingResults,
|
isLoadingResults: state.search.isLoadingResults,
|
||||||
query: state.search.query,
|
query: state.search.query,
|
||||||
results: state.search.results
|
results: state.search.results,
|
||||||
});
|
});
|
||||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||||
handleSearch: (query: string, filters: ISearchFilter[]) => dispatch(updateSearch(query, filters) as any),
|
handleSearch: (query: string, filters: SearchFilter[]) => dispatch(updateSearch(query, filters) as any),
|
||||||
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)),
|
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)),
|
||||||
setIsHoveringOver: (domain?: string) => dispatch(setResultHover(domain))
|
setIsHoveringOver: (domain?: string) => dispatch(setResultHover(domain)),
|
||||||
});
|
});
|
||||||
export default connect(
|
export default connect(mapStateToProps, mapDispatchToProps)(SearchScreen);
|
||||||
mapStateToProps,
|
|
||||||
mapDispatchToProps
|
|
||||||
)(SearchScreen);
|
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { H1 } from "@blueprintjs/core";
|
||||||
|
import React from "react";
|
||||||
|
import { Page } from "../atoms";
|
||||||
|
import { InstanceTable } from "../organisms";
|
||||||
|
|
||||||
|
class TableScreen extends React.PureComponent {
|
||||||
|
public render() {
|
||||||
|
return (
|
||||||
|
<Page fullWidth>
|
||||||
|
<H1>Instances</H1>
|
||||||
|
<InstanceTable />
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TableScreen;
|
|
@ -1,20 +1,20 @@
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { Redirect } from "react-router";
|
import { Redirect } from "react-router";
|
||||||
import { IAppState } from "../../redux/types";
|
import { AppState } from "../../redux/types";
|
||||||
import { setAuthToken } from "../../util";
|
import { setAuthToken } from "../../util";
|
||||||
import { Page } from "../atoms";
|
import { Page } from "../atoms";
|
||||||
|
|
||||||
interface IVerifyLoginScreenProps {
|
interface VerifyLoginScreenProps {
|
||||||
search: string;
|
search: string;
|
||||||
}
|
}
|
||||||
const VerifyLoginScreen: React.FC<IVerifyLoginScreenProps> = ({ search }) => {
|
const VerifyLoginScreen: React.FC<VerifyLoginScreenProps> = ({ search }) => {
|
||||||
const [didSaveToken, setDidSaveToken] = useState(false);
|
const [didSaveToken, setDidSaveToken] = useState(false);
|
||||||
const token = new URLSearchParams(search).get("token");
|
const token = new URLSearchParams(search).get("token");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Save the auth token
|
// Save the auth token
|
||||||
if (!!token) {
|
if (token) {
|
||||||
setAuthToken(token);
|
setAuthToken(token);
|
||||||
setDidSaveToken(true);
|
setDidSaveToken(true);
|
||||||
}
|
}
|
||||||
|
@ -22,15 +22,14 @@ const VerifyLoginScreen: React.FC<IVerifyLoginScreenProps> = ({ search }) => {
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return <Redirect to="/admin/login" />;
|
return <Redirect to="/admin/login" />;
|
||||||
} else if (!didSaveToken) {
|
}
|
||||||
|
if (!didSaveToken) {
|
||||||
return <Page />;
|
return <Page />;
|
||||||
}
|
}
|
||||||
return <Redirect to="/admin" />;
|
return <Redirect to="/admin" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: IAppState) => {
|
const mapStateToProps = (state: AppState) => ({
|
||||||
return {
|
search: state.router.location.search,
|
||||||
search: state.router.location.search
|
});
|
||||||
};
|
|
||||||
};
|
|
||||||
export default connect(mapStateToProps)(VerifyLoginScreen);
|
export default connect(mapStateToProps)(VerifyLoginScreen);
|
||||||
|
|
|
@ -5,3 +5,4 @@ export { default as InstanceScreen } from "./InstanceScreen";
|
||||||
export { default as AdminScreen } from "./AdminScreen";
|
export { default as AdminScreen } from "./AdminScreen";
|
||||||
export { default as LoginScreen } from "./LoginScreen";
|
export { default as LoginScreen } from "./LoginScreen";
|
||||||
export { default as VerifyLoginScreen } from "./VerifyLoginScreen";
|
export { default as VerifyLoginScreen } from "./VerifyLoginScreen";
|
||||||
|
export { default as TableScreen } from "./TableScreen";
|
||||||
|
|
|
@ -20,8 +20,9 @@ export const QUALITATIVE_COLOR_SCHEME = [
|
||||||
"#AD99FF",
|
"#AD99FF",
|
||||||
"#0E5A8A",
|
"#0E5A8A",
|
||||||
"#0A6640",
|
"#0A6640",
|
||||||
|
"#AAB42F",
|
||||||
"#A66321",
|
"#A66321",
|
||||||
"#A82A2A"
|
"#A82A2A",
|
||||||
];
|
];
|
||||||
|
|
||||||
// From https://blueprintjs.com/docs/#core/colors.sequential-color-schemes
|
// From https://blueprintjs.com/docs/#core/colors.sequential-color-schemes
|
||||||
|
@ -35,11 +36,11 @@ export const QUANTITATIVE_COLOR_SCHEME = [
|
||||||
"#C15B3F",
|
"#C15B3F",
|
||||||
"#B64C2F",
|
"#B64C2F",
|
||||||
"#AA3C1F",
|
"#AA3C1F",
|
||||||
"#9E2B0E"
|
"#9E2B0E",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const INSTANCE_DOMAIN_PATH = "/instance/:domain";
|
export const INSTANCE_DOMAIN_PATH = "/instance/:domain";
|
||||||
export interface IInstanceDomainPath {
|
export interface InstanceDomainPath {
|
||||||
domain: string;
|
domain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -55,5 +56,6 @@ export const INSTANCE_TYPES = [
|
||||||
"friendica",
|
"friendica",
|
||||||
"hubzilla",
|
"hubzilla",
|
||||||
"plume",
|
"plume",
|
||||||
"wordpress"
|
"wordpress",
|
||||||
|
"smithereen",
|
||||||
];
|
];
|
||||||
|
|
|
@ -12,3 +12,7 @@ body {
|
||||||
.app-toaster {
|
.app-toaster {
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.current-navbar-item {
|
||||||
|
background-color: #293742 !important;
|
||||||
|
}
|
||||||
|
|
|
@ -25,8 +25,7 @@ FocusStyleManager.onlyShowFocusOnTabs();
|
||||||
export const history = createBrowserHistory();
|
export const history = createBrowserHistory();
|
||||||
|
|
||||||
// Initialize redux
|
// Initialize redux
|
||||||
// @ts-ignore
|
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||||
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
|
||||||
const store = createStore(
|
const store = createStore(
|
||||||
createRootReducer(history),
|
createRootReducer(history),
|
||||||
composeEnhancers(applyMiddleware(routerMiddleware(history), thunk))
|
composeEnhancers(applyMiddleware(routerMiddleware(history), thunk))
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
/// <reference types="react-scripts" />
|
// / <reference types="react-scripts" />
|
||||||
|
|
|
@ -2,139 +2,145 @@ import { isEqual } from "lodash";
|
||||||
import { Dispatch } from "redux";
|
import { Dispatch } from "redux";
|
||||||
|
|
||||||
import { push } from "connected-react-router";
|
import { push } from "connected-react-router";
|
||||||
import { ISearchFilter } from "../searchFilters";
|
import { SearchFilter } from "../searchFilters";
|
||||||
import { getFromApi } from "../util";
|
import { getFromApi } from "../util";
|
||||||
import { ActionType, IAppState, IGraph, IInstanceDetails, ISearchResponse } from "./types";
|
import { ActionType, AppState, Graph, InstanceDetails, InstanceSort, SearchResponse } from "./types";
|
||||||
|
|
||||||
// Instance details
|
// Instance details
|
||||||
const requestInstanceDetails = (instanceName: string) => {
|
const requestInstanceDetails = (instanceName: string) => ({
|
||||||
return {
|
payload: instanceName,
|
||||||
payload: instanceName,
|
type: ActionType.REQUEST_INSTANCE_DETAILS,
|
||||||
type: ActionType.REQUEST_INSTANCE_DETAILS
|
});
|
||||||
};
|
const receiveInstanceDetails = (instanceDetails: InstanceDetails) => ({
|
||||||
};
|
payload: instanceDetails,
|
||||||
const receiveInstanceDetails = (instanceDetails: IInstanceDetails) => {
|
type: ActionType.RECEIVE_INSTANCE_DETAILS,
|
||||||
return {
|
});
|
||||||
payload: instanceDetails,
|
const instanceLoadFailed = () => ({
|
||||||
type: ActionType.RECEIVE_INSTANCE_DETAILS
|
type: ActionType.INSTANCE_LOAD_ERROR,
|
||||||
};
|
});
|
||||||
};
|
const deselectInstance = () => ({
|
||||||
const instanceLoadFailed = () => {
|
type: ActionType.DESELECT_INSTANCE,
|
||||||
return {
|
});
|
||||||
type: ActionType.INSTANCE_LOAD_ERROR
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const deselectInstance = () => {
|
|
||||||
return {
|
|
||||||
type: ActionType.DESELECT_INSTANCE
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Graph
|
// Graph
|
||||||
const requestGraph = () => {
|
const requestGraph = () => ({
|
||||||
return {
|
type: ActionType.REQUEST_GRAPH,
|
||||||
type: ActionType.REQUEST_GRAPH
|
});
|
||||||
};
|
const receiveGraph = (graph: Graph) => ({
|
||||||
};
|
payload: graph,
|
||||||
const receiveGraph = (graph: IGraph) => {
|
type: ActionType.RECEIVE_GRAPH,
|
||||||
return {
|
});
|
||||||
payload: graph,
|
const graphLoadFailed = () => ({
|
||||||
type: ActionType.RECEIVE_GRAPH
|
type: ActionType.GRAPH_LOAD_ERROR,
|
||||||
};
|
});
|
||||||
};
|
|
||||||
const graphLoadFailed = () => {
|
// Instance list
|
||||||
return {
|
const requestInstanceList = (sort?: InstanceSort) => ({
|
||||||
type: ActionType.GRAPH_LOAD_ERROR
|
payload: sort,
|
||||||
};
|
type: ActionType.REQUEST_INSTANCES,
|
||||||
};
|
});
|
||||||
|
const receiveInstanceList = (instances: InstanceDetails[]) => ({
|
||||||
|
payload: instances,
|
||||||
|
type: ActionType.RECEIVE_INSTANCES,
|
||||||
|
});
|
||||||
|
const instanceListLoadFailed = () => ({
|
||||||
|
type: ActionType.INSTANCE_LIST_LOAD_ERROR,
|
||||||
|
});
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
const requestSearchResult = (query: string, filters: ISearchFilter[]) => {
|
const requestSearchResult = (query: string, filters: SearchFilter[]) => ({
|
||||||
return {
|
payload: { query, filters },
|
||||||
payload: { query, filters },
|
type: ActionType.REQUEST_SEARCH_RESULTS,
|
||||||
type: ActionType.REQUEST_SEARCH_RESULTS
|
});
|
||||||
};
|
const receiveSearchResults = (result: SearchResponse) => ({
|
||||||
};
|
payload: result,
|
||||||
const receiveSearchResults = (result: ISearchResponse) => {
|
type: ActionType.RECEIVE_SEARCH_RESULTS,
|
||||||
return {
|
});
|
||||||
payload: result,
|
const searchFailed = () => ({
|
||||||
type: ActionType.RECEIVE_SEARCH_RESULTS
|
type: ActionType.SEARCH_RESULTS_ERROR,
|
||||||
};
|
});
|
||||||
};
|
|
||||||
const searchFailed = () => {
|
|
||||||
return {
|
|
||||||
type: ActionType.SEARCH_RESULTS_ERROR
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetSearch = () => {
|
const resetSearch = () => ({
|
||||||
return {
|
type: ActionType.RESET_SEARCH,
|
||||||
type: ActionType.RESET_SEARCH
|
});
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setResultHover = (domain?: string) => {
|
export const setResultHover = (domain?: string) => ({
|
||||||
return {
|
payload: domain,
|
||||||
payload: domain,
|
type: ActionType.SET_SEARCH_RESULT_HOVER,
|
||||||
type: ActionType.SET_SEARCH_RESULT_HOVER
|
});
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Async actions: https://redux.js.org/advanced/asyncactions */
|
/** Async actions: https://redux.js.org/advanced/asyncactions */
|
||||||
|
|
||||||
export const loadInstance = (instanceName: string | null) => {
|
export const loadInstance = (instanceName: string | null) => (dispatch: Dispatch, getState: () => AppState) => {
|
||||||
return (dispatch: Dispatch, getState: () => IAppState) => {
|
if (!instanceName) {
|
||||||
if (!instanceName) {
|
dispatch(deselectInstance());
|
||||||
dispatch(deselectInstance());
|
if (getState().router.location.pathname.startsWith("/instance/")) {
|
||||||
if (getState().router.location.pathname.startsWith("/instance/")) {
|
dispatch(push("/"));
|
||||||
dispatch(push("/"));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
dispatch(requestInstanceDetails(instanceName));
|
return;
|
||||||
return getFromApi("instances/" + instanceName)
|
}
|
||||||
.then(details => dispatch(receiveInstanceDetails(details)))
|
dispatch(requestInstanceDetails(instanceName));
|
||||||
.catch(() => dispatch(instanceLoadFailed()));
|
return getFromApi(`instances/${instanceName}`)
|
||||||
};
|
.then((details) => dispatch(receiveInstanceDetails(details)))
|
||||||
|
.catch(() => dispatch(instanceLoadFailed()));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateSearch = (query: string, filters: ISearchFilter[]) => {
|
export const updateSearch = (query: string, filters: SearchFilter[]) => (
|
||||||
return (dispatch: Dispatch, getState: () => IAppState) => {
|
dispatch: Dispatch,
|
||||||
query = query.trim();
|
getState: () => AppState
|
||||||
|
) => {
|
||||||
|
query = query.trim();
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
dispatch(resetSearch());
|
dispatch(resetSearch());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevQuery = getState().search.query;
|
const prevQuery = getState().search.query;
|
||||||
const prevFilters = getState().search.filters;
|
const prevFilters = getState().search.filters;
|
||||||
const isNewQuery = prevQuery !== query || !isEqual(prevFilters, filters);
|
const isNewQuery = prevQuery !== query || !isEqual(prevFilters, filters);
|
||||||
|
|
||||||
const next = getState().search.next;
|
const { next } = getState().search;
|
||||||
let url = `search/?query=${query}`;
|
let url = `search/?query=${query}`;
|
||||||
if (!isNewQuery && next) {
|
if (!isNewQuery && next) {
|
||||||
url += `&after=${next}`;
|
url += `&after=${next}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add filters
|
// Add filters
|
||||||
// The format is e.g. type_eq=mastodon or user_count_gt=1000
|
// The format is e.g. type_eq=mastodon or user_count_gt=1000
|
||||||
filters.forEach(filter => {
|
filters.forEach((filter) => {
|
||||||
url += `&${filter.field}_${filter.relation}=${filter.value}`;
|
url += `&${filter.field}_${filter.relation}=${filter.value}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
dispatch(requestSearchResult(query, filters));
|
dispatch(requestSearchResult(query, filters));
|
||||||
return getFromApi(url)
|
return getFromApi(url)
|
||||||
.then(result => dispatch(receiveSearchResults(result)))
|
.then((result) => dispatch(receiveSearchResults(result)))
|
||||||
.catch(() => dispatch(searchFailed()));
|
.catch(() => dispatch(searchFailed()));
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchGraph = () => {
|
export const fetchGraph = () => (dispatch: Dispatch) => {
|
||||||
return (dispatch: Dispatch) => {
|
dispatch(requestGraph());
|
||||||
dispatch(requestGraph());
|
return getFromApi("graph")
|
||||||
return getFromApi("graph")
|
.then((graph) => dispatch(receiveGraph(graph)))
|
||||||
.then(graph => dispatch(receiveGraph(graph)))
|
.catch(() => dispatch(graphLoadFailed()));
|
||||||
.catch(() => dispatch(graphLoadFailed()));
|
};
|
||||||
};
|
|
||||||
|
export const loadInstanceList = (page?: number, sort?: InstanceSort) => (
|
||||||
|
dispatch: Dispatch,
|
||||||
|
getState: () => AppState
|
||||||
|
) => {
|
||||||
|
sort = sort || getState().data.instanceListSort;
|
||||||
|
dispatch(requestInstanceList(sort));
|
||||||
|
const params: string[] = [];
|
||||||
|
if (page) {
|
||||||
|
params.push(`page=${page}`);
|
||||||
|
}
|
||||||
|
if (sort) {
|
||||||
|
params.push(`sortField=${sort.field}`);
|
||||||
|
params.push(`sortDirection=${sort.direction}`);
|
||||||
|
}
|
||||||
|
const path = params ? `instances?${params.join("&")}` : "instances";
|
||||||
|
return getFromApi(path)
|
||||||
|
.then((instancesListResponse) => dispatch(receiveInstanceList(instancesListResponse)))
|
||||||
|
.catch(() => dispatch(instanceListLoadFailed()));
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,83 +3,106 @@ import { isEqual } from "lodash";
|
||||||
import { combineReducers } from "redux";
|
import { combineReducers } from "redux";
|
||||||
|
|
||||||
import { History } from "history";
|
import { History } from "history";
|
||||||
import { ActionType, IAction, ICurrentInstanceState, IDataState, ISearchState } from "./types";
|
import { ActionType, Action, CurrentInstanceState, DataState, SearchState } from "./types";
|
||||||
|
|
||||||
const initialDataState = {
|
const initialDataState: DataState = {
|
||||||
error: false,
|
graphLoadError: false,
|
||||||
isLoadingGraph: false
|
instanceListLoadError: false,
|
||||||
|
instanceListSort: { field: "userCount", direction: "desc" },
|
||||||
|
isLoadingGraph: false,
|
||||||
|
isLoadingInstanceList: false,
|
||||||
};
|
};
|
||||||
const data = (state: IDataState = initialDataState, action: IAction): IDataState => {
|
const data = (state: DataState = initialDataState, action: Action): DataState => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case ActionType.REQUEST_GRAPH:
|
case ActionType.REQUEST_GRAPH:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
graphResponse: undefined,
|
graphResponse: undefined,
|
||||||
isLoadingGraph: true
|
isLoadingGraph: true,
|
||||||
};
|
};
|
||||||
case ActionType.RECEIVE_GRAPH:
|
case ActionType.RECEIVE_GRAPH:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
graphResponse: action.payload,
|
graphResponse: action.payload,
|
||||||
isLoadingGraph: false
|
isLoadingGraph: false,
|
||||||
};
|
};
|
||||||
case ActionType.GRAPH_LOAD_ERROR:
|
case ActionType.GRAPH_LOAD_ERROR:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
error: true,
|
graphLoadError: true,
|
||||||
isLoadingGraph: false
|
isLoadingGraph: false,
|
||||||
|
};
|
||||||
|
case ActionType.REQUEST_INSTANCES:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
instanceListLoadError: false,
|
||||||
|
instanceListSort: action.payload,
|
||||||
|
instancesResponse: undefined,
|
||||||
|
isLoadingInstanceList: true,
|
||||||
|
};
|
||||||
|
case ActionType.RECEIVE_INSTANCES:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
instancesResponse: action.payload,
|
||||||
|
isLoadingInstanceList: false,
|
||||||
|
};
|
||||||
|
case ActionType.INSTANCE_LIST_LOAD_ERROR:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
instanceListLoadError: true,
|
||||||
|
isLoadingInstanceList: false,
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialCurrentInstanceState: ICurrentInstanceState = {
|
const initialCurrentInstanceState: CurrentInstanceState = {
|
||||||
currentInstanceDetails: null,
|
currentInstanceDetails: null,
|
||||||
error: false,
|
error: false,
|
||||||
isLoadingInstanceDetails: false
|
isLoadingInstanceDetails: false,
|
||||||
};
|
};
|
||||||
const currentInstance = (state = initialCurrentInstanceState, action: IAction): ICurrentInstanceState => {
|
const currentInstance = (state = initialCurrentInstanceState, action: Action): CurrentInstanceState => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case ActionType.REQUEST_INSTANCE_DETAILS:
|
case ActionType.REQUEST_INSTANCE_DETAILS:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
error: false,
|
error: false,
|
||||||
isLoadingInstanceDetails: true
|
isLoadingInstanceDetails: true,
|
||||||
};
|
};
|
||||||
case ActionType.RECEIVE_INSTANCE_DETAILS:
|
case ActionType.RECEIVE_INSTANCE_DETAILS:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
currentInstanceDetails: action.payload,
|
currentInstanceDetails: action.payload,
|
||||||
error: false,
|
error: false,
|
||||||
isLoadingInstanceDetails: false
|
isLoadingInstanceDetails: false,
|
||||||
};
|
};
|
||||||
case ActionType.DESELECT_INSTANCE:
|
case ActionType.DESELECT_INSTANCE:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
currentInstanceDetails: null,
|
currentInstanceDetails: null,
|
||||||
error: false
|
error: false,
|
||||||
};
|
};
|
||||||
case ActionType.INSTANCE_LOAD_ERROR:
|
case ActionType.INSTANCE_LOAD_ERROR:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
error: true,
|
error: true,
|
||||||
isLoadingInstanceDetails: false
|
isLoadingInstanceDetails: false,
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialSearchState: ISearchState = {
|
const initialSearchState: SearchState = {
|
||||||
error: false,
|
error: false,
|
||||||
filters: [],
|
filters: [],
|
||||||
isLoadingResults: false,
|
isLoadingResults: false,
|
||||||
next: "",
|
next: "",
|
||||||
query: "",
|
query: "",
|
||||||
results: []
|
results: [],
|
||||||
};
|
};
|
||||||
const search = (state = initialSearchState, action: IAction): ISearchState => {
|
const search = (state = initialSearchState, action: Action): SearchState => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case ActionType.REQUEST_SEARCH_RESULTS:
|
case ActionType.REQUEST_SEARCH_RESULTS:
|
||||||
const { query, filters } = action.payload;
|
const { query, filters } = action.payload;
|
||||||
|
@ -91,7 +114,7 @@ const search = (state = initialSearchState, action: IAction): ISearchState => {
|
||||||
isLoadingResults: true,
|
isLoadingResults: true,
|
||||||
next: isNewQuery ? "" : state.next,
|
next: isNewQuery ? "" : state.next,
|
||||||
query,
|
query,
|
||||||
results: isNewQuery ? [] : state.results
|
results: isNewQuery ? [] : state.results,
|
||||||
};
|
};
|
||||||
case ActionType.RECEIVE_SEARCH_RESULTS:
|
case ActionType.RECEIVE_SEARCH_RESULTS:
|
||||||
return {
|
return {
|
||||||
|
@ -99,7 +122,7 @@ const search = (state = initialSearchState, action: IAction): ISearchState => {
|
||||||
error: false,
|
error: false,
|
||||||
isLoadingResults: false,
|
isLoadingResults: false,
|
||||||
next: action.payload.next,
|
next: action.payload.next,
|
||||||
results: state.results.concat(action.payload.results)
|
results: state.results.concat(action.payload.results),
|
||||||
};
|
};
|
||||||
case ActionType.SEARCH_RESULTS_ERROR:
|
case ActionType.SEARCH_RESULTS_ERROR:
|
||||||
return { ...initialSearchState, error: true };
|
return { ...initialSearchState, error: true };
|
||||||
|
@ -108,7 +131,7 @@ const search = (state = initialSearchState, action: IAction): ISearchState => {
|
||||||
case ActionType.SET_SEARCH_RESULT_HOVER:
|
case ActionType.SET_SEARCH_RESULT_HOVER:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
hoveringOverResult: action.payload
|
hoveringOverResult: action.payload,
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
|
@ -118,8 +141,7 @@ const search = (state = initialSearchState, action: IAction): ISearchState => {
|
||||||
export default (history: History) =>
|
export default (history: History) =>
|
||||||
combineReducers({
|
combineReducers({
|
||||||
router: connectRouter(history),
|
router: connectRouter(history),
|
||||||
// tslint:disable-next-line:object-literal-sort-keys
|
|
||||||
currentInstance,
|
currentInstance,
|
||||||
data,
|
data,
|
||||||
search
|
search,
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { RouterState } from "connected-react-router";
|
import { RouterState } from "connected-react-router";
|
||||||
import { ISearchFilter } from "../searchFilters";
|
import { SearchFilter } from "../searchFilters";
|
||||||
|
|
||||||
export enum ActionType {
|
export enum ActionType {
|
||||||
// Instance details
|
// Instance details
|
||||||
|
@ -10,6 +10,10 @@ export enum ActionType {
|
||||||
REQUEST_GRAPH = "REQUEST_GRAPH",
|
REQUEST_GRAPH = "REQUEST_GRAPH",
|
||||||
RECEIVE_GRAPH = "RECEIVE_GRAPH",
|
RECEIVE_GRAPH = "RECEIVE_GRAPH",
|
||||||
GRAPH_LOAD_ERROR = "GRAPH_LOAD_ERROR",
|
GRAPH_LOAD_ERROR = "GRAPH_LOAD_ERROR",
|
||||||
|
// Instance list
|
||||||
|
REQUEST_INSTANCES = "REQUEST_INSTANCES",
|
||||||
|
RECEIVE_INSTANCES = "RECEIVE_INSTANCES",
|
||||||
|
INSTANCE_LIST_LOAD_ERROR = "INSTANCE_LIST_LOAD_ERROR",
|
||||||
// Nav
|
// Nav
|
||||||
DESELECT_INSTANCE = "DESELECT_INSTANCE",
|
DESELECT_INSTANCE = "DESELECT_INSTANCE",
|
||||||
// Search
|
// Search
|
||||||
|
@ -18,26 +22,44 @@ export enum ActionType {
|
||||||
SEARCH_RESULTS_ERROR = "SEARCH_RESULTS_ERROR",
|
SEARCH_RESULTS_ERROR = "SEARCH_RESULTS_ERROR",
|
||||||
RESET_SEARCH = "RESET_SEARCH",
|
RESET_SEARCH = "RESET_SEARCH",
|
||||||
// Search -- hovering over results
|
// Search -- hovering over results
|
||||||
SET_SEARCH_RESULT_HOVER = "SET_SEARCH_RESULT_HOVER"
|
SET_SEARCH_RESULT_HOVER = "SET_SEARCH_RESULT_HOVER",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAction {
|
export interface Action {
|
||||||
type: ActionType;
|
type: ActionType;
|
||||||
payload: any;
|
payload: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IInstance {
|
export type SortField = "domain" | "userCount" | "statusCount" | "insularity";
|
||||||
|
export type SortDirection = "asc" | "desc";
|
||||||
|
export interface InstanceSort {
|
||||||
|
field: SortField;
|
||||||
|
direction: SortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Peer {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISearchResultInstance {
|
export interface SearchResultInstance {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
userCount?: number;
|
userCount?: number;
|
||||||
type?: string;
|
type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IInstanceDetails {
|
export interface FederationRestrictions {
|
||||||
|
reportRemoval?: string[];
|
||||||
|
reject?: string[];
|
||||||
|
mediaRemoval?: string[];
|
||||||
|
mediaNsfw?: string[];
|
||||||
|
federatedTimelineRemoval?: string[];
|
||||||
|
bannerRemoval?: string[];
|
||||||
|
avatarRemoval?: string[];
|
||||||
|
accept?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceDetails {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
|
@ -45,7 +67,8 @@ export interface IInstanceDetails {
|
||||||
insularity?: number;
|
insularity?: number;
|
||||||
statusCount?: number;
|
statusCount?: number;
|
||||||
domainCount?: number;
|
domainCount?: number;
|
||||||
peers?: IInstance[];
|
peers?: Peer[];
|
||||||
|
federationRestrictions: FederationRestrictions;
|
||||||
lastUpdated?: string;
|
lastUpdated?: string;
|
||||||
status: string;
|
status: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
@ -53,7 +76,7 @@ export interface IInstanceDetails {
|
||||||
statusesPerUserPerDay?: number;
|
statusesPerUserPerDay?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IGraphNode {
|
interface GraphNode {
|
||||||
data: {
|
data: {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
@ -65,7 +88,7 @@ interface IGraphNode {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IGraphEdge {
|
interface GraphEdge {
|
||||||
data: {
|
data: {
|
||||||
source: string;
|
source: string;
|
||||||
target: string;
|
target: string;
|
||||||
|
@ -74,53 +97,65 @@ interface IGraphEdge {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IGraphMetadata {
|
interface GraphMetadata {
|
||||||
ranges: { [key: string]: [number, number] };
|
ranges: { [key: string]: [number, number] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IGraph {
|
export interface Graph {
|
||||||
nodes: IGraphNode[];
|
nodes: GraphNode[];
|
||||||
edges: IGraphEdge[];
|
edges: GraphEdge[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IGraphResponse {
|
export interface GraphResponse {
|
||||||
graph: IGraph;
|
graph: Graph;
|
||||||
metadata: IGraphMetadata;
|
metadata: GraphMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISearchResponse {
|
export interface SearchResponse {
|
||||||
results: ISearchResultInstance[];
|
results: SearchResultInstance[];
|
||||||
next: string | null;
|
next: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InstanceListResponse {
|
||||||
|
pageNumber: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalEntries: number;
|
||||||
|
pageSize: number;
|
||||||
|
instances: InstanceDetails[];
|
||||||
|
}
|
||||||
|
|
||||||
// Redux state
|
// Redux state
|
||||||
|
|
||||||
// The current instance name is stored in the URL. See state -> router -> location
|
// The current instance name is stored in the URL. See state -> router -> location
|
||||||
export interface ICurrentInstanceState {
|
export interface CurrentInstanceState {
|
||||||
currentInstanceDetails: IInstanceDetails | null;
|
currentInstanceDetails: InstanceDetails | null;
|
||||||
isLoadingInstanceDetails: boolean;
|
isLoadingInstanceDetails: boolean;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IDataState {
|
export interface DataState {
|
||||||
graphResponse?: IGraphResponse;
|
graphResponse?: GraphResponse;
|
||||||
|
instancesResponse?: InstanceListResponse;
|
||||||
|
instanceListSort: InstanceSort;
|
||||||
isLoadingGraph: boolean;
|
isLoadingGraph: boolean;
|
||||||
error: boolean;
|
isLoadingInstanceList: boolean;
|
||||||
|
graphLoadError: boolean;
|
||||||
|
instanceListLoadError: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISearchState {
|
export interface SearchState {
|
||||||
error: boolean;
|
error: boolean;
|
||||||
isLoadingResults: boolean;
|
isLoadingResults: boolean;
|
||||||
next: string;
|
next: string;
|
||||||
query: string;
|
query: string;
|
||||||
results: ISearchResultInstance[];
|
results: SearchResultInstance[];
|
||||||
filters: ISearchFilter[];
|
filters: SearchFilter[];
|
||||||
hoveringOverResult?: string;
|
hoveringOverResult?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAppState {
|
export interface AppState {
|
||||||
router: RouterState;
|
router: RouterState;
|
||||||
currentInstance: ICurrentInstanceState;
|
currentInstance: CurrentInstanceState;
|
||||||
data: IDataState;
|
data: DataState;
|
||||||
search: ISearchState;
|
search: SearchState;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
type ISearchFilterRelation = "eq" | "gt" | "gte" | "lt" | "lte";
|
type SearchFilterRelation = "eq" | "gt" | "gte" | "lt" | "lte";
|
||||||
export interface ISearchFilter {
|
export interface SearchFilter {
|
||||||
// The ES field to filter on
|
// The ES field to filter on
|
||||||
field: string;
|
field: string;
|
||||||
relation: ISearchFilterRelation;
|
relation: SearchFilterRelation;
|
||||||
// The value we want to filter to
|
// The value we want to filter to
|
||||||
value: string;
|
value: string;
|
||||||
// Human-meaningful text that we're showing in the UI
|
// Human-meaningful text that we're showing in the UI
|
||||||
|
@ -10,17 +10,19 @@ export interface ISearchFilter {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maps to translate this to user-friendly text
|
// Maps to translate this to user-friendly text
|
||||||
|
type SearchFilterField = "type" | "user_count";
|
||||||
const searchFilterFieldTranslations = {
|
const searchFilterFieldTranslations = {
|
||||||
type: "Instance type",
|
type: "Instance type",
|
||||||
user_count: "User count"
|
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||||
|
user_count: "User count",
|
||||||
};
|
};
|
||||||
const searchFilterRelationTranslations = {
|
const searchFilterRelationTranslations = {
|
||||||
eq: "=",
|
eq: "=",
|
||||||
gt: ">",
|
gt: ">",
|
||||||
gte: ">=",
|
gte: ">=",
|
||||||
lt: "<",
|
lt: "<",
|
||||||
lte: "<="
|
lte: "<=",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSearchFilterDisplayValue = (field: string, relation: ISearchFilterRelation, value: string) =>
|
export const getSearchFilterDisplayValue = (field: SearchFilterField, relation: SearchFilterRelation, value: string) =>
|
||||||
`${searchFilterFieldTranslations[field]} ${searchFilterRelationTranslations[relation]} ${value}`;
|
`${searchFilterFieldTranslations[field]} ${searchFilterRelationTranslations[relation]} ${value}`;
|
||||||
|
|
|
@ -2,5 +2,5 @@ import { Position, Toaster } from "@blueprintjs/core";
|
||||||
|
|
||||||
export default Toaster.create({
|
export default Toaster.create({
|
||||||
className: "app-toaster",
|
className: "app-toaster",
|
||||||
position: Position.TOP
|
position: Position.TOP,
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { INSTANCE_TYPES } from "./constants";
|
import { INSTANCE_TYPES } from "./constants";
|
||||||
|
|
||||||
interface IColorSchemeBase {
|
interface ColorSchemeBase {
|
||||||
// The name of the coloring, e.g. "Instance type"
|
// The name of the coloring, e.g. "Instance type"
|
||||||
name: string;
|
name: string;
|
||||||
// The name of the key in a cytoscape node's `data` field to color by.
|
// The name of the key in a cytoscape node's `data` field to color by.
|
||||||
|
@ -9,30 +9,30 @@ interface IColorSchemeBase {
|
||||||
description?: string;
|
description?: string;
|
||||||
type: "qualitative" | "quantitative";
|
type: "qualitative" | "quantitative";
|
||||||
}
|
}
|
||||||
interface IQualitativeColorScheme extends IColorSchemeBase {
|
interface QualitativeColorScheme extends ColorSchemeBase {
|
||||||
// The values the color scheme is used for. E.g. ["mastodon", "pleroma", "misskey"].
|
// The values the color scheme is used for. E.g. ["mastodon", "pleroma", "misskey"].
|
||||||
values: string[];
|
values: string[];
|
||||||
type: "qualitative";
|
type: "qualitative";
|
||||||
}
|
}
|
||||||
interface IQuantitativeColorScheme extends IColorSchemeBase {
|
interface QuantitativeColorScheme extends ColorSchemeBase {
|
||||||
type: "quantitative";
|
type: "quantitative";
|
||||||
exponential: boolean;
|
exponential: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IColorScheme = IQualitativeColorScheme | IQuantitativeColorScheme;
|
export type ColorScheme = QualitativeColorScheme | QuantitativeColorScheme;
|
||||||
|
|
||||||
export const typeColorScheme: IQualitativeColorScheme = {
|
export const typeColorScheme: QualitativeColorScheme = {
|
||||||
cytoscapeDataKey: "type",
|
cytoscapeDataKey: "type",
|
||||||
name: "Instance type",
|
name: "Instance type",
|
||||||
type: "qualitative",
|
type: "qualitative",
|
||||||
values: INSTANCE_TYPES
|
values: INSTANCE_TYPES,
|
||||||
};
|
};
|
||||||
export const activityColorScheme: IQuantitativeColorScheme = {
|
export const activityColorScheme: QuantitativeColorScheme = {
|
||||||
cytoscapeDataKey: "statusesPerDay",
|
cytoscapeDataKey: "statusesPerDay",
|
||||||
description: "The average number of statuses posted per day. This is an exponential scale.",
|
description: "The average number of statuses posted per day. This is an exponential scale.",
|
||||||
exponential: true,
|
exponential: true,
|
||||||
name: "Activity",
|
name: "Activity",
|
||||||
type: "quantitative"
|
type: "quantitative",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const colorSchemes: IColorScheme[] = [typeColorScheme, activityColorScheme];
|
export const colorSchemes: ColorScheme[] = [typeColorScheme, activityColorScheme];
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
declare module "*.png";
|
|
@ -1,41 +1,39 @@
|
||||||
import { createMatchSelector } from "connected-react-router";
|
import { createMatchSelector } from "connected-react-router";
|
||||||
import fetch from "cross-fetch";
|
import fetch from "cross-fetch";
|
||||||
import { range } from "lodash";
|
import { range } from "lodash";
|
||||||
import { DESKTOP_WIDTH_THRESHOLD, IInstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants";
|
import { DESKTOP_WIDTH_THRESHOLD, InstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants";
|
||||||
import { IAppState } from "./redux/types";
|
import { AppState } from "./redux/types";
|
||||||
|
|
||||||
let API_ROOT = "http://localhost:4000/api/";
|
let API_ROOT = "https://api.index.community/api/";
|
||||||
if (["true", true, 1, "1"].indexOf(process.env.REACT_APP_STAGING || "") > -1) {
|
if (["true", true, 1, "1"].includes(process.env.REACT_APP_STAGING || "")) {
|
||||||
API_ROOT = "https://phoenix.api-develop.fediverse.space/api/";
|
API_ROOT = "https://api.index.community/api/";
|
||||||
} else if (process.env.NODE_ENV === "production") {
|
} else if (process.env.NODE_ENV === "production") {
|
||||||
API_ROOT = "https://phoenix.api.fediverse.space/api/";
|
API_ROOT = "https://api.index.community/api/";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getFromApi = (path: string, token?: string): Promise<any> => {
|
export const getFromApi = (path: string, token?: string): Promise<any> => {
|
||||||
const domain = API_ROOT.endsWith("/") ? API_ROOT : API_ROOT + "/";
|
const domain = API_ROOT.endsWith("/") ? API_ROOT : `${API_ROOT}/`;
|
||||||
const headers = token ? { token } : undefined;
|
const headers = token ? { token } : undefined;
|
||||||
return fetch(encodeURI(domain + path), {
|
return fetch(encodeURI(domain + path), {
|
||||||
headers
|
headers,
|
||||||
}).then(response => response.json());
|
}).then((response) => response.json());
|
||||||
};
|
};
|
||||||
|
|
||||||
export const postToApi = (path: string, body: any, token?: string): Promise<any> => {
|
export const postToApi = (path: string, body: any, token?: string): Promise<any> => {
|
||||||
const domain = API_ROOT.endsWith("/") ? API_ROOT : API_ROOT + "/";
|
const domain = API_ROOT.endsWith("/") ? API_ROOT : `${API_ROOT}/`;
|
||||||
const defaultHeaders = {
|
const defaultHeaders = {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json",
|
||||||
};
|
};
|
||||||
const headers = token ? { ...defaultHeaders, token } : defaultHeaders;
|
const headers = token ? { ...defaultHeaders, token } : defaultHeaders;
|
||||||
return fetch(encodeURI(domain + path), {
|
return fetch(encodeURI(domain + path), {
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
headers,
|
headers,
|
||||||
method: "POST"
|
method: "POST",
|
||||||
}).then(response => {
|
}).then((response) => response.json());
|
||||||
return response.json();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const domainMatchSelector = createMatchSelector<IAppState, IInstanceDomainPath>(INSTANCE_DOMAIN_PATH);
|
export const domainMatchSelector = createMatchSelector<AppState, InstanceDomainPath>(INSTANCE_DOMAIN_PATH);
|
||||||
|
|
||||||
export const isSmallScreen = window.innerWidth < DESKTOP_WIDTH_THRESHOLD;
|
export const isSmallScreen = window.innerWidth < DESKTOP_WIDTH_THRESHOLD;
|
||||||
|
|
||||||
|
@ -49,28 +47,25 @@ export const unsetAuthToken = () => {
|
||||||
sessionStorage.removeItem("adminToken");
|
sessionStorage.removeItem("adminToken");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAuthToken = () => {
|
export const getAuthToken = () => sessionStorage.getItem("adminToken");
|
||||||
return sessionStorage.getItem("adminToken");
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBuckets = (min: number, max: number, steps: number, exponential: boolean) => {
|
export const getBuckets = (min: number, max: number, steps: number, exponential: boolean) => {
|
||||||
if (exponential) {
|
if (exponential) {
|
||||||
const logSpace = range(steps).map(i => Math.E ** i);
|
const logSpace = range(steps).map((i) => Math.E ** i);
|
||||||
// Scale the log space to the linear range
|
// Scale the log space to the linear range
|
||||||
const logRange = logSpace[logSpace.length - 1] - logSpace[0];
|
const logRange = logSpace[logSpace.length - 1] - logSpace[0];
|
||||||
const linearRange = max - min;
|
const linearRange = max - min;
|
||||||
const scalingFactor = linearRange / logRange;
|
const scalingFactor = linearRange / logRange;
|
||||||
const translation = min - logSpace[0];
|
const translation = min - logSpace[0];
|
||||||
return logSpace.map(i => (i + translation) * scalingFactor);
|
return logSpace.map((i) => (i + translation) * scalingFactor);
|
||||||
} else {
|
|
||||||
// Linear
|
|
||||||
const bucketSize = (max - min) / steps;
|
|
||||||
return range(min, max, bucketSize);
|
|
||||||
}
|
}
|
||||||
|
// Linear
|
||||||
|
const bucketSize = (max - min) / steps;
|
||||||
|
return range(min, max, bucketSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeToDisplay = {
|
const typeToDisplay: { [field: string]: string } = {
|
||||||
gnusocial: "GNU Social"
|
gnusocial: "GNU Social",
|
||||||
};
|
};
|
||||||
export const getTypeDisplayString = (key: string) => {
|
export const getTypeDisplayString = (key: string) => {
|
||||||
if (key in typeToDisplay) {
|
if (key in typeToDisplay) {
|
||||||
|
|
|
@ -1,40 +1,32 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"allowJs": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"lib": [
|
|
||||||
"es2015",
|
|
||||||
"dom"
|
|
||||||
],
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"noImplicitAny": true,
|
|
||||||
"noImplicitReturns": true,
|
|
||||||
"noImplicitThis": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"outDir": "build",
|
|
||||||
"rootDir": "src",
|
|
||||||
"sourceMap": true,
|
|
||||||
"strictNullChecks": true,
|
|
||||||
"suppressImplicitAnyIndexErrors": true,
|
|
||||||
"target": "es5",
|
"target": "es5",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"jsx": "react",
|
||||||
"typeRoots": [
|
"typeRoots": [
|
||||||
"./node_modules/@types",
|
"./node_modules/@types",
|
||||||
"./src/typings"
|
"./src/typings"
|
||||||
],
|
],
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true
|
|
||||||
},
|
},
|
||||||
"exclude": [
|
|
||||||
"node_modules",
|
|
||||||
"build"
|
|
||||||
],
|
|
||||||
"include": [
|
"include": [
|
||||||
"src"
|
"src"
|
||||||
]
|
]
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue