Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: Api Server #36

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .env.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
ROD_TOKEN=
ROD_INTENT_FEATURES_ENABLE=
ROD_ADMIN_IDS=
ROD_ADMIN_GUILD=
ROD_DEV=
ROD_DEV_GUILD_ID=
ROD_PREFIX=

ROD_ENABLE_API_SERVER=
API_SERVER_FRONTEND_ORIGIN=
API_SERVER_HOST=
API_SERVER_PORT=

DB_HOST=
DB_PORT=
POSTGRES_PASSWORD=
POSTGRES_USER=
POSTGRES_DB=

JWT_SECRET=
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=
80 changes: 80 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
name: Test

on:
push:
branches:
- rewrite
pull_request:

jobs:
analyze:
name: dart analyze
runs-on: ubuntu-latest
steps:
- name: Setup Dart Action
uses: dart-lang/setup-dart@v1

- name: Checkout
uses: actions/checkout@v4

- name: Cache
uses: actions/cache@v4
with:
path: ~/.pub-cache
key: ${{ runner.os }}-pubspec-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-pubspec-

- name: Install dependencies
run: dart pub get

- name: Analyze project source
run: dart analyze

fix:
name: dart fix
runs-on: ubuntu-latest
steps:
- name: Setup Dart Action
uses: dart-lang/setup-dart@v1

- name: Checkout
uses: actions/checkout@v4

- name: Cache
uses: actions/cache@v4
with:
path: ~/.pub-cache
key: ${{ runner.os }}-pubspec-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-pubspec-

- name: Install dependencies
run: dart pub get

- name: Analyze project source
run: dart fix --dry-run

format:
name: dart format
runs-on: ubuntu-latest
steps:
- name: Setup Dart Action
uses: dart-lang/setup-dart@v1

- name: Checkout
uses: actions/checkout@v4

- name: Cache
uses: actions/cache@v4
with:
path: ~/.pub-cache
key: ${{ runner.os }}-pubspec-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-pubspec-

- name: Install dependencies
run: dart pub get

- name: Format
run: dart format --set-exit-if-changed -l120 .
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ doc/api/

# dotenv environment variables file
.env*
!.env.dist

# IDE configuration folders
.vscode/
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 4.7.0-dev.1
- Implement api server for dashboard
- Bot info collection improvements

## 4.6.1
- Fix unnecessary coma in join logs
- Move number to song title in media info
Expand Down
7 changes: 5 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ format: ## Run dart format
fix: ## Run dart fix
dart fix --apply

fix-project: fix format ## Fix whole project
analyze: ## Run dart analyze
dart analyze

fix-project: fix analyze format ## Fix whole project

run: ## Run dev project
docker compose up --build
docker compose up --build
2 changes: 2 additions & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ include: package:lints/recommended.yaml

analyzer:
exclude: [build/**]
errors:
implementation_imports: ignore
32 changes: 32 additions & 0 deletions bin/console.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import 'package:running_on_dart/src/api/jwt_middleware.dart';

enum Commands {
generateAdminJwt('generate-admin-jwt');

final String name;
const Commands(this.name);

static Commands byName(String name) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, Enum.byName already exists in an extension

return values.firstWhere((e) => e.name == name);
}
}

int main(List<String> args) {
if (args.isEmpty) {
print("Available commands: \n${Commands.values.map((e) => e.name).join(", ")}");
return -1;
}

final command = Commands.byName(args[0]);

switch (command) {
case Commands.generateAdminJwt:
print(generateJwtKey("0", "test-user"));
break;
default:
print("Command '$command' not recognized!");
return -1;
}

return 0;
}
2 changes: 2 additions & 0 deletions bin/running_on_dart.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,6 @@ void main() async {
));

await setupContainer(client);

WebServer().startServer();
}
7 changes: 7 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ services:
- db
depends_on:
- db
networks:
- nginx-proxy_default

db:
image: postgres:17
Expand All @@ -21,3 +23,8 @@ services:

volumes:
rod_db_17:

networks:
nginx-proxy_default:
name: nginx-proxy_default
driver: bridge
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ services:
container_name: running_on_dart
env_file:
- .env
links:
- db
ports:
- "8088:8088"
depends_on:
- db

Expand Down
2 changes: 2 additions & 0 deletions lib/running_on_dart.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,5 @@ export 'src/settings.dart';
export 'src/converter.dart';
export 'src/error_handler.dart';
export 'src/init.dart';

export 'src/api/api_server.dart' show WebServer;
125 changes: 125 additions & 0 deletions lib/src/api/api_server.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import 'dart:convert';

import 'package:injector/injector.dart';
import 'package:nyxx/nyxx.dart';
import 'package:running_on_dart/running_on_dart.dart';
import 'package:running_on_dart/src/api/jwt_middleware.dart';
import 'package:running_on_dart/src/api/utils.dart';
import 'package:running_on_dart/src/services/bot_info.dart';

import 'package:shelf_cors_headers/shelf_cors_headers.dart';
import 'package:shelf_router/shelf_router.dart' as shelf_router;
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as shelf_io;

import 'package:http/http.dart' as http;

final clientId = getEnv('DISCORD_CLIENT_ID');
final clientSecret = getEnv('DISCORD_CLIENT_SECRET');
final clientRedirectUri = getEnv('DISCORD_REDIRECT_URI');

enum WebApiPermission {
guilds('G');

final String name;

const WebApiPermission(this.name);
}

class WebServer {
final Logger _logger = Logger('ROD.ApiServer');

Future<shelf.Response> _handleBotInfo(shelf.Request request) async {
final botInfo = await Injector.appInstance.get<BotInfoService>().getCurrentBotInfo();

return createOkResponse(botInfo.toJson());
}

Future<shelf.Response> _handleGuildInfo(shelf.Request request) async {
final outputJson = Injector.appInstance.get<NyxxGateway>().guilds.cache.values.map((guild) {
return <String, dynamic>{
"id": guild.id.toString(),
"icon_hash": guild.iconHash,
"banner_hash": guild.bannerHash,
"name": guild.name,
"cached_members": guild.members.cache.length,
"cached_roles": guild.roles.cache.length,
};
});

return createOkResponse(outputJson.toList());
}

Future<shelf.Response> _handleLogin(shelf.Request request) async {
final requestBody = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
final authCode = requestBody['code'];

final response = await http.post(Uri.https('discord.com', '/api/oauth2/token'), body: {
'client_id': clientId,
'client_secret': clientSecret,
'redirect_uri': clientRedirectUri,
'grant_type': 'authorization_code',
'code': authCode,
}, headers: {
'Content-Type': 'application/x-www-form-urlencoded',
});

if (response.statusCode != 200) {
return createJsonErrorResponse(400, "Cannot login through discord. Try again");
}

final responseBody = jsonDecode(response.body) as Map<String, dynamic>;
final accessToken = responseBody['access_token'];

final authorizedUserResponse = await http.get(Uri.https('discord.com', '/api/oauth2/@me'),
headers: {"Accept": 'application/json', 'Authorization': 'Bearer $accessToken'});

if (authorizedUserResponse.statusCode != 200) {
return createJsonErrorResponse(400, "Cannot fetch user data from discord!");
}

final authorizedUserResponseBody = jsonDecode(authorizedUserResponse.body) as Map<String, dynamic>;

final userName =
authorizedUserResponseBody['user']['global_name'] ?? authorizedUserResponseBody['user']['username'];
final jwtToken = generateJwtKey(authorizedUserResponseBody['user']['id'], userName);

return createOkResponse({
'token': jwtToken,
'user': {
'id': authorizedUserResponseBody['user']['id'],
'name': userName,
'avatar_hash': authorizedUserResponseBody['user']['avatar'],
},
});
}

Future<shelf_router.Router> _setupRouter() async {
return shelf_router.Router()
..get("/api/info", _handleBotInfo)
..post("/api/login", _handleLogin)
..get("/api/guilds", _authorized(_handleGuildInfo, [WebApiPermission.guilds]));
}

shelf.Handler _authorized(shelf.Handler inner, [List<WebApiPermission> requiredRoles = const []]) =>
shelf.Pipeline().addMiddleware(jwtMiddleware(requiredRoles.map((e) => e.name).toList())).addHandler(inner);

Future<void> startServer() async {
if (!enableApiServer) {
_logger.info("Api server disabled...");
return;
}

final router = await _setupRouter();

final corsOriginChecker = dev ? originAllowAll : originOneOf([getEnv('API_SERVER_FRONTEND_ORIGIN')]);

final app = const shelf.Pipeline()
.addMiddleware(shelf.logRequests())
.addMiddleware(corsHeaders(originChecker: corsOriginChecker))
.addHandler(router.call);

_logger.info("Starting api server at `$apiServerHost:$apiServerPort`");
await shelf_io.serve(app, apiServerHost, apiServerPort);
}
}
57 changes: 57 additions & 0 deletions lib/src/api/jwt_middleware.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import 'package:jaguar_jwt/jaguar_jwt.dart';
import 'package:nyxx/nyxx.dart';
import 'package:running_on_dart/running_on_dart.dart';
import 'package:running_on_dart/src/api/api_server.dart';
import 'package:running_on_dart/src/api/utils.dart';
import 'package:shelf/shelf.dart' as shelf;

final jwtKey = getEnv("JWT_SECRET");

class MissingPermissionsException implements Exception {}

String generateJwtKey(String discordUserId, String userName) {
final perms = adminIds.contains(Snowflake.parse(discordUserId)) ? WebApiPermission.values.map((e) => e.name) : [];

final jwtClaim = JwtClaim(
subject: discordUserId,
maxAge: Duration(days: 1),
payload: {
"name": userName,
'perms': perms,
},
);

return issueJwtHS256(jwtClaim, jwtKey);
}

void validateClaims(JwtClaim jwt, List<String> requiredPermissions) {
final permissions = (jwt.payload['perms'] as List<dynamic>? ?? []).cast<String>();
final valid = Set.of(permissions).containsAll(requiredPermissions);
if (!valid) {
throw MissingPermissionsException();
}
}

shelf.Middleware jwtMiddleware([List<String> requiredPermissions = const []]) => (shelf.Handler handler) {
return (shelf.Request request) {
final authHeader = request.headers['Authorization'];

if (authHeader == null) {
return createUnauthorizedResponse('Missing authorization header');
}

try {
final jwt = verifyJwtHS256Signature(authHeader.replaceFirst('Bearer ', ''), jwtKey);

if (requiredPermissions.isNotEmpty) {
validateClaims(jwt, requiredPermissions);
}
} on JwtException catch (e) {
return createUnauthorizedResponse(e.message);
} on MissingPermissionsException {
return createForbiddenResponse();
}

return handler(request);
};
};
Loading