diff --git a/CHANGELOG.md b/CHANGELOG.md
index e089085..fccd59a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Instance administrators can now log in to opt in or out of crawling.
+
### Changed
- Instances are now crawled hourly instead of every 30 minutes.
diff --git a/backend/README.md b/backend/README.md
index e365945..efd6f6a 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -6,6 +6,25 @@
- 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/).
+## Configuration
+
+There are several environment variables you can set to configure how the crawler behaves.
+
+- `DATABASE_URL` (required) . The URL of the Postgres db.
+- `POOL_SIZE`. The size of the database pool. Default: 10
+- `PORT`. Default: 4000
+- `BACKEND_HOSTNAME` (required). The url the backend is running on.
+- `SECRET_KEY_BASE` (required). Used for signing tokens.
+- `TWILIO_ACCOUNT_SID`. Used for sending SMS alerts to the admin.
+- `TWILIO_AUTH_TOKEN`. As above.
+- `ADMIN_PHONE`. The phone number to receive alerts at.
+ - At the moment, the only alert is when there are potential new spam domains.
+- `TWILIO_PHONE`. The phone number to send alerts from.
+- `ADMIN_EMAIL`. Used for receiving alerts.
+- `FRONTEND_DOMAIN` (required). Used to generate login links for instance admins.
+ - 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.
+
## Deployment
Deployment with Docker is handled as per the [Distillery docs](https://hexdocs.pm/distillery/guides/working_with_docker.html).
diff --git a/backend/config/config.exs b/backend/config/config.exs
index f2bbb96..2646339 100644
--- a/backend/config/config.exs
+++ b/backend/config/config.exs
@@ -48,7 +48,8 @@ config :backend, :crawler,
user_agent: "fediverse.space crawler",
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: "https://www.fediverse.space"
config :backend, Backend.Scheduler,
jobs: [
diff --git a/backend/config/releases.exs b/backend/config/releases.exs
index 71d7334..d5f50d1 100644
--- a/backend/config/releases.exs
+++ b/backend/config/releases.exs
@@ -32,7 +32,8 @@ config :ex_twilio,
config :backend, :crawler,
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")
config :backend, Backend.Mailer,
adapter: Swoosh.Adapters.Sendgrid,
diff --git a/backend/lib/backend/api.ex b/backend/lib/backend/api.ex
index ce812cc..07381d3 100644
--- a/backend/lib/backend/api.ex
+++ b/backend/lib/backend/api.ex
@@ -3,12 +3,6 @@ defmodule Backend.Api do
import Backend.Util
import Ecto.Query
- @spec list_instances() :: [Instance.t()]
- def list_instances() do
- Instance
- |> Repo.all()
- end
-
@spec get_instance!(String.t()) :: Instance.t()
def get_instance!(domain) do
Instance
@@ -16,6 +10,14 @@ defmodule Backend.Api do
|> Repo.get_by!(domain: domain)
end
+ def update_instance(instance) do
+ Repo.insert(
+ instance,
+ on_conflict: {:replace, [:opt_in, :opt_out]},
+ conflict_target: :domain
+ )
+ end
+
@doc """
Returns a list of instances that
* have a user count (required to give the instance a size on the graph)
@@ -32,7 +34,7 @@ defmodule Backend.Api do
|> where(
[i],
not is_nil(i.x) and not is_nil(i.y) and not is_nil(i.user_count) and
- i.user_count >= ^user_threshold
+ i.user_count >= ^user_threshold and not i.opt_out
)
|> maybe_filter_nodes_to_neighborhood(domain)
|> select([c], [:domain, :user_count, :x, :y, :type])
@@ -71,7 +73,8 @@ defmodule Backend.Api do
[e, i1, i2],
not is_nil(i1.x) and not is_nil(i1.y) and
not is_nil(i2.x) and not is_nil(i2.y) and
- i1.user_count >= ^user_threshold and i2.user_count >= ^user_threshold
+ i1.user_count >= ^user_threshold and i2.user_count >= ^user_threshold and
+ not i1.opt_out and not i2.opt_out
)
|> Repo.all()
end
@@ -103,7 +106,7 @@ defmodule Backend.Api do
%{entries: instances, metadata: metadata} =
Instance
- |> where([i], ilike(i.domain, ^ilike_query))
+ |> where([i], ilike(i.domain, ^ilike_query) and not i.opt_out)
|> order_by(asc: :id)
|> Repo.paginate(after: cursor_after, cursor_fields: [:id], limit: 50)
diff --git a/backend/lib/backend/application.ex b/backend/lib/backend/application.ex
index 8880fe3..63b3c30 100644
--- a/backend/lib/backend/application.ex
+++ b/backend/lib/backend/application.ex
@@ -4,7 +4,6 @@ defmodule Backend.Application do
@moduledoc false
use Application
- require Logger
import Backend.Util
def start(_type, _args) do
diff --git a/backend/lib/backend/auth.ex b/backend/lib/backend/auth.ex
new file mode 100644
index 0000000..211e2ae
--- /dev/null
+++ b/backend/lib/backend/auth.ex
@@ -0,0 +1,17 @@
+defmodule Backend.Auth do
+ alias Phoenix.Token
+ import Backend.Util
+
+ @salt "fedi auth salt"
+
+ def get_login_link(domain) do
+ token = Token.sign(BackendWeb.Endpoint, @salt, domain)
+ frontend_domain = get_config(:frontend_domain)
+ "https://#{frontend_domain}/admin/verify?token=#{URI.encode(token)}"
+ end
+
+ def verify_token(token) do
+ # tokens are valid for 12 hours
+ Token.verify(BackendWeb.Endpoint, @salt, token, max_age: 43200)
+ end
+end
diff --git a/backend/lib/backend/crawler/api_crawler.ex b/backend/lib/backend/crawler/api_crawler.ex
index 4147e2e..30f4933 100644
--- a/backend/lib/backend/crawler/api_crawler.ex
+++ b/backend/lib/backend/crawler/api_crawler.ex
@@ -6,7 +6,7 @@ defmodule Backend.Crawler.ApiCrawler do
* You must adhere to the following configuration values:
* `:status_age_limit_days` specifies that you must only crawl statuses from the most recent N days
* `:status_count_limit` specifies the max number of statuses to crawl in one go
- * `:personal_instance_threshold` specifies that instances with fewer than this number of users should not be crawled
+ * `:personal_instance_threshold` specifies that instances with fewer than this number of users should not be crawled (unless :opt_in is true)
* profiles with the string "nobot" (case insensitive) in their profile must not be included in any stats
* Make sure to check the most recent crawl of the instance so you don't re-crawl old statuses
"""
diff --git a/backend/lib/backend/crawler/crawlers/mastodon.ex b/backend/lib/backend/crawler/crawlers/mastodon.ex
index f1f4a91..3c740aa 100644
--- a/backend/lib/backend/crawler/crawlers/mastodon.ex
+++ b/backend/lib/backend/crawler/crawlers/mastodon.ex
@@ -2,7 +2,9 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
require Logger
import Backend.Crawler.Util
import Backend.Util
+ import Ecto.Query
alias Backend.Crawler.ApiCrawler
+ alias Backend.{Instance, Repo}
@behaviour ApiCrawler
@@ -34,7 +36,14 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
def crawl(domain) do
instance = Jason.decode!(get!("https://#{domain}/api/v1/instance").body)
- if get_in(instance, ["stats", "user_count"]) > get_config(:personal_instance_threshold) do
+ has_opted_in =
+ case Instance |> select([:opt_in]) |> Repo.get_by(domain: domain) do
+ %{opt_in: true} -> true
+ _ -> false
+ end
+
+ if get_in(instance, ["stats", "user_count"]) > get_config(:personal_instance_threshold) or
+ has_opted_in do
crawl_large_instance(domain, instance)
else
Map.merge(
diff --git a/backend/lib/backend/crawler/stale_instance_manager.ex b/backend/lib/backend/crawler/stale_instance_manager.ex
index b78fcca..4e7a17a 100644
--- a/backend/lib/backend/crawler/stale_instance_manager.ex
+++ b/backend/lib/backend/crawler/stale_instance_manager.ex
@@ -67,8 +67,8 @@ defmodule Backend.Crawler.StaleInstanceManager do
|> join(:left, [i], c in subquery(crawls_subquery), on: i.domain == c.instance_domain)
|> where(
[i, c],
- c.most_recent_crawl < datetime_add(^NaiveDateTime.utc_now(), ^interval, "minute") or
- is_nil(c.crawl_count)
+ (c.most_recent_crawl < datetime_add(^NaiveDateTime.utc_now(), ^interval, "minute") or
+ is_nil(c.crawl_count)) and not i.opt_out
)
|> select([i], i.domain)
|> Repo.all()
diff --git a/backend/lib/backend/instance.ex b/backend/lib/backend/instance.ex
index efed46b..a2d618f 100644
--- a/backend/lib/backend/instance.ex
+++ b/backend/lib/backend/instance.ex
@@ -11,6 +11,8 @@ defmodule Backend.Instance do
field :insularity, :float
field :type, :string
field :base_domain, :string
+ field :opt_in, :boolean
+ field :opt_out, :boolean
many_to_many :peers, Backend.Instance,
join_through: Backend.InstancePeer,
@@ -37,7 +39,9 @@ defmodule Backend.Instance do
:insularity,
:updated_at,
:type,
- :base_domain
+ :base_domain,
+ :opt_in,
+ :opt_out
])
|> validate_required([:domain])
|> put_assoc(:peers, attrs.peers)
diff --git a/backend/lib/backend/util.ex b/backend/lib/backend/util.ex
index f1ef479..4081527 100644
--- a/backend/lib/backend/util.ex
+++ b/backend/lib/backend/util.ex
@@ -142,4 +142,19 @@ defmodule Backend.Util do
Logger.info("Could not send SMS to admin; not configured.")
end
end
+
+ def clean_domain(domain) do
+ domain
+ |> String.replace_prefix("https://", "")
+ |> String.trim_trailing("/")
+ |> String.downcase()
+ end
+
+ def get_account(username, domain) do
+ if username == nil or domain == nil do
+ nil
+ else
+ "#{String.downcase(username)}@#{clean_domain(domain)}"
+ end
+ end
end
diff --git a/backend/lib/backend_web/controllers/admin_controller.ex b/backend/lib/backend_web/controllers/admin_controller.ex
new file mode 100644
index 0000000..9d0c2a6
--- /dev/null
+++ b/backend/lib/backend_web/controllers/admin_controller.ex
@@ -0,0 +1,34 @@
+defmodule BackendWeb.AdminController do
+ use BackendWeb, :controller
+ alias Backend.{Auth, Api, Instance}
+ require Logger
+
+ action_fallback BackendWeb.FallbackController
+
+ def show(conn, _params) do
+ [token] = get_req_header(conn, "token")
+
+ with {:ok, domain} <- Auth.verify_token(token) do
+ instance = Api.get_instance!(domain)
+ render(conn, "show.json", instance: instance)
+ end
+ end
+
+ def update(conn, params) do
+ [token] = get_req_header(conn, "token")
+
+ with {:ok, domain} <- Auth.verify_token(token) do
+ %{"optIn" => opt_in, "optOut" => opt_out} = params
+
+ instance = %Instance{
+ domain: domain,
+ opt_in: opt_in,
+ opt_out: opt_out
+ }
+
+ with {:ok, updated_instance} <- Api.update_instance(instance) do
+ render(conn, "show.json", instance: updated_instance)
+ end
+ end
+ end
+end
diff --git a/backend/lib/backend_web/controllers/admin_login_controller.ex b/backend/lib/backend_web/controllers/admin_login_controller.ex
new file mode 100644
index 0000000..e116f61
--- /dev/null
+++ b/backend/lib/backend_web/controllers/admin_login_controller.ex
@@ -0,0 +1,52 @@
+defmodule BackendWeb.AdminLoginController do
+ use BackendWeb, :controller
+ import Backend.Util
+ alias Backend.Mailer.UserEmail
+
+ action_fallback BackendWeb.FallbackController
+
+ @doc """
+ Given an instance, looks up the login types (email or admin account) and returns them. The user can then
+ choose one or the other by POSTing back.
+ """
+ def show(conn, %{"id" => domain}) do
+ # TODO: this should really be handled in a more async manner
+ # TODO: this assumes mastodon/pleroma API
+ cleaned_domain = clean_domain(domain)
+
+ instance_data =
+ HTTPoison.get!("https://#{cleaned_domain}/api/v1/instance")
+ |> Map.get(:body)
+ |> Jason.decode!()
+
+ render(conn, "show.json", instance_data: instance_data, cleaned_domain: cleaned_domain)
+ end
+
+ def create(conn, %{"domain" => domain, "type" => type}) do
+ cleaned_domain = clean_domain(domain)
+
+ instance_data =
+ HTTPoison.get!("https://#{cleaned_domain}/api/v1/instance")
+ |> Map.get(:body)
+ |> Jason.decode!()
+
+ error =
+ cond do
+ type == "email" ->
+ email = Map.get(instance_data, "email")
+
+ case UserEmail.send_login_email(email, cleaned_domain) do
+ {:ok, _} -> nil
+ {:error, _} -> "Failed to send email."
+ end
+
+ # type == "fediverseAccount" ->
+ # account = nil
+
+ true ->
+ "Invalid account type. Must be 'email' or 'fediverseAccount'."
+ end
+
+ render(conn, "create.json", error: error)
+ end
+end
diff --git a/backend/lib/backend_web/controllers/fallback_controller.ex b/backend/lib/backend_web/controllers/fallback_controller.ex
index cc4cc12..77745b0 100644
--- a/backend/lib/backend_web/controllers/fallback_controller.ex
+++ b/backend/lib/backend_web/controllers/fallback_controller.ex
@@ -12,4 +12,10 @@ defmodule BackendWeb.FallbackController do
|> put_view(BackendWeb.ErrorView)
|> render(:"404")
end
+
+ def call(conn, {:error, _}) do
+ conn
+ |> put_status(500)
+ |> render(:"500")
+ end
end
diff --git a/backend/lib/backend_web/controllers/instance_controller.ex b/backend/lib/backend_web/controllers/instance_controller.ex
index 8a165f4..bbacf06 100644
--- a/backend/lib/backend_web/controllers/instance_controller.ex
+++ b/backend/lib/backend_web/controllers/instance_controller.ex
@@ -6,11 +6,6 @@ defmodule BackendWeb.InstanceController do
action_fallback(BackendWeb.FallbackController)
- def index(conn, _params) do
- instances = Api.list_instances()
- render(conn, "index.json", instances: instances)
- end
-
def show(conn, %{"id" => domain}) do
instance = Api.get_instance!(domain)
last_crawl = get_last_crawl(domain)
diff --git a/backend/lib/backend_web/endpoint.ex b/backend/lib/backend_web/endpoint.ex
index a795eb8..46f7bf4 100644
--- a/backend/lib/backend_web/endpoint.ex
+++ b/backend/lib/backend_web/endpoint.ex
@@ -44,8 +44,10 @@ defmodule BackendWeb.Endpoint do
signing_salt: "HJa1j4FI"
)
- # TODO
- plug(Corsica, origins: "*")
+ plug(Corsica,
+ origins: ["http://localhost:3000", ~r{^https?://(.*\.?)fediverse\.space$}],
+ allow_headers: ["content-type", "token"]
+ )
plug(BackendWeb.Router)
end
diff --git a/backend/lib/backend_web/router.ex b/backend/lib/backend_web/router.ex
index fea7e2a..dfa4f01 100644
--- a/backend/lib/backend_web/router.ex
+++ b/backend/lib/backend_web/router.ex
@@ -8,8 +8,12 @@ defmodule BackendWeb.Router do
scope "/api", BackendWeb do
pipe_through(:api)
- resources("/instances", InstanceController, only: [:index, :show])
+ resources("/instances", InstanceController, only: [:show])
resources("/graph", GraphController, only: [:index, :show])
resources("/search", SearchController, only: [:index])
+
+ resources("/admin/login", AdminLoginController, only: [:show, :create])
+ get "/admin", AdminController, :show
+ post "/admin", AdminController, :update
end
end
diff --git a/backend/lib/backend_web/views/admin_login_view.ex b/backend/lib/backend_web/views/admin_login_view.ex
new file mode 100644
index 0000000..e2e550a
--- /dev/null
+++ b/backend/lib/backend_web/views/admin_login_view.ex
@@ -0,0 +1,28 @@
+defmodule BackendWeb.AdminLoginView do
+ use BackendWeb, :view
+ import Backend.Util
+
+ 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)
+
+ %{
+ domain: cleaned_domain,
+ email: Map.get(instance_data, "email"),
+ fediverseAccount: fedi_account
+ }
+ end
+
+ def render("create.json", %{error: error}) do
+ if error != nil do
+ %{
+ error: error
+ }
+ else
+ %{
+ data: "success"
+ }
+ end
+ end
+end
diff --git a/backend/lib/backend_web/views/admin_view.ex b/backend/lib/backend_web/views/admin_view.ex
new file mode 100644
index 0000000..cd72cb6
--- /dev/null
+++ b/backend/lib/backend_web/views/admin_view.ex
@@ -0,0 +1,25 @@
+defmodule BackendWeb.AdminView do
+ use BackendWeb, :view
+ import Backend.Util
+ require Logger
+
+ def render("show.json", %{instance: instance}) do
+ Logger.info(inspect(instance))
+
+ %{
+ domain: domain,
+ opt_in: opt_in,
+ opt_out: opt_out,
+ user_count: user_count,
+ status_count: status_count
+ } = instance
+
+ %{
+ domain: domain,
+ optIn: opt_in,
+ optOut: opt_out,
+ userCount: user_count,
+ statusCount: status_count
+ }
+ end
+end
diff --git a/backend/lib/backend_web/views/instance_view.ex b/backend/lib/backend_web/views/instance_view.ex
index 800865e..2449c86 100644
--- a/backend/lib/backend_web/views/instance_view.ex
+++ b/backend/lib/backend_web/views/instance_view.ex
@@ -4,19 +4,7 @@ defmodule BackendWeb.InstanceView do
import Backend.Util
require Logger
- def render("index.json", %{instances: instances}) do
- render_many(instances, InstanceView, "instance.json")
- end
-
def render("show.json", %{instance: instance, crawl: crawl}) do
- render_one(instance, InstanceView, "instance_detail.json", crawl: crawl)
- end
-
- def render("instance.json", %{instance: instance}) do
- %{name: instance.domain}
- end
-
- def render("instance_detail.json", %{instance: instance, crawl: crawl}) do
user_threshold = get_config(:personal_instance_threshold)
[status, last_updated] =
@@ -39,6 +27,10 @@ defmodule BackendWeb.InstanceView do
}
true ->
+ filtered_peers =
+ instance.peers
+ |> Enum.filter(fn peer -> not peer.opt_out end)
+
%{
name: instance.domain,
description: instance.description,
@@ -47,11 +39,15 @@ defmodule BackendWeb.InstanceView do
insularity: instance.insularity,
statusCount: instance.status_count,
domainCount: length(instance.peers),
- peers: render_many(instance.peers, InstanceView, "instance.json"),
+ peers: render_many(filtered_peers, InstanceView, "instance.json"),
lastUpdated: last_updated,
status: status,
type: instance.type
}
end
end
+
+ def render("instance.json", %{instance: instance}) do
+ %{name: instance.domain}
+ end
end
diff --git a/backend/lib/mailer/user_email.ex b/backend/lib/mailer/user_email.ex
new file mode 100644
index 0000000..c50adaa
--- /dev/null
+++ b/backend/lib/mailer/user_email.ex
@@ -0,0 +1,21 @@
+defmodule Backend.Mailer.UserEmail do
+ import Swoosh.Email
+ import Backend.Auth
+ require Logger
+
+ @spec send_login_email(String.t(), String.t()) :: {:ok | :error, term}
+ def send_login_email(address, domain) do
+ frontend_domain = get_config(:frontend_domain)
+
+ body =
+ "Someone tried to log in to #{domain} on https://#{frontend_domain}.\n\nIf it was you, click here to confirm:\n\n" <>
+ get_login_link(domain)
+
+ new()
+ |> to(address)
+ |> from("noreply@fediverse.space")
+ |> subject("Login to fediverse.space")
+ |> text_body(body)
+ |> Backend.Mailer.deliver()
+ end
+end
diff --git a/backend/priv/repo/migrations/20190726104004_add_instance_settings.exs b/backend/priv/repo/migrations/20190726104004_add_instance_settings.exs
new file mode 100644
index 0000000..2f02f62
--- /dev/null
+++ b/backend/priv/repo/migrations/20190726104004_add_instance_settings.exs
@@ -0,0 +1,12 @@
+defmodule Backend.Repo.Migrations.AddInstanceSettings do
+ use Ecto.Migration
+
+ def change do
+ alter table(:instances) do
+ add :opt_in, :boolean, default: false, null: false
+ add :opt_out, :boolean, default: false, null: false
+ end
+
+ create index(:instances, [:opt_out])
+ end
+end
diff --git a/frontend/src/AppRouter.tsx b/frontend/src/AppRouter.tsx
index 516cb02..9bdea62 100644
--- a/frontend/src/AppRouter.tsx
+++ b/frontend/src/AppRouter.tsx
@@ -1,18 +1,21 @@
-import * as React from "react";
+import React from "react";
import { Classes } from "@blueprintjs/core";
import { ConnectedRouter } from "connected-react-router";
import { Route } from "react-router-dom";
import { Nav } from "./components/organisms/";
-import { AboutScreen, GraphScreen } from "./components/screens/";
+import { AboutScreen, AdminScreen, GraphScreen, LoginScreen, VerifyLoginScreen } from "./components/screens/";
import { history } from "./index";
const AppRouter: React.FC = () => (
- This site is inspired by several other sites in the same vein: -
This site is inspired by several other sites in the same vein:
+