diff --git a/application/Makefile b/application/Makefile index 799ae133..83684609 100644 --- a/application/Makefile +++ b/application/Makefile @@ -2,7 +2,7 @@ run: @ echo "This command should not be used in a production environment. OAuth requires an https connection outside of development." - AUTHLIB_INSECURE_TRANSPORT=true TEMPLATES_AUTO_RELOAD=true ../venv/bin/flask --debug run --reload --debug + GATEWAY_HOST="localhost:4000/gateway" AUTHLIB_INSECURE_TRANSPORT=true TEMPLATES_AUTO_RELOAD=true ../venv/bin/flask --debug run --reload --debug sass: cd static/styles/ && sass . diff --git a/application/controllers/api/v1/__init__.py b/application/controllers/api/v1/__init__.py index d0316140..59803ee5 100644 --- a/application/controllers/api/v1/__init__.py +++ b/application/controllers/api/v1/__init__.py @@ -18,6 +18,7 @@ from controllers.api.v1 import ( bot, faction, + gateway, items, key, notification, @@ -407,3 +408,7 @@ view_func=notification.notification.toggle_guild_notification, methods=["POST"], ) + +# /api/v1/gateway +mod.add_url_rule("/api/v1/gateway/token", view_func=gateway.create_token, methods=["POST"]) +mod.add_url_rule("/api/v1/gateway/token/revoke", view_func=gateway.revoke_token, methods=["POST"]) diff --git a/application/controllers/api/v1/gateway/__init__.py b/application/controllers/api/v1/gateway/__init__.py new file mode 100644 index 00000000..b25263c3 --- /dev/null +++ b/application/controllers/api/v1/gateway/__init__.py @@ -0,0 +1,78 @@ +# Copyright (C) 2021-2025 tiksan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import datetime +import json +import os +import secrets +import uuid + +from flask import request +from peewee import DoesNotExist +from tornium_commons.models import GatewayToken + +from controllers.api.v1.decorators import ratelimit, require_oauth, session_required +from controllers.api.v1.utils import api_ratelimit_response, make_exception_response +from controllers.authroutes import _log_auth + +GATEWAY_HOST = os.environ.get("GATEWAY_HOST") or "https://gateway.tornium.com/" + +# NOTE: secrets.token_hex generates double the number of characters as the number of bytes inputted +TOKEN_LENGTH = 64 + + +@require_oauth("gateway") +@ratelimit +def create_token(*args, **kwargs): + key = f"tornium:ratelimit:{kwargs['user'].tid}" + token = GatewayToken.create( + guid=uuid.uuid4(), + user_id=kwargs["user"].tid, + token=secrets.token_hex(TOKEN_LENGTH / 2), + created_at=datetime.datetime.utcnow(), + created_ip=request.headers.get("CF-Connecting-IP") or request.remote_addr, + expires_at=datetime.datetime.utcnow() + datetime.timedelta(minutes=15), + ) + + return ( + {"token": token.token, "expires_at": token.expires_at.timestamp(), "gateway_url": f"{GATEWAY_HOST}/"}, + 200, + api_ratelimit_response(key), + ) + + +@require_oauth("gateway") +@ratelimit +def revoke_token(*args, **kwargs): + data = json.loads(request.get_data().decode("utf-8")) + key = f"tornium:ratelimit:{kwargs['user'].tid}" + + token = data.get("token") + + if token is None or not isinstance(token, str) or len(token) != TOKEN_LENGTH: + return make_exception_response("1300", key) + + try: + gateway_token: GatewayToken = ( + GatewayToken.select() + .where((GatewayToken.token == token) & (GatewayToken.user_id == kwargs["user"].tid)) + .get() + ) + except DoesNotExist: + return make_exception_response("1300", key) + + gateway_token.delete_instance() + + return "", 204, api_ratelimit_response(key) diff --git a/application/controllers/api/v1/utils.py b/application/controllers/api/v1/utils.py index 44b2c804..70559c1e 100644 --- a/application/controllers/api/v1/utils.py +++ b/application/controllers/api/v1/utils.py @@ -135,9 +135,9 @@ }, "1300": { "code": 1300, - "name": "UnknownGatewayClient", - "http": 404, - "message": "[DEPRECATED] Server failed to locate the requested gateway client.", + "name": "InvalidGatewayToken", + "http": 401, + "message": "The provided gateway token was invalid.", }, "1400": { "code": 1400, diff --git a/application/controllers/faction/__init__.py b/application/controllers/faction/__init__.py index 41ea25e4..94fdf68a 100644 --- a/application/controllers/faction/__init__.py +++ b/application/controllers/faction/__init__.py @@ -15,7 +15,7 @@ from flask import Blueprint, render_template -from controllers.faction import armory, banking, bot, crimes, members +from controllers.faction import armory, banking, bot, crimes, members, ranked_war mod = Blueprint("factionroutes", __name__) @@ -39,6 +39,9 @@ # Armory Routes mod.add_url_rule("/faction/armory", view_func=armory.armory, methods=["GET"]) +# Ranked War Routes +mod.add_url_rule("/faction/ranked-war/tracker", view_func=ranked_war.tracker, methods=["GET"]) + @mod.route("/faction") def index(): diff --git a/application/controllers/faction/ranked_war.py b/application/controllers/faction/ranked_war.py new file mode 100644 index 00000000..a0de89ea --- /dev/null +++ b/application/controllers/faction/ranked_war.py @@ -0,0 +1,30 @@ +# Copyright (C) 2021-2025 tiksan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import typing + +from flask import render_template +from flask_login import current_user, login_required +from tornium_commons.models import Faction, User + +from controllers.faction.decorators import fac_required + + +@login_required +@fac_required +def tracker(*args, **kwargs): + return render_template( + "faction/ranked_war_tracker.html", + ) diff --git a/application/controllers/oauth.py b/application/controllers/oauth.py index ce511f92..492c1c3d 100644 --- a/application/controllers/oauth.py +++ b/application/controllers/oauth.py @@ -26,6 +26,7 @@ valid_scopes = ( "identity", + "gateway", # Faction scopes "faction", "faction:attacks", @@ -48,7 +49,7 @@ def openid_configuration(): { "issuer": "https://tornium.com", "authorization_endpoint": "https://tornium.com/oauth/authorize", - "token_endpoint": "https://tornium.com/oauth/token", + "token_endpoint": "https://tornium.com/oauth/token", # nosec "scopes_supported": list(valid_scopes), "response_types_supported": ["code"], "grant_types_supported": ["authorization_code"], diff --git a/application/templates/faction/armory.html b/application/templates/faction/armory.html index af629983..e1c2142d 100644 --- a/application/templates/faction/armory.html +++ b/application/templates/faction/armory.html @@ -46,6 +46,12 @@ + + {% if current_user.can_manage_crimes() %} + + {% if current_user.can_manage_crimes() %} + + {% if current_user.can_manage_crimes() %} + + {% if current_user.can_manage_crimes() %} + + {% if current_user.can_manage_crimes() %} + + {% if current_user.can_manage_crimes() %} + + {% if current_user.can_manage_crimes() %} + + {% if current_user.can_manage_crimes() %}
diff --git a/application/templates/faction/ranked_war_tracker.html b/application/templates/faction/ranked_war_tracker.html new file mode 100644 index 00000000..15c44879 --- /dev/null +++ b/application/templates/faction/ranked_war_tracker.html @@ -0,0 +1,107 @@ +{% extends 'base.html' %} + +{% block title %} +Tornium - Ranked War Tracker +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block subnav %} +
+
+ + + + + {% if current_user.is_authenticated and current_user.factiontid != 0 %} + + + + + + + {% if current_user.can_manage_crimes() %} + + {% endif %} + + {% if current_user.faction_aa %} + + + + {% endif %} + {% endif %} +
+
+{% endblock %} + +{% block content %} +
+
+
+
Ranked War Tracker
+ +

+ TBA +

+ +

+ For information on using this tool, see the tool's documentation page. +

+
+
+ +
+
Monitoring: NYI
+
+

This feature has not been implemented yet.

+
+
+
+{% endblock %} diff --git a/application/templates/oauth/authorize.html b/application/templates/oauth/authorize.html index 2cba1a6d..fb854e63 100644 --- a/application/templates/oauth/authorize.html +++ b/application/templates/oauth/authorize.html @@ -61,6 +61,14 @@
WARNING<
{% endif %} + {% if scope == "gateway" %} +
  • +
    +
    Gateway
    + This application will be able to access the Tornium Gateway. +
    +
  • + {% endif %} {% if scope == "faction" %}
  • diff --git a/application/utils/notification_trigger.py b/application/utils/notification_trigger.py index 2e73934c..4e809cf1 100644 --- a/application/utils/notification_trigger.py +++ b/application/utils/notification_trigger.py @@ -277,8 +277,16 @@ def load_trigger(path: pathlib.Path, official: bool = False): with open(f"{path}/code.lua", "r") as f: code: str = f.read() - with open(f"{path}/message.liquid", "r") as f: - template: str = f.read() + templates = {} + + for template_type, template_file in config_data["templates"]: + if template_type not in ("discord", "gateway"): + raise ValueError( + f"When loading the {config_data['trigger']['name']} template, there was an invalid template type: {template_type}" + ) + + with open(f"{path}/message.liquid", "r") as f: + templates[template_type] = f.read() # TODO: Validate data provided by files @@ -293,7 +301,8 @@ def load_trigger(path: pathlib.Path, official: bool = False): code=code, parameters=config_data["implementation"].get("parameters", {}), message_type=config_data["implementation"]["message_type"], - message_template=template, + message_template=templates.get("discord"), + gateway_template=templates.get("gateway"), restricted_data=has_restricted_selection(code, config_data["implementation"]["resource"]), official=official, ).on_conflict( @@ -309,6 +318,7 @@ def load_trigger(path: pathlib.Path, official: bool = False): NotificationTrigger.parameters, NotificationTrigger.message_type, NotificationTrigger.message_template, + NotificationTrigger.gateway_template, NotificationTrigger.restricted_data, NotificationTrigger.official, ], diff --git a/commons/tornium_commons/models/__init__.py b/commons/tornium_commons/models/__init__.py index d7ec1f5e..3882497c 100644 --- a/commons/tornium_commons/models/__init__.py +++ b/commons/tornium_commons/models/__init__.py @@ -17,6 +17,7 @@ from .auth_log import AuthAction, AuthLog from .faction import Faction from .faction_position import FactionPosition +from .gateway_token import GatewayToken from .item import Item from .notification import Notification from .notification_trigger import NotificationTrigger @@ -51,6 +52,7 @@ "AuthLog", "Faction", "FactionPosition", + "GatewayToken", "Item", "Notification", "NotificationTrigger", diff --git a/commons/tornium_commons/models/gateway_token.py b/commons/tornium_commons/models/gateway_token.py new file mode 100644 index 00000000..16e9cba8 --- /dev/null +++ b/commons/tornium_commons/models/gateway_token.py @@ -0,0 +1,36 @@ +# Copyright (C) 2021-2025 tiksan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import datetime + +from peewee import CharField, DateTimeField, ForeignKeyField +from playhouse.postgres_ext import UUIDField + +from .base_model import BaseModel +from .extra_fields import INETField +from .user import User + + +class GatewayToken(BaseModel): + class Meta: + table_name = "gateway_token" + + guid = UUIDField(primary_key=True) + user = ForeignKeyField(User, null=False) + token = CharField(max_length=64, null=False) + + created_at = DateTimeField(null=False, default=datetime.datetime.utcnow) + created_ip = INETField(null=False) + expires_at = DateTimeField(null=False) diff --git a/commons/tornium_commons/models/notification_trigger.py b/commons/tornium_commons/models/notification_trigger.py index 21f22ef6..6b8bf406 100644 --- a/commons/tornium_commons/models/notification_trigger.py +++ b/commons/tornium_commons/models/notification_trigger.py @@ -43,8 +43,12 @@ class Meta: code = TextField(null=False) parameters = JSONField(default={}, null=False) + # Delivery: Discord message_type = CharField(null=False, choices=["update", "send"]) - message_template = TextField(null=False) + message_template = TextField(null=True) + + # Delivery: SSE Gateway + gateway_template = TextField(null=True) restricted_data = BooleanField(default=False, null=False) official = BooleanField(default=False, null=False) diff --git a/docs/src/data-use-storage.md b/docs/src/data-use-storage.md index 8554704a..e70990ca 100644 --- a/docs/src/data-use-storage.md +++ b/docs/src/data-use-storage.md @@ -39,7 +39,7 @@ We collect your faction positions to handle permissions on Tornium and to determ We collect your faction overdoses for overdose notifications. This data is stored persistently. Your faction overdoses cannot be accessed by anyone, but notifications for faction overdoses may be sent to the linked Discord server. ## General Geolocation -We log the full IP addresses used to log in to accounts and retain that data for account security. This data is stored persistently. We also log IP addresses for all requests on a rolling basis for monitoring purposes. This data can only be accessed by Tornium administartor(s). +We log the full IP addresses used to log in to accounts and to authorize access to the Tornium API and Tornium Gateway; and we retain that data for account security. This data is stored persistently. We also log IP addresses for all requests on a rolling basis for monitoring purposes. This data can only be accessed by Tornium administartor(s). ## General Torn Data We collect general, public Torn data (such as all items) to keep our database up-to-date using random users' API keys. This data is stored on a persistent basis. This data can be accessed any user. diff --git a/docs/src/reference/api/endpoints.md b/docs/src/reference/api/endpoints.md index 71ef45f5..498f2dab 100644 --- a/docs/src/reference/api/endpoints.md +++ b/docs/src/reference/api/endpoints.md @@ -1,6 +1,7 @@ # Endpoints The Tornium API supports the following resource APIs: - [Faction API](#faction-api) +- [Gateway API](#gateway-api) - [Stat API](#stat-api) - [Stocks API](#stocks-api) - [Users API](#users-api) @@ -205,6 +206,39 @@ Authorization: Bearer {{ access_token }} } ``` +## Gateway API +### Create a Gateway Token +Create a Gateway token for the authenticated user. This token can be used to connect to the Tornium **S**erver-**S**ent **E**vents (SSE) Gateway. + +**Scopes Required:** `gateway` + +```http +POST /api/v1/gateway/token HTTP/1.1 +Authorization: Bearer {{ access_token }} +Content-Type: application/json + +{ + "token": "c107172687379c43e959d4601593a3e8035f45146632d136ce4f22fa8a140c4d", + "expires_at": 1768886936, + "gateway_url": "https://gateway.tornium.com/" +} +``` + +### Revoke a Gateway Token +Revoke a specific Gateway token for the authenticated user. If successful, this endpoint returns HTTP 204 with no further information. + +**Scopes Required:** `gateway` + +```http +POST /api/v1/gateway/token/revoke HTTP/1.1 +Authorization: Bearer {{ access_token }} +Content-Type: application/json + +{ + "token": "c107172687379c43e959d4601593a3e8035f45146632d136ce4f22fa8a140c4d" +} +``` + ## Stat API ### Generate Chain List Generate a chain list for the authenticated user. The authenticated user must have a stat score in the database, either from being logged into Tornium with an API key or through the faction's TornStats. diff --git a/docs/src/reference/api/oauth-provider.md b/docs/src/reference/api/oauth-provider.md index 040bf935..1b7c8a29 100644 --- a/docs/src/reference/api/oauth-provider.md +++ b/docs/src/reference/api/oauth-provider.md @@ -100,11 +100,12 @@ This is a list of Tornium's OAuth2 scopes required for use of certain endpoints | Name | Description | | --------------- | ----------------------------------------------------------------------- | | `identity` | allows access to information on a user's identity | +| `gateway` | allows access to the Tornium Gateway | | `faction` | allows access to all information on the user's faction | | `faction:attacks` | allows access to information related to the attacks of a user's faction | | `faction:banking` | allows access to the Tornium banking of a user's faction | | `faction:crimes` | allows access to the organized crime information of the user's faction | -| `torn_key:usage` | allows the usage of the user's API key | +| `torn_key:usage` | allows the usage of the user's API key | ## Security Considerations diff --git a/notifications/faction-hospital/config.toml b/notifications/faction-hospital/config.toml index e80c2b66..6af584e3 100644 --- a/notifications/faction-hospital/config.toml +++ b/notifications/faction-hospital/config.toml @@ -12,3 +12,7 @@ message_type = "update" [implementation.parameters] MEMBER_LIMIT = "(integer) Number of members to show in the message" + +[templates] +discord = "message.discord.liquid" +gateway = "message.gateway.liquid" diff --git a/notifications/faction-hospital/message.liquid b/notifications/faction-hospital/message.discord.liquid similarity index 100% rename from notifications/faction-hospital/message.liquid rename to notifications/faction-hospital/message.discord.liquid diff --git a/notifications/faction-self-hosp/config.toml b/notifications/faction-self-hosp/config.toml index 82f7b256..255c8951 100644 --- a/notifications/faction-self-hosp/config.toml +++ b/notifications/faction-self-hosp/config.toml @@ -13,3 +13,7 @@ message_type = "send" [implementation.parameters] MINUTES = "(integer) Number of minutes left in the hospital" ONLY_RW = "(string: True or False) Only during RWs" + +[templates] +discord = "message.discord.liquid" +gateway = "message.gateway.liquid" diff --git a/notifications/faction-self-hosp/message.liquid b/notifications/faction-self-hosp/message.discord.liquid similarity index 100% rename from notifications/faction-self-hosp/message.liquid rename to notifications/faction-self-hosp/message.discord.liquid diff --git a/notifications/faction-traveling/config.toml b/notifications/faction-traveling/config.toml index 0162a325..34a62427 100644 --- a/notifications/faction-traveling/config.toml +++ b/notifications/faction-traveling/config.toml @@ -12,3 +12,7 @@ message_type = "update" [implementation.parameters] TRAVEL_METHOD = "(integer) method of travel used" + +[templates] +discord = "message.discord.liquid" +gateway = "message.gateway.liquid" diff --git a/notifications/faction-traveling/message.liquid b/notifications/faction-traveling/message.discord.liquid similarity index 100% rename from notifications/faction-traveling/message.liquid rename to notifications/faction-traveling/message.discord.liquid diff --git a/worker/lib/faction/schema/faction.ex b/worker/lib/faction/schema/faction.ex index da50aa3f..4366c805 100644 --- a/worker/lib/faction/schema/faction.ex +++ b/worker/lib/faction/schema/faction.ex @@ -15,6 +15,8 @@ defmodule Tornium.Schema.Faction do use Ecto.Schema + import Ecto.Query + alias Tornium.Repo @type t :: %__MODULE__{ tid: integer(), @@ -55,4 +57,26 @@ defmodule Tornium.Schema.Faction do field(:last_attacks, :utc_datetime_usec) field(:has_migrated_oc, :boolean) end + + @doc """ + Get a list of AA API keys for a specific faction. + + This will only retrieve default API keys that are not disabled or paused belonging to members of + the specified faction ID with faction AA permissions. + """ + @spec get_api_keys(faction_id :: pos_integer() | __MODULE__.t()) :: [Tornium.Schema.TornKey.t()] + def get_api_keys(%__MODULE__{tid: faction_id}) do + get_api_keys(faction_id) + end + + def get_api_keys(faction_id) when is_integer(faction_id) do + Tornium.Schema.TornKey + |> join(:inner, [k], u in assoc(k, :user), on: u.tid == k.user_id) + |> where([k, u], k.default == true) + |> where([k, u], k.disabled == false) + |> where([k, u], k.paused == false) + |> where([k, u], u.faction_id == ^faction_id) + |> where([k, u], u.faction_aa == true) + |> Repo.all() + end end diff --git a/worker/lib/gateway/schema/token.ex b/worker/lib/gateway/schema/token.ex new file mode 100644 index 00000000..eb57d042 --- /dev/null +++ b/worker/lib/gateway/schema/token.ex @@ -0,0 +1,42 @@ +# Copyright (C) 2021-2025 tiksan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +defmodule Tornium.Schema.GatewayToken do + @moduledoc """ + A short-lived token from the Flask API for use connecting to the Elixir SSE gateway. + """ + + use Ecto.Schema + + @type t :: %__MODULE__{ + guid: Ecto.UUID.t(), + user_id: pos_integer(), + user: Tornium.Schema.User.t(), + token: String.t(), + created_at: DateTime.t(), + created_ip: term(), + expires_at: DateTime.t() + } + + @primary_key {:guid, Ecto.UUID, autogenerate: true} + schema "gateway_token" do + belongs_to(:user, Tornium.Schema.User, references: :tid) + field(:token, :string) + + field(:created_at, :utc_datetime) + field(:created_ip, EctoNetwork.INET) + field(:expires_at, :utc_datetime) + end +end diff --git a/worker/lib/notification/delivery/adapter.ex b/worker/lib/notification/delivery/adapter.ex new file mode 100644 index 00000000..78168a49 --- /dev/null +++ b/worker/lib/notification/delivery/adapter.ex @@ -0,0 +1,10 @@ +defmodule Tornium.Notification.Delivery do + # TODO: Add docs + @callback render(state :: map(), notification :: Tornium.Schema.Notification.t()) :: + {:ok, rendered_template :: String.t()} | {:error, error :: term()} + + @callback validate(rendered :: String.t()) :: {:ok, validated :: map()} | {:error, error :: term()} + + @callback deliver(rendered :: map(), notification :: Tornium.Schema.Notification.t()) :: + {:ok, output :: term()} | {:error, error :: term()} +end diff --git a/worker/lib/notification/delivery/discord.ex b/worker/lib/notification/delivery/discord.ex new file mode 100644 index 00000000..00b0ff91 --- /dev/null +++ b/worker/lib/notification/delivery/discord.ex @@ -0,0 +1,215 @@ +defmodule Tornium.Notification.Delivery.Discord do + require Logger + import Ecto.Query + alias Tornium.Repo + + @behaviour Tornium.Notification.Delivery + + @impl true + def render( + state, + %Tornium.Schema.Notification{ + trigger: %Tornium.Schema.Trigger{message_template: template} + } = _notification + ) + when is_map(state) and is_binary(template) do + # TODO: Improve the error handling upon errors from Solid + # TODO: Test this + # TODO: Document this + + try do + template + |> Solid.parse!() + |> Solid.render!(state) + |> Kernel.to_string() + |> String.replace(["\n", "\t"], "") + rescue + e in Solid.TemplateError -> + e + |> inspect(label: "Template parse error") + |> Logger.debug() + + {:error, e} + + e in Solid.RenderError -> + e + |> inspect(label: "Template render error") + |> Logger.debug() + + {:error, e} + end + end + + @impl true + def validate(rendered) when is_binary(rendered) do + # We want to validate the rendered JSON Discord message to avoid unnecessary Discord API calls + # that would fail. + + # For now, just decoding the message is sufficient, but in the future we could validate + # aspects such as the message length, embed count, etc. + JSON.decode(rendered) + end + + @impl true + def deliver( + rendered, + %Tornium.Schema.Notification{trigger: %Tornium.Schema.Trigger{message_type: message_type}} = notification + ) + when is_map(rendered) do + try_message(rendered, message_type, notification) + end + + defp try_message( + %{} = message, + :send, + %Tornium.Schema.Notification{nid: nid, channel_id: channel_id, one_shot: one_shot?} = notification + ) do + # Valid keys are listed in https://kraigie.github.io/nostrum/Nostrum.Api.html#create_message/2-options + case Nostrum.Api.Message.create(channel_id, message) do + {:ok, %Nostrum.Struct.Message{} = resp_message} -> + # The message was successfully sent... + # Thus the notification should be deleted as one-shot notifications are deleted once triggered + + if one_shot? do + Tornium.Schema.Notification + |> where(nid: ^nid) + |> Repo.delete_all() + end + + {:ok, resp_message} + + {:error, %Nostrum.Error.ApiError{response: %{code: 10_003}} = error} -> + # Discord Opcode 10003: Unknown channel + # The message could be updated as the channel does not exist, so + # - Disable the notification + # - Send a message to the audit channel if possible + + Tornium.Schema.Notification + |> where([n], n.nid == ^nid) + |> update([n], set: [channel_id: nil, enabled: false, error: "Unknown channel"]) + |> Repo.update_all([]) + + Tornium.Notification.Audit.log(:invalid_channel, notification) + {:error, :discord_error, error} + + {:error, %Nostrum.Error.ApiError{response: %{code: code}} = error} -> + # Upon an error, the notification should be disabled with an audit message sent if possible to avoid additional Discord API load + + error_msg = "Nostrum error #{code}" + + Tornium.Schema.Notification + |> where([n], n.nid == ^nid) + |> update([n], set: [enabled: false, error: ^error_msg]) + |> Repo.update_all([]) + + Tornium.Notification.Audit.log(:discord_error, notification, false, error: error) + {:error, :discord_error, error} + end + end + + # TODO: Handle when the channel_id is nil + + defp try_message( + %{} = message, + :update, + %Tornium.Schema.Notification{nid: nid, channel_id: channel_id, message_id: message_id} = notification + ) + when is_nil(message_id) do + # This should only occur the first time the notification is triggered + + # Valid keys are listed in https://kraigie.github.io/nostrum/Nostrum.Api.html#create_message/2-options + case Nostrum.Api.Message.create(channel_id, message) do + {:ok, %Nostrum.Struct.Message{} = resp_message} -> + # The message was successfully sent... + # The notification should be updated to include the message ID + + Tornium.Schema.Notification + |> where([n], n.nid == ^nid) + |> update([n], set: [message_id: ^resp_message.id, channel_id: ^resp_message.channel_id, enabled: true]) + |> Repo.update_all([]) + + {:ok, resp_message} + + {:error, %Nostrum.Error.ApiError{response: %{code: 10_003}} = error} -> + # Discord Opcode 10003: Unknown channel + # The message could be updated as the channel does not exist, so + # - Disable the notification + # - Send a message to the audit channel if possible + + Tornium.Schema.Notification + |> where([n], n.nid == ^nid) + |> update([n], set: [channel_id: nil, enabled: false, error: "Unknown channel"]) + |> Repo.update_all([]) + + Tornium.Notification.Audit.log(:invalid_channel, notification) + {:error, :discord_error, error} + + {:error, %Nostrum.Error.ApiError{response: %{code: code}} = error} -> + # Upon an error, the notification should be disabled with an audit message sent if possible to avoid additional Discord API load + + error_msg = "Nostrum error #{code}" + + Tornium.Schema.Notification + |> where([n], n.nid == ^nid) + |> update([n], set: [enabled: false, error: ^error_msg]) + |> Repo.update_all([]) + + Tornium.Notification.Audit.log(:discord_error, notification, false, error: error) + {:error, :discord_error, error} + end + end + + defp try_message( + %{} = message, + :update, + %Tornium.Schema.Notification{nid: nid, channel_id: channel_id, message_id: message_id} = notification + ) do + # Once the notification is created, the notification's pre-existing message will be updated + # with the new message. If the message is deleted or can't be updated, a new message will be created. + + case Nostrum.Api.Message.edit(channel_id, message_id, message) do + {:ok, %Nostrum.Struct.Message{} = resp_message} -> + # The message was successfully updated and no further action is required + resp_message + + {:error, %Nostrum.Error.ApiError{response: %{code: 10_003}} = error} -> + # Discord Opcode 10003: Unknown channel + # The message could be updated as the channel does not exist, so + # - Disable the notification + # - Send a message to the audit channel if possible + + Tornium.Schema.Notification + |> where([n], n.nid == ^nid) + |> update([n], set: [message_id: nil, channel_id: nil, enabled: false, error: "Unknown channel"]) + |> Repo.update_all([]) + + Tornium.Notification.Audit.log(:invalid_channel, notification) + {:error, :discord_error, error} + + {:error, %Nostrum.Error.ApiError{response: %{code: 10_008}} = _error} -> + # Discord Opcode 10008: Unknown message + # The message couldn't be updated so + # - The message ID should be set to nil + # - The message should be recreated + + {1, [notification]} = + Tornium.Schema.Notification + |> select([n], n) + |> where([n], n.nid == ^nid) + |> update([n], set: [message_id: nil, enabled: false, error: "Unknown message"]) + |> Repo.update_all([]) + + try_message(message, :update, notification) + + {:error, %Nostrum.Error.ApiError{response: %{code: code}} = error} -> + error_msg = "Nostrum error #{code}" + + Tornium.Schema.Notification + |> where([n], n.nid == ^nid) + |> update([n], set: [enabled: false, error: ^error_msg]) + |> Repo.update_all([]) + + {:error, :discord_error, error} + end + end +end diff --git a/worker/lib/notification/delivery/gateway.ex b/worker/lib/notification/delivery/gateway.ex new file mode 100644 index 00000000..e69de29b diff --git a/worker/lib/notification/notification.ex b/worker/lib/notification/notification.ex index 84c0c8da..8f09e084 100644 --- a/worker/lib/notification/notification.ex +++ b/worker/lib/notification/notification.ex @@ -14,294 +14,263 @@ # along with this program. If not, see . defmodule Tornium.Notification do + @moduledoc """ + Notification engine built upon `Lua` and `Solid`. + """ + require Logger - alias Tornium.Repo import Ecto.Query + alias Tornium.Repo @api_call_priority 10 + @typedoc """ + Resources that triggers can be built on top of. These resources represent an in-game "object". + """ @type trigger_resource() :: :user | :faction - @type render_errors() :: {:error, :template_parse_error | :template_render_error, String.t()} + + @type render_errors() :: {:error, Solid.TemplateError.t() | Solid.RenderError.t()} @type render_validation_errors() :: {:error, :template_decode_error, Jason.DecodeError.t()} @doc """ - Execute all notifications against a specific resource (e.g. a certain user ID) with a single API call to retrieve the union. - """ - @spec execute_resource( - resource :: trigger_resource(), - resource_id :: integer(), - notifications :: [Tornium.Schema.Notification.t()] - ) :: [Tornium.Notification.Lua.trigger_return()] | nil - def execute_resource(resource, resource_id, notifications) when is_list(notifications) do - # Determine the union of selections and users for all notifications against a specific resource and resource ID - {selections, users} = - Enum.reduce(notifications, {MapSet.new([]), MapSet.new([])}, fn notification, {s, u} -> - # Use the IDs of admins when the notification is for a Discord server as to avoid issues with missing API keys - - case notification.server do - nil -> - # Use this when the notification is not for a server and is intended to be sent through the SSE gateway - # NOTE: This chunk of the codebase is not implemented yet - { - MapSet.union(s, MapSet.new(notification.trigger.selections)), - MapSet.put(u, notification.user_id) - } - - guild -> - { - MapSet.union(s, MapSet.new(notification.trigger.selections)), - MapSet.union(u, MapSet.new(guild.admins)) - } - end - end) - - restricted = restricted_notification?(notifications) - api_key = get_api_key(users, resource, resource_id, restricted, notifications) - - case api_key do - nil -> - # All notifications need to be disabled at this stage instead of specific notifications - # given the overall lack of API keys - - nids = - Enum.map(notifications, fn %Tornium.Schema.Notification{} = notification -> - Tornium.Notification.Audit.log(:no_api_keys, notification) - - notification.nid - end) - - Tornium.Schema.Notification - |> where([n], n.nid in ^nids) - |> Repo.update_all(set: [enabled: false, error: "No API keys to use"]) - - nil + Retrieve a random API key for a set of notifications. If no keys are available, or there is a permissions + issue, `nil` will be returned. - %Tornium.Schema.TornKey{} -> - api_response = resource_data(resource, resource_id, MapSet.to_list(selections), api_key) - # TODO: Better handle Torn error messages before entering each notification + If `:restricted?` is true, then only a resource owner's API key will be returned and all notifications that + don't belong to a resource owner will be disabled. - notifications - |> Enum.group_by(& &1.trigger) - |> Enum.map(fn {%Tornium.Schema.Trigger{} = trigger, trigger_notifications} -> - api_response - |> filter_response(trigger.resource, trigger.selections) - |> handle_api_response(trigger, trigger_notifications) - end) - - {:error, :restricted} -> - notifications_to_disable = - Enum.filter(notifications, fn %Tornium.Schema.Notification{} = notification -> - notification.trigger.restricted_data - end) - - notification_nids = - Enum.map(notifications_to_disable, fn %Tornium.Schema.Notification{} = notification -> - Tornium.Notification.Audit.log(:restricted, notification) - - notification.nid - end) - - Tornium.Schema.Notification - |> where([n], n.nid in ^notification_nids) - |> Repo.update_all(set: [enabled: false, error: ":restricted"]) - end - end + Otherwise, depending on the resource type, a random server admin or the resourec owner can be utilized. + If a notification is disabled due to a permissions issue, all notifications in the group will be skipped + with a `:skip` return value until the next execution to ensure that only currently enabled notifications + are executed. + """ @spec get_api_key( - admins :: [integer()], resource :: trigger_resource(), resource_id :: integer(), - restricted :: boolean(), + restricted? :: boolean(), notifications :: [Tornium.Schema.Notification.t()] - ) :: Tornium.Schema.TornKey.t() | nil | {:error, :restricted} - defp get_api_key(_admins, :faction, resource_id, true, [ - %Tornium.Schema.Notification{server: %Tornium.Schema.Server{} = server} = _notification | _notifications - ]) do - faction = + ) :: Tornium.Schema.TornKey.t() | nil | :skip + def get_api_key(:faction, resource_id, true, notifications) when is_list(notifications) do + # For notifications against the faction resource utilizing restricted data, all the notifications must be + # sent to servers that are linked with the faction to avoid the leakage of potentially sensitive data. All + # other notifications will be disabled with the `:restricted` reason. + + linked_server_id = Tornium.Schema.Faction |> where([f], f.tid == ^resource_id) - |> select([:guild_id]) + |> join(:inner, [f], s in assoc(f, :guild), on: f.guild_id == s.tid) + |> where([f, s], f.tid in s.factions) + |> select([f, s], f.guild_id) |> Repo.one() - if Enum.member?(server.factions, resource_id) and not is_nil(faction) and faction.guild_id == server.sid do - Tornium.Schema.TornKey - |> join(:inner, [k], u in assoc(k, :user), on: u.tid == k.user_id) - |> where([k, u], k.default == true) - |> where([k, u], k.disabled == false) - |> where([k, u], k.paused == false) - |> where([k, u], u.faction_id == ^resource_id) - |> where([k, u], u.faction_aa == true) - |> select([:api_key, :user_id]) - |> first() - |> Repo.one() + if is_nil(linked_server_id) do + # There is no linked server or the faction ID does not exist so all notifications must be disabled. + Tornium.Schema.Notification.disable(notifications, ":restricted") + # TODO: Send audit message + + nil else - {:error, :restricted} + {_valid_notifications, notifications_to_disable} = + Enum.split_with(notifications, fn notification -> notification.server_id == linked_server_id end) + + # We need to disable notifications that are not for the linked server just so that they don't keep + # running. Server admins can re-enable them if they want to do so. + Tornium.Schema.Notification.disable(notifications_to_disable, ":restricted") + # TODO: Send audit message + + if notifications_to_disable == [] do + api_keys = Tornium.Schema.Faction.get_api_keys(resource_id) + + case api_keys do + [] -> nil + _ -> Enum.random(api_keys) + end + else + :skip + end end end - defp get_api_key(admins, :user, resource_id, true, _notifications) do - if Enum.member?(admins, resource_id) do + def get_api_key(:user, resource_id, true, notifications) do + # For notifications against the user resource utilizing restricted data, all notifications must be created + # by the resource owner to avoid potentially leaking sensitive data of the user. + + {_valid_notifications, notifications_to_disable} = + Enum.split_with(notifications, fn notification -> notification.owner_id == resource_id end) + + Tornium.Schema.Notification.disable(notifications_to_disable, ":restricted") + # TODO: Send audit message + + if notifications_to_disable == [] do Tornium.Schema.TornKey |> where([k], k.user_id == ^resource_id) |> where([k, u], k.default == true) |> where([k, u], k.disabled == false) |> where([k, u], k.paused == false) - |> select([:api_key, :user_id]) |> first() |> Repo.one() else - {:error, :restricted} + :skip end end - defp get_api_key(admins, _resource, _resource_id, false, _notifications) when Kernel.length(admins) == 1 do - Tornium.Schema.TornKey - |> where([k], k.user_id == ^Enum.at(admins, 0)) - |> where([k, u], k.default == true) - |> where([k, u], k.disabled == false) - |> where([k, u], k.paused == false) - |> select([:api_key, :user_id]) - |> first() - |> Repo.one() - end + def get_api_key(_resource, _resource_id, false, notifications) do + # This is the fallback for all notifications that should operate upon any admin of the server as + # the data is public and does not reveal non-public information depending on the caller. + + api_key_users = + notifications + |> Enum.flat_map(fn + %Tornium.Schema.Notification{server_id: nil, user_id: user_id} -> + user_id + + %Tornium.Schema.Notification{server: %Tornium.Schema.Server{admins: server_admins}} + when is_list(server_admins) -> + server_admins + end) + |> Enum.uniq() - defp get_api_key(admins, _resource, _resource_id, false, _notifications) do Tornium.Schema.TornKey - |> where([k], k.user_id in ^MapSet.to_list(admins)) + |> where([k], k.user_id in ^api_key_users) |> where([k, u], k.default == true) |> where([k, u], k.disabled == false) |> where([k, u], k.paused == false) - |> select([:api_key, :user_id]) |> order_by(fragment("RANDOM()")) |> first() |> Repo.one() end - # Determine if any of the notifications for this resource + resource ID requires restricted data - @spec restricted_notification?(notifications :: [Tornium.Schema.Notification.t()]) :: boolean() - defp restricted_notification?(notifications) do - Enum.any?(notifications, fn %Tornium.Schema.Notification{ - trigger: %Tornium.Schema.Trigger{restricted_data: restricted} - } -> - restricted - end) + # TODO: Add tests + @doc """ + Determine a list of selections corresponding to the list of notifications. + """ + @spec get_selections(notifications :: [Tornium.Schema.Notification.t()]) :: [String.t()] + def get_selections([%Tornium.Schema.Notification{} | _] = notifications) do + notifications + |> Enum.map(& &1.trigger.selections) + |> List.flatten() + |> Enum.uniq() end - @spec resource_data( + # TODO: Add tests + @doc """ + Convert the data from notification(s) to a `Tornex.Query` to fetch the data for the notification(s). + """ + @spec to_query( resource :: trigger_resource(), resource_id :: integer(), selections :: [String.t()], api_key :: Tornium.Schema.TornKey.t() - ) :: map() | {:error, any()} - defp resource_data(:user, resource_id, selections, api_key) do - # TODO: Create and use types in Tornex for return types - Tornex.Scheduler.Bucket.enqueue(%Tornex.Query{ + ) :: Tornex.Query.t() + def to_query( + :user, + resource_id, + selections, + %Tornium.Schema.TornKey{api_key: api_key, user_id: api_key_owner} + ) + when is_list(selections) and is_binary(api_key) and is_integer(api_key_owner) do + %Tornex.Query{ resource: "user", resource_id: resource_id, selections: selections, - key: api_key.api_key, - key_owner: api_key.user_id, + key: api_key, + key_owner: api_key_owner, nice: @api_call_priority - }) + } end - defp resource_data(:faction, resource_id, selections, api_key) do - Tornex.Scheduler.Bucket.enqueue(%Tornex.Query{ + def to_query( + :faction, + resource_id, + selections, + %Tornium.Schema.TornKey{api_key: api_key, user_id: api_key_owner} + ) + when is_list(selections) and is_binary(api_key) and is_integer(api_key_owner) do + %Tornex.Query{ resource: "faction", resource_id: resource_id, selections: selections, - key: api_key.api_key, - key_owner: api_key.user_id, + key: api_key, + key_owner: api_key_owner, nice: @api_call_priority - }) + } end - # Filter the API response to only the keys required for the trigger's selection(s). + # TODO: Test this + @doc """ + Filter the API response to only the keys required for the trigger's selection(s). + """ @spec filter_response( - response :: map(), + response :: %{String.t() => term()}, resource :: trigger_resource(), selections :: [String.t()] - ) :: map() - defp filter_response({:error, _}, _resource, _selections) do - # FIXME: Better handle this case - :error - end - - defp filter_response(%{"error" => _error} = response, _resource, _selections) do - # When the response is a Torn error, the error should be passed through this so that the - # error doesn't get filtered out as an invalid key. - - response - end - - defp filter_response(response, resource, selections) when is_list(selections) do + ) :: %{String.t() => term()} + def filter_response(response, resource, selections) when is_list(selections) do valid_keys = - Enum.reduce(selections, MapSet.new([]), fn selection, acc -> - MapSet.union(acc, MapSet.new(Tornium.Notification.Selections.get_selection_keys(resource, selection))) - end) + selections + |> Enum.map(&Tornium.Notification.Selections.get_selection_keys(resource, &1)) + |> List.flatten() + |> Enum.uniq() Map.filter(response, fn {key, _value} -> Enum.member?(valid_keys, key) end) end - # Handle the API response by running the notification's Lua code and generate the message + @doc """ + Handle the API response by running the notification's Lua code and generate the message. + + For each notification, this will perform the following steps: + - Configure a Lua VM with the API response, notification parameters, and the current notification state + - Execute the notification trigger's Lua code in the Lua VM + - Update the passthrough state of the notification + - Render the message from the render state + - Validate the rendered message JSON + - Attempt to send/update the message + """ @spec handle_api_response( - response :: :error | map(), + response :: %{String.t() => term()}, trigger :: Tornium.Schema.Trigger.t(), notifications :: [Tornium.Schema.Notification.t()] - ) :: {:error, Tornium.API.Error.t()} | list(nil) - defp handle_api_response(:error, _trigger, _notifications) do - # NOTE: Temporary solution to unknown errors from Tornex - {:error, :unknown} - end - - defp handle_api_response( - %{"error" => %{"code" => code, "error" => error}} = _response, - _trigger, - _notifications - ) do - {:error, Tornium.API.Error.construct(code, error)} - end - - defp handle_api_response(%{} = response, trigger, notifications) when is_list(notifications) do - Enum.map(notifications, fn %Tornium.Schema.Notification{} = notification -> + ) :: :ok + def handle_api_response(%{} = response, trigger, notifications) when is_list(notifications) do + Enum.each(notifications, fn %Tornium.Schema.Notification{} = notification -> Tornium.Notification.Lua.execute_lua(trigger.code, generate_lua_state_map(notification, response)) |> update_passthrough_state(notification) |> handle_lua_states(notification) end) end - # Handle the returned states (or errors) from the Lua trigger code. If successfully executed, the states will be used - # to create the message to be sent/updated. If there's an error from the Lua code, the notification will be disabled. + # Handle the returned states (or errors) from the Lua trigger code. If successfully executed, the + # states will be used to create the message to be sent/updated. If there's an error from the + # Lua code, the notification will be disabled. @spec handle_lua_states( Tornium.Notification.Lua.trigger_return(), notification :: Tornium.Schema.Notification.t() ) :: nil defp handle_lua_states( {:ok, [triggered?: true, render_state: %{} = render_state, passthrough_state: %{} = _passthrough_state]}, - %Tornium.Schema.Notification{ - trigger: %Tornium.Schema.Trigger{message_template: trigger_message_template, message_type: :update} - } = notification - ) do - render_message(trigger_message_template, render_state) - |> validate_message() - |> try_message(:update, notification) + %Tornium.Schema.Notification{server_id: server_id} = notification + ) + when not is_nil(server_id) do + with {:ok, rendered} <- Tornium.Notification.Delivery.Discord.render(render_state, notification), + {:ok, validated} <- Tornium.Notification.Delivery.Discord.validate(rendered), + {:ok, output} <- Tornium.Notification.Delivery.Discord.deliver(validated, notification) do + {:ok, output} + end end defp handle_lua_states( {:ok, [triggered?: true, render_state: %{} = render_state, passthrough_state: %{} = _passthrough_state]}, - %Tornium.Schema.Notification{ - trigger: %Tornium.Schema.Trigger{message_template: trigger_message_template, message_type: :send} - } = notification - ) do - render_message(trigger_message_template, render_state) - |> validate_message() - |> try_message(:send, notification) + %Tornium.Schema.Notification{server_id: server_id} = notification + ) + when not is_nil(server_id) do end defp handle_lua_states( {:ok, [triggered?: false, render_state: %{} = _render_state, passthrough_state: %{} = _passthrough_state]}, %Tornium.Schema.Notification{} = _notification ) do + # Since the Lua code did not trigger the notification, we are not going to do anything here and ignore everything. nil end @@ -315,13 +284,6 @@ defmodule Tornium.Notification do |> Repo.update_all([]) end - defp handle_lua_states( - {:error, %Tornium.API.Error{} = _torn_error}, - %Tornium.Schema.Notification{} = _notification - ) do - # TODO: Handle this error - end - defp handle_lua_states({:error, reason}, %Tornium.Schema.Notification{} = _notification) do reason |> inspect(label: "Notification error reason") @@ -331,221 +293,6 @@ defmodule Tornium.Notification do {:error, reason} end - defp handle_lua_states(_trigger_return, _notification) do - # Invalid response - throw(Exception) - end - - @spec render_message(template :: String.t(), state :: map()) :: String.t() | render_errors() - def render_message(template, %{} = state) do - # Attempt to render the JSON message for a notification provided the render state and the Solid template. - # TODO: Improve the error handling upon errors from Solid - # TODO: Test this - # TODO: Document this - - try do - template - |> Solid.parse!() - |> Solid.render!(state) - |> Kernel.to_string() - |> String.replace(["\n", "\t"], "") - rescue - e in Solid.TemplateError -> - IO.inspect(e, label: "Template parse error") - {:error, :template_parse_error, e.message} - - e in Solid.RenderError -> - IO.inspect(e, label: "Template render error") - {:error, :template_render_error, e.message} - end - end - - @spec validate_message(message :: String.t() | render_errors()) :: - map() | render_validation_errors() | render_errors() - defp validate_message({:error, _, _} = error) do - error - end - - defp validate_message(message) when is_binary(message) do - # Validate the rendered JSON Discord message to avoid unnecessary Discord API calls. - - case Jason.decode(message) do - {:ok, parsed_message} -> - parsed_message - - {:error, reason} -> - {:error, :template_decode_error, reason} - end - end - - # Attempt to send or update a message for a notification depending on the notification's configuration - @spec try_message( - message :: map() | render_errors() | render_validation_errors(), - action_type :: :send | :update, - notification :: Tornium.Schema.Notification.t() - ) :: - {:ok, Nostrum.Struct.Message.t()} - | {:error, :discord_error, Nostrum.Error.ApiError.t()} - | render_errors() - | render_validation_errors() - defp try_message({:error, _, _} = error, _action_type, _notification) do - error - end - - defp try_message( - %{} = message, - :send, - %Tornium.Schema.Notification{nid: nid, channel_id: channel_id, one_shot: one_shot?} = notification - ) do - # Valid keys are listed in https://kraigie.github.io/nostrum/Nostrum.Api.html#create_message/2-options - case Nostrum.Api.Message.create(channel_id, message) do - {:ok, %Nostrum.Struct.Message{} = resp_message} -> - # The message was successfully sent... - # Thus the notification should be deleted as one-shot notifications are deleted once triggered - - if one_shot? do - Tornium.Schema.Notification - |> where(nid: ^nid) - |> Repo.delete_all() - end - - {:ok, resp_message} - - {:error, %Nostrum.Error.ApiError{response: %{code: 10_003}} = error} -> - # Discord Opcode 10003: Unknown channel - # The message could be updated as the channel does not exist, so - # - Disable the notification - # - Send a message to the audit channel if possible - - Tornium.Schema.Notification - |> where([n], n.nid == ^nid) - |> update([n], set: [channel_id: nil, enabled: false, error: "Unknown channel"]) - |> Repo.update_all([]) - - Tornium.Notification.Audit.log(:invalid_channel, notification) - {:error, :discord_error, error} - - {:error, %Nostrum.Error.ApiError{response: %{code: code}} = error} -> - # Upon an error, the notification should be disabled with an audit message sent if possible to avoid additional Discord API load - - error_msg = "Nostrum error #{code}" - - Tornium.Schema.Notification - |> where([n], n.nid == ^nid) - |> update([n], set: [enabled: false, error: ^error_msg]) - |> Repo.update_all([]) - - Tornium.Notification.Audit.log(:discord_error, notification, false, error: error) - {:error, :discord_error, error} - end - end - - # TODO: Handle when the channel_id is nil - - defp try_message( - %{} = message, - :update, - %Tornium.Schema.Notification{nid: nid, channel_id: channel_id, message_id: message_id} = notification - ) - when is_nil(message_id) do - # This should only occur the first time the notification is triggered - - # Valid keys are listed in https://kraigie.github.io/nostrum/Nostrum.Api.html#create_message/2-options - case Nostrum.Api.Message.create(channel_id, message) do - {:ok, %Nostrum.Struct.Message{} = resp_message} -> - # The message was successfully sent... - # The notification should be updated to include the message ID - - Tornium.Schema.Notification - |> where([n], n.nid == ^nid) - |> update([n], set: [message_id: ^resp_message.id, channel_id: ^resp_message.channel_id, enabled: true]) - |> Repo.update_all([]) - - {:ok, resp_message} - - {:error, %Nostrum.Error.ApiError{response: %{code: 10_003}} = error} -> - # Discord Opcode 10003: Unknown channel - # The message could be updated as the channel does not exist, so - # - Disable the notification - # - Send a message to the audit channel if possible - - Tornium.Schema.Notification - |> where([n], n.nid == ^nid) - |> update([n], set: [channel_id: nil, enabled: false, error: "Unknown channel"]) - |> Repo.update_all([]) - - Tornium.Notification.Audit.log(:invalid_channel, notification) - {:error, :discord_error, error} - - {:error, %Nostrum.Error.ApiError{response: %{code: code}} = error} -> - # Upon an error, the notification should be disabled with an audit message sent if possible to avoid additional Discord API load - - error_msg = "Nostrum error #{code}" - - Tornium.Schema.Notification - |> where([n], n.nid == ^nid) - |> update([n], set: [enabled: false, error: ^error_msg]) - |> Repo.update_all([]) - - Tornium.Notification.Audit.log(:discord_error, notification, false, error: error) - {:error, :discord_error, error} - end - end - - defp try_message( - %{} = message, - :update, - %Tornium.Schema.Notification{nid: nid, channel_id: channel_id, message_id: message_id} = notification - ) do - # Once the notification is created, the notification's pre-existing message will be updated - # with the new message. If the message is deleted or can't be updated, a new message will be created. - - case Nostrum.Api.Message.edit(channel_id, message_id, message) do - {:ok, %Nostrum.Struct.Message{} = resp_message} -> - # The message was successfully updated and no further action is required - resp_message - - {:error, %Nostrum.Error.ApiError{response: %{code: 10_003}} = error} -> - # Discord Opcode 10003: Unknown channel - # The message could be updated as the channel does not exist, so - # - Disable the notification - # - Send a message to the audit channel if possible - - Tornium.Schema.Notification - |> where([n], n.nid == ^nid) - |> update([n], set: [message_id: nil, channel_id: nil, enabled: false, error: "Unknown channel"]) - |> Repo.update_all([]) - - Tornium.Notification.Audit.log(:invalid_channel, notification) - {:error, :discord_error, error} - - {:error, %Nostrum.Error.ApiError{response: %{code: 10_008}} = _error} -> - # Discord Opcode 10008: Unknown message - # The message couldn't be updated so - # - The message ID should be set to nil - # - The message should be recreated - - {1, [notification]} = - Tornium.Schema.Notification - |> select([n], n) - |> where([n], n.nid == ^nid) - |> update([n], set: [message_id: nil, enabled: false, error: "Unknown message"]) - |> Repo.update_all([]) - - try_message(message, :update, notification) - - {:error, %Nostrum.Error.ApiError{response: %{code: code}} = error} -> - error_msg = "Nostrum error #{code}" - - Tornium.Schema.Notification - |> where([n], n.nid == ^nid) - |> update([n], set: [enabled: false, error: ^error_msg]) - |> Repo.update_all([]) - - {:error, :discord_error, error} - end - end - @doc """ Parse the cron tab string of a notification trigger to determine when the next execution will occur. """ @@ -583,7 +330,6 @@ defmodule Tornium.Notification do |> Map.put(:state, state) end - @spec generate_lua_state_map(notification :: Tornium.Schema.Notification.t(), response :: map()) :: map() defp generate_lua_state_map( %Tornium.Schema.Notification{ parameters: parameters, @@ -597,20 +343,6 @@ defmodule Tornium.Notification do |> Map.put(:state, state) end - @spec generate_lua_state_map(notification :: Tornium.Schema.Notification.t(), response :: map()) :: map() - defp generate_lua_state_map( - %Tornium.Schema.Notification{ - parameters: parameters, - trigger: %Tornium.Schema.Trigger{resource: _resource} = _trigger, - previous_state: state - } = _notification, - _response - ) do - # TODO: Add the remaining resources for `generate_lua_state_map/2` - parameters - |> Map.put(:state, state) - end - @spec update_passthrough_state( Tornium.Notification.Lua.trigger_return(), notification :: Tornium.Schema.Notification.t() diff --git a/worker/lib/notification/schema/notification.ex b/worker/lib/notification/schema/notification.ex index 5be907d8..e8eef8fb 100644 --- a/worker/lib/notification/schema/notification.ex +++ b/worker/lib/notification/schema/notification.ex @@ -14,21 +14,28 @@ # along with this program. If not, see . defmodule Tornium.Schema.Notification do + @moduledoc """ + Notification for a specific trigger against a specific resource ID with customized parameters. + """ + use Ecto.Schema @type t :: %__MODULE__{ nid: Ecto.UUID.t(), + trigger_id: Ecto.UUID.t(), trigger: Tornium.Schema.Trigger.t(), + user_id: pos_integer(), user: Tornium.Schema.User.t(), enabled: boolean(), - server: Tornium.Schema.Server.t(), - channel_id: integer(), - resource_id: integer(), + server_id: pos_integer() | nil, + server: Tornium.Schema.Server.t() | nil, + channel_id: pos_integer() | nil, + message_id: pos_integer() | nil, + resource_id: integer() | nil, one_shot: boolean(), - parameters: map(), - message_id: integer() | nil, + parameters: %{String.t() => term()}, error: String.t() | nil, - previous_state: map() + previous_state: %{String.t() => term()} } @primary_key {:nid, Ecto.UUID, autogenerate: true} @@ -48,4 +55,17 @@ defmodule Tornium.Schema.Notification do field(:error, :string) field(:previous_state, :map) end + + # TODO: Add docs and return type + @doc """ + """ + @spec disable(notifications :: [Ecto.UUID.t()], reason :: String.t()) :: :ok + def disable([], _reason) do + :ok + end + + def disable(notifications, reason) when is_list(notifications) and is_binary(reason) do + # TODO: Implement this end + :ok + end end diff --git a/worker/lib/notification/schema/trigger.ex b/worker/lib/notification/schema/trigger.ex index 5cf585e7..f8fc0c9e 100644 --- a/worker/lib/notification/schema/trigger.ex +++ b/worker/lib/notification/schema/trigger.ex @@ -14,21 +14,32 @@ # along with this program. If not, see . defmodule Tornium.Schema.Trigger do + # TODO: Add a summary for the moduledoc + @moduledoc """ + TODO + + If there is no `:next_execution` value, the notification trigger will be executed immediately to determine + when the notification trigger should next be triggered. Otherwise, the notification trigger will be executed + at the next `:next_execution` value as determined by `:cron` during the previous execution. + """ + use Ecto.Schema @type t :: %__MODULE__{ tid: Ecto.UUID.t(), name: String.t(), description: String.t(), + owner_id: pos_integer(), owner: Tornium.Schema.User.t(), cron: String.t(), - next_execution: DateTime.t(), + next_execution: DateTime.t() | nil, resource: :user | :faction | :company | :torn | :faction_v2, selections: [String.t()], code: String.t(), - parameters: map(), + parameters: %{String.t() => String.t()}, message_type: :update | :send, - message_template: String.t(), + message_template: String.t() | nil, + gateway_template: String.t() | nil, restricted_data: boolean(), official: boolean() } @@ -47,9 +58,13 @@ defmodule Tornium.Schema.Trigger do field(:code, :string) field(:parameters, :map) + # Delivery: Discord field(:message_type, Ecto.Enum, values: [:update, :send]) field(:message_template, :string) + # Delivery: SSE Gateway + field(:gateway_template, :string) + field(:restricted_data, :boolean) field(:official, :boolean) end diff --git a/worker/lib/workers/notification.ex b/worker/lib/workers/notification.ex index 7f69fa53..85f52912 100644 --- a/worker/lib/workers/notification.ex +++ b/worker/lib/workers/notification.ex @@ -26,15 +26,93 @@ defmodule Tornium.Workers.Notification do unique: [ period: :infinity, fields: [:worker, :args], - keys: [:resource, :resource_id], + keys: [:resource, :resource_id, :restricted], states: :incomplete ] @impl Oban.Worker def perform( - %Oban.Job{args: %{"notifications" => notifications_ids, "resource" => resource, "resource_id" => resource_id}} = - _job + %Oban.Job{ + args: %{ + "notifications" => notifications_ids, + "resource" => resource, + "resource_id" => resource_id, + "restricted" => _restricted?, + "api_call_id" => api_call_id + } + } = _job ) do + api_call_id + |> Tornium.API.Store.pop() + |> do_perform(notifications_ids, resource, resource_id) + end + + @spec do_perform( + response :: map() | :not_ready | :expired | nil, + notifications_ids :: [String.t()], + resource :: String.t(), + resource_id :: integer() + ) :: Oban.Worker.result() + defp do_perform(response, notifications_ids, _resource, _resource_id) + when is_nil(response) and is_list(notifications_ids) do + # When a notification is cancelled because of an invalid call ID, we still want to update the next execution to ensure that + # the notification is run again within an appropriate timeframe. + + notifications = + Tornium.Schema.Notification + |> where([n], n.nid in ^notifications_ids) + |> join(:inner, [n], t in assoc(n, :trigger), on: t.tid == n.trigger_id) + |> preload([n, t, s], trigger: t) + |> Repo.all() + + notifications + |> Enum.uniq_by(fn %Tornium.Schema.Notification{} = notification -> notification.trigger end) + |> Enum.each(fn %Tornium.Schema.Notification{trigger: %Tornium.Schema.Trigger{} = trigger} -> + Tornium.Notification.update_next_execution(trigger) + end) + + {:cancel, :invalid_call_id} + end + + defp do_perform(:expired = _response, notifications_ids, _resource, _resource_id) when is_list(notifications_ids) do + # When a notification is cancelled because of an expired API call result, we still want to update the next + # execution to ensure that the notification is run again within an appropriate timeframe. + + notifications = + Tornium.Schema.Notification + |> where([n], n.nid in ^notifications_ids) + |> join(:inner, [n], t in assoc(n, :trigger), on: t.tid == n.trigger_id) + |> preload([n, t, s], trigger: t) + |> Repo.all() + + notifications + |> Enum.uniq_by(fn %Tornium.Schema.Notification{} = notification -> notification.trigger end) + |> Enum.each(fn %Tornium.Schema.Notification{trigger: %Tornium.Schema.Trigger{} = trigger} -> + Tornium.Notification.update_next_execution(trigger) + end) + + {:cancel, :expired} + end + + defp do_perform(:not_ready = _response, _notification_ids, _resource, _resource_id) do + {:error, :not_ready} + end + + defp do_perform(%{"error" => %{"code" => error_code}} = _response, _notifications_ids, _resource, _resource_id) + when error_code in [] do + # This handles error codes that need to be handled in a specific manner. Generic Torn API errors can be handled by + # cancelling or erroring the job. + + # TODO: Implement this + end + + defp do_perform(%{"error" => %{"code" => error_code}} = _response, _notifications_ids, _resource, _resource_id) + when is_integer(error_code) do + # TODO: Implement this + end + + defp do_perform(response, notifications_ids, resource, _resource_id) + when is_map(response) and is_list(notifications_ids) do notifications = Tornium.Schema.Notification |> where([n], n.nid in ^notifications_ids) @@ -49,18 +127,19 @@ defmodule Tornium.Workers.Notification do Tornium.Notification.update_next_execution(trigger) end) - Tornium.Notification.execute_resource(String.to_atom(resource), resource_id, notifications) + resource = String.to_atom(resource) - :ok + notifications + |> Enum.group_by(& &1.trigger_id) + |> Enum.each(fn {_trigger_id, [%Tornium.Schema.Notification{trigger: trigger} | _] = trigger_notifications} -> + response + |> Tornium.Notification.filter_response(resource, trigger.selections) + |> Tornium.Notification.handle_api_response(trigger, trigger_notifications) + end) end @impl Oban.Worker - def timeout(%Oban.Job{args: %{"notifications" => notifications}} = _job) when is_list(notifications) do - :timer.minutes(2) - end - def timeout(_job) do - # This condition should never happen but is here for stability :timer.seconds(30) end end diff --git a/worker/lib/workers/notification_scheduler.ex b/worker/lib/workers/notification_scheduler.ex index 300e899b..ecb7f038 100644 --- a/worker/lib/workers/notification_scheduler.ex +++ b/worker/lib/workers/notification_scheduler.ex @@ -14,6 +14,17 @@ # along with this program. If not, see . defmodule Tornium.Workers.NotificationScheduler do + @moduledoc """ + Worker to group and enqueue similar notifications as seperate tasks. + + If the notification is set up to work within a Discord server, the server will need to have a + channel ID and a notification configuration and the server's notification configuration must + be enabled. If the notification is not set up to run within a Discord server, the notification + will be served through the gateway and... + """ + + # TODO: Add conditions for serving the notification through the gateway + require Logger alias Tornium.Repo import Ecto.Query @@ -31,32 +42,103 @@ defmodule Tornium.Workers.NotificationScheduler do @impl Oban.Worker def perform(%Oban.Job{} = _job) do - Logger.debug("Scheduling notifications") now = DateTime.utc_now() - # Schedule notifications for Discord servers where notifications are enabled - # TODO: Determine how this can be modified into query to include notifications outside of Discord servers Tornium.Schema.Notification - |> where([n], not is_nil(n.server_id)) |> where([n], n.enabled == true) |> join(:inner, [n], t in assoc(n, :trigger)) |> where([n, t, s, c], t.next_execution <= ^now or is_nil(t.next_execution)) - |> join(:inner, [n, t], s in assoc(n, :server)) - |> join(:inner, [n, t, s], c in assoc(s, :notifications_config)) - |> where([n, t, s, c], c.enabled == true) + |> join(:left, [n, t], s in assoc(n, :server)) + |> join(:left, [n, t, s], c in assoc(s, :notifications_config)) + |> where( + [n, t, s, c], + (not is_nil(n.server_id) and not is_nil(s.notifications_config_id) and c.enabled == true and + not is_nil(n.channel_id)) or is_nil(n.server_id) + ) |> preload([n, t, s, c], trigger: t) |> Repo.all() |> Enum.group_by(&{&1.trigger.resource, &1.resource_id}) |> Enum.each(fn {{resource, resource_id}, notifications} -> - %{ - resource_id: resource_id, - resource: resource, - notifications: Enum.map(notifications, fn notification -> notification.nid end) - } - |> Tornium.Workers.Notification.new() - |> Oban.insert() + # To be cautious, notifications are split by whether they utilize restricted data. Notifications that + # do will be executed as an owner of the resource. All other notifications will be executed as a + # random server admin/user using the notification. This will prevent the leakage of data in the API + # call that may change for API endpoints with changing responses depending on the caller being a + # resource owner. + + notifications + |> Enum.group_by(fn notification -> notification.trigger.restricted_data end) + |> Enum.each(fn {restricted?, grouped_notifications} -> + start_notifications(resource, resource_id, restricted?, grouped_notifications) + end) end) :ok end + + @doc """ + Start the notifications for a specific resource + resource ID in the Tornex task supervisor and + handoff the processing of the notification to the `Tornium.Workers.Notification` worker. + """ + @spec start_notifications( + resource :: Tornium.Notification.trigger_resource(), + resource_id :: integer(), + restricted? :: boolean(), + notifications :: [Tornium.Schema.Notification.t()] + ) :: term() + def start_notifications(_resource, _resource_id, _restricted?, [] = _notifications) do + nil + end + + def start_notifications(resource, resource_id, restricted?, notifications) when is_binary(resource) do + api_key = Tornium.Notification.get_api_key(resource, resource_id, restricted?, notifications) + + case api_key do + %Tornium.Schema.TornKey{} -> + query = + Tornium.Notification.to_query( + resource, + resource_id, + Tornium.Notification.get_selections(notifications), + api_key + ) + + api_call_id = Ecto.UUID.generate() + Tornium.API.Store.create(api_call_id, 300) + + Task.Supervisor.async_nolink(Tornium.TornexTaskSupervisor, fn -> + query + |> Tornex.Scheduler.Bucket.enqueue() + |> Tornium.API.Store.insert(api_call_id) + end) + + %{ + resource_id: resource_id, + resource: resource, + restricted: restricted?, + notifications: Enum.map(notifications, & &1.nid), + # TODO: Consider adding this later for improved debugging + # origin_job_id: job_id, + api_call_id: api_call_id + } + |> Tornium.Workers.Notification.new(schedule_in: _seconds = 15) + |> Oban.insert() + + nil -> + # All notifications in this group need to be disabled at this stage instead of specific + # notifications given the overall lack of API keys across all the notifications. + + Enum.each(notifications, fn %Tornium.Schema.Notification{} = notification -> + Tornium.Notification.Audit.log(:no_api_keys, notification) + end) + + Tornium.Schema.Notification.disable(notifications, "No API keys to use") + + :skip -> + # This case occurs when there is an API key available but certain notifications had to be + # disabled in `Tornium.Notification.get_api_key/4` due to some permission-related issue with + # a restricted notification. + + nil + end + end end diff --git a/worker/mix.exs b/worker/mix.exs index ce4b0fac..e86eddf8 100644 --- a/worker/mix.exs +++ b/worker/mix.exs @@ -42,6 +42,7 @@ defmodule Tornium.MixProject do {:certifi, "~> 2.14", override: true}, {:ecto, "~> 3.0"}, {:ecto_sql, "~> 3.10"}, + {:ecto_network, "~> 1.6.0"}, # {:nostrum, "~> 0.10"}, {:nostrum, github: "Kraigie/nostrum", ref: "c95d702e476513253a0eff3910fa88fb52e91602"}, {:postgrex, ">= 0.0.0"}, @@ -53,7 +54,7 @@ defmodule Tornium.MixProject do {:oban_web, "~> 2.11"}, {:bandit, "~> 1.8"}, {:lua, "~> 0.4"}, - {:solid, "~> 0.18"}, + {:solid, "~> 1.0"}, {:logger_json, "~> 7.0"}, {:tornex, "~> 0.4"}, {:torngen_elixir_client, ">= 3.0.0"}, diff --git a/worker/mix.lock b/worker/mix.lock index a8e1b7c8..fd909d6f 100644 --- a/worker/mix.lock +++ b/worker/mix.lock @@ -9,9 +9,11 @@ "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, "credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"}, "crontab": {:hex, :crontab, "1.2.0", "503611820257939d5d0fd272eb2b454f48a470435a809479ddc2c40bb515495c", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "ebd7ef4d831e1b20fa4700f0de0284a04cac4347e813337978e25b4cc5cc2207"}, + "date_time_parser": {:hex, :date_time_parser, "1.3.0", "6ba16850b5ab83dd126576451023ab65349e29af2336ca5084aa1e37025b476e", [:mix], [{:kday, "~> 1.0", [hex: :kday, repo: "hexpm", optional: false]}], "hexpm", "93c8203a8ddc66b1f1531fc0e046329bf0b250c75ffa09567ef03d2c09218e8c"}, "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, + "ecto_network": {:hex, :ecto_network, "1.6.0", "f3159c0adb280321b72dc711b3fc7bcc48e2772f71db292a1c3529c587603611", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 0.0.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.14.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "000411a6d3a585845f6809383f20afd418c090e104f862241124ac9518ac1073"}, "ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, @@ -20,6 +22,7 @@ "gun": {:hex, :gun, "2.2.0", "b8f6b7d417e277d4c2b0dc3c07dfdf892447b087f1cc1caff9c0f556b884e33d", [:make, :rebar3], [{:cowlib, ">= 2.15.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "76022700c64287feb4df93a1795cff6741b83fb37415c40c34c38d2a4645261a"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "kday": {:hex, :kday, "1.1.0", "64efac85279a12283eaaf3ad6f13001ca2dff943eda8c53288179775a8c057a0", [:mix], [{:ex_doc, "~> 0.21", [hex: :ex_doc, repo: "hexpm", optional: true]}], "hexpm", "69703055d63b8d5b260479266c78b0b3e66f7aecdd2022906cd9bf09892a266d"}, "logger_json": {:hex, :logger_json, "7.0.4", "e315f2b9a755504658a745f3eab90d88d2cd7ac2ecfd08c8da94d8893965ab5c", [:mix], [{:decimal, ">= 0.0.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d1369f8094e372db45d50672c3b91e8888bcd695fdc444a37a0734e96717c45c"}, "lua": {:hex, :lua, "0.4.0", "de0f04871fdd133cd13a0662690b4fd3ba7a73ca5857493c4665a0a4251908fe", [:mix], [{:luerl, "~> 1.5.1", [hex: :luerl, repo: "hexpm", optional: false]}], "hexpm", "648e17ab9faa1ab1a788fa58ed608366a7026d0eeaec2f311510c065817c4067"}, "luerl": {:hex, :luerl, "1.5.1", "f6700420950fc6889137e7a0c11c4a8467dea04a8c23f707a40d83566d14e786", [:rebar3], [], "hexpm", "abf88d849baa0d5dca93b245a8688d4de2ee3d588159bb2faf51e15946509390"}, @@ -48,7 +51,7 @@ "prom_ex": {:hex, :prom_ex, "1.11.0", "1f6d67f2dead92224cb4f59beb3e4d319257c5728d9638b4a5e8ceb51a4f9c7e", [:mix], [{:absinthe, ">= 1.7.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.1.0", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.11.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.10.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.4", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:peep, "~> 3.0", [hex: :peep, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.20.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.16.0", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.6.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.2", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.1", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "76b074bc3730f0802978a7eb5c7091a65473eaaf07e99ec9e933138dcc327805"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "salchicha": {:hex, :salchicha, "0.4.0", "930cbb172701ed0adff3be0b94c9e0b197988c9a21028f2c33620f5c1982a519", [:mix], [], "hexpm", "e62a8f769f7bd873ac3335c0be79c4eb14e1b7e65e5278cfe09060d36e396bc9"}, - "solid": {:hex, :solid, "0.18.0", "6b20770ab046b0f202bae7454f84013392a93000c719383306eac7514400de7e", [:mix], [{:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7704681c11c880308fe1337acf7690083f884076b612d38b7dccb5a1bd016068"}, + "solid": {:hex, :solid, "1.2.0", "3d177d6c8315b87951ac99b956a29e81d9e32a1411d89b794d55bb9c62fbc7c0", [:mix], [{:date_time_parser, "~> 1.2", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "06696c57a45bee4b3ad21a9e084cc31d94a02fa3014a734128d280fea2f43ef0"}, "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, diff --git a/worker/priv/repo/migrations/20260120040510_add_gateway_notification_template.exs b/worker/priv/repo/migrations/20260120040510_add_gateway_notification_template.exs new file mode 100644 index 00000000..094128f0 --- /dev/null +++ b/worker/priv/repo/migrations/20260120040510_add_gateway_notification_template.exs @@ -0,0 +1,28 @@ +defmodule Tornium.Repo.Migrations.AddGatewayNotificationTemplate do + use Ecto.Migration + + def up do + alter table("notification_trigger") do + add :gateway_template, :text, null: true + modify :message_template, :text, null: true + end + + execute(""" + ALTER TABLE notification_trigger + ADD CONSTRAINT notification_trigger_template_constraint + CHECK (num_nonnulls(message_template, gateway_template) >= 1) + """) + end + + def down do + execute(""" + ALTER TABLE notification_trigger + DROP CONSTRAINT IF EXISTS notification_trigger_template_constraint + """) + + alter table("notification_trigger") do + remove :gateway_template + modify :message_template, :text, null: false + end + end +end diff --git a/worker/priv/repo/migrations/20260120044304_add_gateway_tokens.exs b/worker/priv/repo/migrations/20260120044304_add_gateway_tokens.exs new file mode 100644 index 00000000..00495806 --- /dev/null +++ b/worker/priv/repo/migrations/20260120044304_add_gateway_tokens.exs @@ -0,0 +1,16 @@ +defmodule Tornium.Repo.Migrations.AddGatewayTokens do + use Ecto.Migration + + def change do + create_if_not_exists table("gateway_token", primary_key: false) do + add :guid, :binary, primary_key: true, autogenerate: true + add :user_id, references(:user, column: :tid, type: :integer), null: false + + add :created_at, :utc_datetime, null: false, default: fragment("now()") + add :created_ip, :inet, null: false + add :expires_at, :utc_datetime, null: false + end + + create index(:gateway_token, :user_id) + end +end