forked from Nekojimi/JackIt
312 lines
10 KiB
GDScript
312 lines
10 KiB
GDScript
## A routable HTTP server for Godot
|
|
##
|
|
## Provides a web server with routes for specific endpoints
|
|
## [br]Example usage:
|
|
## [codeblock]
|
|
## var server := HttpServer.new()
|
|
## server.register_router("/", MyExampleRouter.new())
|
|
## add_child(server)
|
|
## server.start()
|
|
## [/codeblock]
|
|
|
|
class_name HttpServer
|
|
extends Node
|
|
|
|
## The ip address to bind the server to. Use * for all IP addresses [*]
|
|
@export var bind_address: String = "*"
|
|
|
|
## The port to bind the server to. [8080]
|
|
@export var port: int = 8080
|
|
|
|
## The server identifier to use when responding to requests [GodotTPD]
|
|
@export var server_identifier: String = "GodotTPD"
|
|
|
|
# If `HttpRequest`s and `HttpResponse`s should be logged
|
|
@export var _logging: bool = false
|
|
|
|
# The TCP server instance used
|
|
var _server: TCPServer
|
|
|
|
# An array of StraemPeerTCP objects who are currently talking to the server
|
|
var _clients: Array
|
|
|
|
# A list of HttpRequest routers who could handle a request
|
|
var _routers: Array = []
|
|
|
|
# A regex identifiying the method line
|
|
var _method_regex: RegEx = RegEx.new()
|
|
|
|
# A regex for header lines
|
|
var _header_regex: RegEx = RegEx.new()
|
|
|
|
# The base path used in a project to serve files
|
|
var _local_base_path: String = "res://src"
|
|
|
|
# list of host allowed to call the server
|
|
var _allowed_origins: PackedStringArray = []
|
|
|
|
# Comma separed methods for the access control
|
|
var _access_control_allowed_methods = "POST, GET, OPTIONS"
|
|
|
|
# Comma separed headers for the access control
|
|
var _access_control_allowed_headers = "content-type"
|
|
|
|
# Compile the required regex
|
|
func _init(_logging: bool = false):
|
|
self._logging = _logging
|
|
set_process(false)
|
|
_method_regex.compile("^(?<method>GET|POST|HEAD|PUT|PATCH|DELETE|OPTIONS) (?<path>[^ ]+) HTTP/1.1$")
|
|
_header_regex.compile("^(?<key>[\\w-]+): (?<value>(.*))$")
|
|
|
|
# Print a debug message in console, if the debug mode is enabled
|
|
#
|
|
# #### Parameters
|
|
# - message: The message to be printed (only in debug mode)
|
|
func _print_debug(message: String) -> void:
|
|
var time = Time.get_datetime_dict_from_system()
|
|
var time_return = "%02d-%02d-%02d %02d:%02d:%02d" % [time.year, time.month, time.day, time.hour, time.minute, time.second]
|
|
print_debug("[SERVER] ",time_return," >> ", message)
|
|
|
|
## Register a new router to handle a specific path
|
|
## [br]
|
|
## [br][param path] - The path the router will handle.
|
|
## Supports a regular expression and the group matches will be available in HttpRequest.query_match.
|
|
## [br][param router] - The router which will handle the request
|
|
func register_router(path: String, router: HttpRouter):
|
|
var path_regex = RegEx.new()
|
|
var params: Array = []
|
|
if path.left(0) == "^":
|
|
path_regex.compile(path)
|
|
else:
|
|
var regexp: Array = _path_to_regexp(path, router is HttpFileRouter)
|
|
path_regex.compile(regexp[0])
|
|
params = regexp[1]
|
|
_routers.push_back({
|
|
"path": path_regex,
|
|
"params": params,
|
|
"router": router
|
|
})
|
|
|
|
|
|
## Handle possibly incoming requests
|
|
func _process(_delta: float) -> void:
|
|
if _server:
|
|
while _server.is_connection_available():
|
|
var new_client = _server.take_connection()
|
|
if new_client:
|
|
self._clients.append(new_client)
|
|
for client in self._clients:
|
|
client.poll()
|
|
if client.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
|
var bytes = client.get_available_bytes()
|
|
if bytes > 0:
|
|
var request_string = client.get_utf8_string(bytes)
|
|
self._handle_request(client, request_string)
|
|
_remove_disconnected_clients()
|
|
|
|
|
|
func _remove_disconnected_clients():
|
|
var valid_statuses = [StreamPeerTCP.STATUS_CONNECTED, StreamPeerTCP.STATUS_CONNECTING]
|
|
self._clients = self._clients.filter(
|
|
func(c: StreamPeerTCP): return valid_statuses.has(c.get_status())
|
|
)
|
|
|
|
|
|
## Start the server
|
|
func start():
|
|
set_process(true)
|
|
self._server = TCPServer.new()
|
|
var err: int = self._server.listen(self.port, self.bind_address)
|
|
match err:
|
|
22:
|
|
_print_debug("Could not bind to port %d, already in use" % [self.port])
|
|
stop()
|
|
_:
|
|
_print_debug("HTTP Server listening on http://%s:%s" % [self.bind_address, self.port])
|
|
|
|
|
|
## Stop the server and disconnect all clients
|
|
func stop():
|
|
for client in self._clients:
|
|
client.disconnect_from_host()
|
|
self._clients.clear()
|
|
self._server.stop()
|
|
set_process(false)
|
|
_print_debug("Server stopped.")
|
|
|
|
|
|
# Interpret a request string and perform the request
|
|
#
|
|
# #### Parameters
|
|
# - client: The client that send the request
|
|
# - request: The received request as a String
|
|
func _handle_request(client: StreamPeer, request_string: String):
|
|
var request = HttpRequest.new()
|
|
for line in request_string.split("\r\n"):
|
|
var method_matches = _method_regex.search(line)
|
|
var header_matches = _header_regex.search(line)
|
|
if method_matches:
|
|
request.method = method_matches.get_string("method")
|
|
var request_path: String = method_matches.get_string("path")
|
|
# Check if request_path contains "?" character, could be a query parameter
|
|
if not "?" in request_path:
|
|
request.path = request_path
|
|
else:
|
|
var path_query: PackedStringArray = request_path.split("?")
|
|
request.path = path_query[0]
|
|
request.query = _extract_query_params(path_query[1])
|
|
request.headers = {}
|
|
request.body = ""
|
|
elif header_matches:
|
|
request.headers[header_matches.get_string("key")] = \
|
|
header_matches.get_string("value")
|
|
else:
|
|
request.body += line
|
|
self._perform_current_request(client, request)
|
|
|
|
|
|
# Handle a specific request and send it to a router
|
|
# If no router matches, send a 404
|
|
#
|
|
# #### Parameters
|
|
# - client: The client that send the request
|
|
# - request_info: A dictionary with information about the request
|
|
# - method: The method of the request (e.g. GET, POST)
|
|
# - path: The requested path
|
|
# - headers: A dictionary of headers of the request
|
|
# - body: The raw body of the request
|
|
func _perform_current_request(client: StreamPeer, request: HttpRequest):
|
|
_print_debug("HTTP Request: " + str(request))
|
|
var found = false
|
|
var is_allowed_origin = false
|
|
var response = HttpResponse.new()
|
|
var fetch_mode = ""
|
|
var origin = ""
|
|
response.client = client
|
|
response.server_identifier = server_identifier
|
|
|
|
if request.headers.has("Sec-Fetch-Mode"):
|
|
fetch_mode = request.headers["Sec-Fetch-Mode"]
|
|
elif request.headers.has("sec-fetch-mode"):
|
|
fetch_mode = request.headers["sec-fetch-mode"]
|
|
|
|
if request.headers.has("Origin"):
|
|
origin = request.headers["Origin"]
|
|
elif request.headers.has("origin"):
|
|
origin = request.headers["origin"]
|
|
|
|
if _allowed_origins.has(origin):
|
|
is_allowed_origin = true
|
|
response.access_control_origin = origin
|
|
|
|
response.access_control_allowed_methods = _access_control_allowed_methods
|
|
response.access_control_allowed_headers = _access_control_allowed_headers
|
|
|
|
for router in self._routers:
|
|
var matches = router.path.search(request.path)
|
|
if matches:
|
|
request.query_match = matches
|
|
if request.query_match.get_string("subpath"):
|
|
request.path = request.query_match.get_string("subpath")
|
|
if router.params.size() > 0:
|
|
for parameter in router.params:
|
|
request.parameters[parameter] = request.query_match.get_string(parameter)
|
|
match request.method:
|
|
"GET":
|
|
found = true
|
|
router.router.handle_get(request, response)
|
|
"POST":
|
|
found = true
|
|
router.router.handle_post(request, response)
|
|
"HEAD":
|
|
found = true
|
|
router.router.handle_head(request, response)
|
|
"PUT":
|
|
found = true
|
|
router.router.handle_put(request, response)
|
|
"PATCH":
|
|
found = true
|
|
router.router.handle_patch(request, response)
|
|
"DELETE":
|
|
found = true
|
|
router.router.handle_delete(request, response)
|
|
"OPTIONS":
|
|
if _allowed_origins.size() > 0 && fetch_mode == "cors":
|
|
if is_allowed_origin:
|
|
response.send(204)
|
|
else:
|
|
response.send(400, "%s is not present in the allowed origins" % origin)
|
|
|
|
return
|
|
|
|
found = true
|
|
router.router.handle_options(request, response)
|
|
break
|
|
if not found:
|
|
response.send(404, "Not found")
|
|
|
|
|
|
# Converts a URL path to @regexp RegExp, providing a mechanism to fetch groups from the expression
|
|
# indexing each parameter by name in the @params array
|
|
#
|
|
# #### Parameters
|
|
# - path: The path of the HttpRequest
|
|
# - should_match_subfolder: (dafult [false]) if subfolders should be matched and grouped,
|
|
# used for HttpFileRouter
|
|
#
|
|
# Returns: A 2D array, containing a @regexp String and Dictionary of @params
|
|
# [0] = @regexp --> the output expression as a String, to be compiled in RegExp
|
|
# [1] = @params --> an Array of parameters, indexed by names
|
|
# ex. "/user/:id" --> "^/user/(?<id>([^/#?]+?))[/#?]?$"
|
|
func _path_to_regexp(path: String, should_match_subfolders: bool = false) -> Array:
|
|
var regexp: String = "^"
|
|
var params: Array = []
|
|
var fragments: Array = path.split("/")
|
|
fragments.pop_front()
|
|
for fragment in fragments:
|
|
if fragment.left(1) == ":":
|
|
fragment = fragment.lstrip(":")
|
|
regexp += "/(?<%s>([^/#?]+?))" % fragment
|
|
params.append(fragment)
|
|
else:
|
|
regexp += "/" + fragment
|
|
regexp += "[/#?]?$" if not should_match_subfolders else "(?<subpath>$|/.*)"
|
|
return [regexp, params]
|
|
|
|
|
|
## Enable CORS (Cross-origin resource sharing) which only allows requests from the specified servers
|
|
## [br]
|
|
## [br][param allowed_origins] - The origins that are allowed to be accessed from this server
|
|
## [br][param access_control_allowed_methods] - The methods that are allowed to be used
|
|
## [br][param access_control_allowed_headers] - The headers that are allowed to be sent
|
|
func enable_cors(allowed_origins: PackedStringArray, access_control_allowed_methods : String = "POST, GET, OPTIONS", access_control_allowed_headers : String = "content-type"):
|
|
_allowed_origins = allowed_origins
|
|
_access_control_allowed_methods = access_control_allowed_methods
|
|
_access_control_allowed_headers = access_control_allowed_headers
|
|
|
|
|
|
# Extracts query parameters from a String query,
|
|
# building a Query Dictionary of param:value pairs
|
|
#
|
|
# #### Parameters
|
|
# - query_string: the query string, extracted from the HttpRequest.path
|
|
#
|
|
# Returns: A Dictionary of param:value pairs
|
|
func _extract_query_params(query_string: String) -> Dictionary:
|
|
var query: Dictionary = {}
|
|
if query_string == "":
|
|
return query
|
|
var parameters: Array = query_string.split("&")
|
|
for param in parameters:
|
|
if not "=" in param:
|
|
continue
|
|
var kv : Array = param.split("=")
|
|
var value: String = kv[1]
|
|
if value.is_valid_int():
|
|
query[kv[0]] = value.to_int()
|
|
elif value.is_valid_float():
|
|
query[kv[0]] = value.to_float()
|
|
else:
|
|
query[kv[0]] = value
|
|
return query
|