diff --git a/.env.example b/.env.example index fb5d28e..f3d415a 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,2 @@ -MASTER_API_KEY="SOME_API_KEY" \ No newline at end of file +MASTER_API_KEY="SOME_API_KEY" +CONFIG_FILE="./config/example.json" \ No newline at end of file diff --git a/README.md b/README.md index cc11047..1a8c2a5 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ You can run the example with the [Dart SDK](https://dart.dev/get-dart) like this: ```text -$ dart run bin/server.dart +$ dart run bin/appitoolbox.dart Server listening on port 8080 ``` diff --git a/bin/api_handler.dart b/bin/api_handler.dart index 886cd1b..07e3f74 100644 --- a/bin/api_handler.dart +++ b/bin/api_handler.dart @@ -1,21 +1,10 @@ -import 'package:shelf/shelf.dart'; -import 'package:shelf_router/shelf_router.dart'; import 'package:http/http.dart' as http; -import 'package:dotenv/dotenv.dart' as dotenv; +import 'package:shelf_plus/shelf_plus.dart'; + +import 'api_middleware.dart'; class ApiHandler { Future _universalGetHandler(Request request) async { - if (request.headers['X-AppiToolbox-ApiKey'] == null) { - return Response.forbidden( - 'No API Key provided. Use header X-AppiToolbox-ApiKey'); - } - if (request.headers['X-AppiToolbox-ApiKey'] != - dotenv.env['MASTER_API_KEY']) { - return Response.forbidden('Wrong value of X-AppiToolbox-ApiKey'); - } - if (request.params['url'] == null) { - return Response.notFound("You must supply valid URL."); - } Uri url = Uri.parse("https://" + request.params['url']!); print("got url"); Map headers = Map.from(request.headers); @@ -37,18 +26,6 @@ class ApiHandler { } Future _universalPostHandler(Request request) async { - if (request.headers['X-AppiToolbox-ApiKey'] == null) { - return Response.forbidden( - 'No API Key provided. Use header X-AppiToolbox-ApiKey'); - } - if (request.headers['X-AppiToolbox-ApiKey'] != - dotenv.env['MASTER_API_KEY']) { - return Response.forbidden('Wrong value of X-AppiToolbox-ApiKey'); - } - - if (request.params['url'] == null) { - return Response.notFound("You must supply valid URL."); - } Uri url = Uri.parse("https://" + request.params['url']!); Map headers = Map.from(request.headers); print(headers); @@ -76,12 +53,12 @@ class ApiHandler { // By exposing a [Router] for an object, it can be mounted in other routers. Router get router { - final router = Router(); + final router = Router().plus; router - ..get('/', _universalGetHandler) - ..post('/', _universalPostHandler); + ..get('/', _universalGetHandler, use: apiMiddleware()) + ..post('/', _universalPostHandler, use: apiMiddleware()); - return router; + return router.shelfRouter; } } diff --git a/bin/api_middleware.dart b/bin/api_middleware.dart new file mode 100644 index 0000000..644d0ba --- /dev/null +++ b/bin/api_middleware.dart @@ -0,0 +1,59 @@ +import 'dart:async'; + +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; +import 'model/status.dart'; +import 'utils/globals.dart' as globals; +import 'package:dotenv/dotenv.dart' as dotenv; + +Middleware apiMiddleware({ + FutureOr Function(Request)? requestHandler, + FutureOr Function(Response)? responseHandler, + FutureOr Function(Object error, StackTrace)? errorHandler, +}) { + requestHandler ??= (request) => null; + responseHandler ??= (response) => response; + + FutureOr Function(Object, StackTrace)? onError; + if (errorHandler != null) { + onError = (error, stackTrace) { + if (error is HijackException) throw error; + return errorHandler(error, stackTrace); + }; + } + + return (Handler innerHandler) { + return (request) { + if (request.headers['X-AppiToolbox-ApiKey'] == null) { + return Response.forbidden( + 'No API Key provided. Use header X-AppiToolbox-ApiKey'); + } + if (request.headers['X-AppiToolbox-ApiKey'] != + dotenv.env['MASTER_API_KEY']) { + return Response.forbidden('Wrong value of X-AppiToolbox-ApiKey'); + } + if (request.params['url'] == null) { + return Response.notFound("You must supply valid URL."); + } + + String requestedEndpoint = request.params['url']!; + Status status = globals.endpointConfigService + .endpointStatus(requestedEndpoint, request.method); + + if (status == Status.undefined) { + status = globals.globalConfigService.defaultStatus(request.method); + } + + if (status == Status.blocked) { + return Response.forbidden("This endpoint is blocked."); + } + + return Future.sync(() => requestHandler!(request)).then((response) { + if (response != null) return response; + + return Future.sync(() => innerHandler(request)) + .then((response) => responseHandler!(response), onError: onError); + }); + }; + }; +} diff --git a/bin/appitoolbox.dart b/bin/appitoolbox.dart index 924e781..018dfa6 100644 --- a/bin/appitoolbox.dart +++ b/bin/appitoolbox.dart @@ -4,6 +4,7 @@ import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart'; import 'package:shelf_router/shelf_router.dart'; import 'package:dotenv/dotenv.dart' as dotenv; +import 'utils/globals.dart' as globals; import 'api_handler.dart'; @@ -61,6 +62,8 @@ void main(List args) async { final port = int.parse(Platform.environment['PORT'] ?? '8080'); final server = await serve(_handler, ip, port); dotenv.load(); + globals.endpointConfigService.init(); + globals.globalConfigService.init(); print('Server listening on port ${server.port}'); ProcessSignal.sigint.watch().listen((ProcessSignal signal) { diff --git a/bin/model/endpoint_config.dart b/bin/model/endpoint_config.dart new file mode 100644 index 0000000..1d93494 --- /dev/null +++ b/bin/model/endpoint_config.dart @@ -0,0 +1,73 @@ +import 'method.dart'; +import 'status.dart'; + +class EndpointConfig { + String? title; + String? url; + Method method; + Status status; + bool isDefault; + + EndpointConfig( + {this.title, + this.url, + this.method = Method.undefined, + this.status = Status.undefined, + this.isDefault = false}); + + factory EndpointConfig.fromJson(Map json, + {bool isDefault = false}) { + late Status status; + late Method method; + if (json['title'] == null && !isDefault) { + throw Exception("Title is required"); + } + if (json['url'] == null && !isDefault) { + throw Exception("URL is required"); + } + if (json['method'] == null) { + method = Method.undefined; + } else { + try { + method = Method.values.byName(json['method']!); + } catch (e) { + method = Method.undefined; + } + } + if (json['status'] == null) { + status = Status.undefined; + } else { + try { + status = Status.values.byName(json['status']!); + } catch (e) { + status = Status.undefined; + } + } + + return EndpointConfig( + title: json['title'], + url: json['url'], + method: method, + status: status, + isDefault: isDefault); + } + + Status? getStatusFromString(String status) { + status = 'Status.$status'; + return Status.values.firstWhere((f) => f.toString() == status, + orElse: () => Status.undefined); + } + + Method? getMethodFromString(String method) { + method = 'Method.$method'; + return Method.values.firstWhere((f) => f.toString() == method, + orElse: () => Method.undefined); + } + + Map toJson() { + final Map data = {}; + data['title'] = title; + data['url'] = url; + return data; + } +} diff --git a/bin/model/global_config.dart b/bin/model/global_config.dart new file mode 100644 index 0000000..0f619c2 --- /dev/null +++ b/bin/model/global_config.dart @@ -0,0 +1,33 @@ +import 'endpoint_config.dart'; +import 'status.dart'; + +class GlobalConfig { + Status defaultStatus; + List defaultEndpoints; + + GlobalConfig({ + required this.defaultStatus, + required this.defaultEndpoints, + }); + + factory GlobalConfig.fromJson(Map json) { + Status defaultStatus; + if (json['defaultStatus'] == null) { + throw Exception("defaultStatus is required"); + } else { + try { + defaultStatus = Status.values.byName(json['defaultStatus']!); + } catch (e) { + throw Exception("defaultStatus have invalid value"); + } + } + + return GlobalConfig( + defaultStatus: defaultStatus, + defaultEndpoints: (json['defaultEndpoints'] as List) + .map((e) => EndpointConfig.fromJson(e as Map, + isDefault: true)) + .toList(), + ); + } +} diff --git a/bin/model/method.dart b/bin/model/method.dart new file mode 100644 index 0000000..f876b78 --- /dev/null +++ b/bin/model/method.dart @@ -0,0 +1,13 @@ +enum Method { + get, + head, + post, + put, + delete, + connect, + options, + trace, + patch, + undefined, + all +} diff --git a/bin/model/status.dart b/bin/model/status.dart new file mode 100644 index 0000000..d2f0044 --- /dev/null +++ b/bin/model/status.dart @@ -0,0 +1 @@ +enum Status { blocked, allowed, undefined } diff --git a/bin/services/endpoint_config.dart b/bin/services/endpoint_config.dart new file mode 100644 index 0000000..e59a928 --- /dev/null +++ b/bin/services/endpoint_config.dart @@ -0,0 +1,54 @@ +import '../model/endpoint_config.dart'; +import '../model/method.dart'; +import '../model/status.dart'; +import '../utils/globals.dart' as globals; + +class EndpointConfigService { + List configs = []; + bool loaded = false; + + Future init() async { + await _loadEndpoints(); + loaded = true; + } + + Future _loadEndpoints() async { + if (!globals.configLoaded) { + await globals.loadConfig(); + } + final json = globals.config; + for (var item in json["endpoints"]) { + configs.add(EndpointConfig.fromJson(item)); + } + return true; + } + + Status endpointStatus(String url, String methodS) { + Method method; + try { + method = Method.values.byName(methodS.toLowerCase()); + } catch (e) { + method = Method.undefined; + } + if (!loaded) { + throw Exception("You need to load config first"); + } + for (var item in configs) { + if (item.method != method && + item.method != Method.undefined && + item.method != Method.all) { + break; + } + if (item.url == url) { + return item.status; + } + if (item.url == "https://" + url) { + return item.status; + } + if (item.url == "http://" + url) { + return item.status; + } + } + return Status.undefined; + } +} diff --git a/bin/services/global_config.dart b/bin/services/global_config.dart new file mode 100644 index 0000000..01d2594 --- /dev/null +++ b/bin/services/global_config.dart @@ -0,0 +1,44 @@ +import 'dart:io'; + +import '../model/global_config.dart'; +import '../model/method.dart'; +import '../model/status.dart'; +import '../utils/globals.dart' as globals; + +class GlobalConfigService { + late GlobalConfig globalConfig; + var defaultConfig = File('./config/example.json'); + bool loaded = false; + + Future init() async { + await loadConfig(); + loaded = true; + } + + Future loadConfig() async { + if (!globals.configLoaded) { + await globals.loadConfig(); + } + final json = globals.config; + globalConfig = GlobalConfig.fromJson(json["global"]); + } + + Status defaultStatus(String methodS) { + Method method; + try { + method = Method.values.byName(methodS.toLowerCase()); + } catch (e) { + method = Method.undefined; + } + if (!loaded) { + throw Exception("You need to load config first"); + } + try { + return globalConfig.defaultEndpoints + .firstWhere((endpoint) => endpoint.method == method) + .status; + } catch (e) { + return globalConfig.defaultStatus; + } + } +} diff --git a/bin/utils/globals.dart b/bin/utils/globals.dart new file mode 100644 index 0000000..03fab64 --- /dev/null +++ b/bin/utils/globals.dart @@ -0,0 +1,18 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../services/endpoint_config.dart'; +import '../services/global_config.dart'; +import 'package:dotenv/dotenv.dart' as dotenv; + +EndpointConfigService endpointConfigService = EndpointConfigService(); +GlobalConfigService globalConfigService = GlobalConfigService(); +late Map config; +bool configLoaded = false; + +loadConfig() async { + String configPath = dotenv.env['CONFIG_FILE'] ?? "./config/example.json"; + File configFile = File(configPath); + config = jsonDecode(await configFile.readAsString()); + configLoaded = true; +} diff --git a/config/example.json b/config/example.json new file mode 100644 index 0000000..301f82b --- /dev/null +++ b/config/example.json @@ -0,0 +1,25 @@ +{ + "global" : { + "defaultStatus" : "blocked", + "defaultEndpoints" : [ + { + "method" : "get", + "status" : "allowed" + } + ] + }, + "endpoints": [ + { + "title": "example site", + "url": "https://example.com/", + "method" : "get", + "status" : "blocked" + }, + { + "title": "Create employee", + "url": "https://dummy.restapiexample.com/api/v1/create", + "method" : "post", + "status" : "blocked" + } + ] +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index d27476c..1ee503e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -70,7 +70,7 @@ packages: name: coverage url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.0.3" crypto: dependency: transitive description: @@ -106,6 +106,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.2" + hotreloader: + dependency: transitive + description: + name: hotreloader + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" http: dependency: "direct dev" description: @@ -183,6 +190,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.1" + mime_type: + dependency: transitive + description: + name: mime_type + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" node_preamble: dependency: transitive description: @@ -225,6 +239,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.0" + shelf_hotreload: + dependency: transitive + description: + name: shelf_hotreload + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" shelf_packages_handler: dependency: transitive description: @@ -232,6 +253,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.0" + shelf_plus: + dependency: "direct main" + description: + name: shelf_plus + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" shelf_router: dependency: "direct main" description: @@ -288,6 +316,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" + stream_transform: + dependency: transitive + description: + name: stream_transform + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" string_scanner: dependency: transitive description: @@ -295,6 +330,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.0" + supercharged_dart: + dependency: transitive + description: + name: supercharged_dart + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" term_glyph: dependency: transitive description: @@ -343,7 +385,7 @@ packages: name: vm_service url: "https://pub.dartlang.org" source: hosted - version: "8.1.0" + version: "7.5.0" watcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3a734c6..3e4a572 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: args: ^2.0.0 dotenv: ^3.0.0 shelf: ^1.1.0 + shelf_plus: ^1.2.1 shelf_router: ^1.0.0 dev_dependencies: