KUMOS/Server/Libraries/FastCGI.pbi
2026-05-02 15:49:06 +02:00

793 lines
22 KiB
Plaintext

DeclareModule FastCGI
; Server
Declare Open(Port, *Callback, BindedIP.s = "") ; Create a FCGI Application on the given port. Return a Server object if succeed or 0 otherwise. Callback format : Callback(Request)
Declare Close(*Server) ; Close the given Server.
; Request
Declare FinishResponse(*Request) ; Send the response and close connection
Declare.s GetCookie(*Request, Cookie.s) ; Return the value of the given cookie
Declare.s GetParameter(*Request, Parameter.s) ; Return the value of the given parameter if it exists.
Declare.s GetPostData(*Request) ; Return raw POST data
Declare WriteResponseHeader(*Request, Header.s, Value.s) ; Write a header to the response
Declare WriteResponseData(*Request, *Buffer, Length) ; Add data to the response
Declare WriteResponseString(*Request, String.s, Format = #PB_UTF8) ; Add a string to the response
Declare WriteResponseContentType(*Request, File.s) ; Write the MIME type based on file extension
Declare RespondFile(*Request, File.s) ; Automatically send a file as a response
EndDeclareModule
Module FastCGI
EnableExplicit
Global NewMap MIMETypes.s()
;{ Constants
#FCGI_VERSION = 1
; Record types
#FCGI_BEGIN_REQUEST = 1
#FCGI_ABORT_REQUEST = 2
#FCGI_END_REQUEST = 3
#FCGI_PARAMS = 4
#FCGI_STDIN = 5
#FCGI_STDOUT = 6
#FCGI_STDERR = 7
#FCGI_DATA = 8
#FCGI_GET_VALUES = 9
#FCGI_GET_VALUES_RESULT = 10
#FCGI_UNKNOWN_TYPE = 11
; Roles
#FCGI_RESPONDER = 1
#FCGI_AUTHORIZER = 2
#FCGI_FILTER = 3
; Flags
#FCGI_KEEP_CONN = 1
; Protocol status
#FCGI_REQUEST_COMPLETE = 0
#FCGI_CANT_MPX_CONN = 1
#FCGI_OVERLOADED = 2
#FCGI_UNKNOWN_ROLE = 3
#HEADER_SIZE = 8
#MAX_CONTENT_LENGTH = 65535
#MAX_RECORD_SIZE = 65528 ; 65535 - 8 (aligned to 8 bytes)
;}
;{ Structures
Structure FCGI_Header
version.a
type.a
requestIdB1.a
requestIdB0.a
contentLengthB1.a
contentLengthB0.a
paddingLength.a
reserved.a
EndStructure
Structure FCGI_BeginRequestBody
roleB1.a
roleB0.a
flags.a
reserved.a[5]
EndStructure
Structure FCGI_EndRequestBody
appStatusB3.a
appStatusB2.a
appStatusB1.a
appStatusB0.a
protocolStatus.a
reserved.a[3]
EndStructure
Structure Server
ServerID.i
Thread.i
Stop.i
*Callback
Mutex.i
EndStructure
Structure Request
ClientID.i
RequestID.u
KeepConnection.a
Role.u
ParamsComplete.a
StdinComplete.a
Map Parameters.s()
Map ResponseHeaders.s()
List Cookies.s()
List ResponseData.i()
PostData.s
*PostDataBuffer
PostDataLength.i
EndStructure
;}
;{ Private procedure declarations
Declare ServerThread(*Server)
Declare ProcessNameValuePairs(*Data, *Request.Request, Length)
Declare ReceiveAllData(ClientID, *Buffer, Length)
Declare SendAllData(ClientID, *Buffer, Length)
Declare BuildHeader(*Header.FCGI_Header, Type.a, RequestID.u, ContentLength.u, PaddingLength.a)
Declare SendRecord(ClientID, Type.a, RequestID.u, *Content, ContentLength)
Declare SendEndRequest(ClientID, RequestID.u, AppStatus.l, ProtocolStatus.a)
;}
;{ Helper procedures
; Receive exactly 'Length' bytes, blocking until complete or error
Procedure ReceiveAllData(ClientID, *Buffer, Length)
Protected Received = 0, Result
While Received < Length
Result = ReceiveNetworkData(ClientID, *Buffer + Received, Length - Received)
If Result <= 0
ProcedureReturn -1 ; Connection closed or error
EndIf
Received + Result
Wend
ProcedureReturn Received
EndProcedure
; Send all data, blocking until complete
Procedure SendAllData(ClientID, *Buffer, Length)
Protected Sent = 0, Result
While Sent < Length
Result = SendNetworkData(ClientID, *Buffer + Sent, Length - Sent)
If Result <= 0
ProcedureReturn -1
EndIf
Sent + Result
Wend
ProcedureReturn Sent
EndProcedure
; Build a FastCGI header
Procedure BuildHeader(*Header.FCGI_Header, Type.a, RequestID.u, ContentLength.u, PaddingLength.a)
*Header\version = #FCGI_VERSION
*Header\type = Type
*Header\requestIdB1 = (RequestID >> 8) & $FF
*Header\requestIdB0 = RequestID & $FF
*Header\contentLengthB1 = (ContentLength >> 8) & $FF
*Header\contentLengthB0 = ContentLength & $FF
*Header\paddingLength = PaddingLength
*Header\reserved = 0
EndProcedure
; Send a complete FastCGI record
Procedure SendRecord(ClientID, Type.a, RequestID.u, *Content, ContentLength)
Protected *Packet, PacketSize, PaddingLength.a, Result
; Calculate padding to align to 8 bytes
PaddingLength = (8 - (ContentLength % 8)) % 8
PacketSize = #HEADER_SIZE + ContentLength + PaddingLength
*Packet = AllocateMemory(PacketSize, #PB_Memory_NoClear)
If Not *Packet
ProcedureReturn #False
EndIf
; Build header
BuildHeader(*Packet, Type, RequestID, ContentLength, PaddingLength)
; Copy content if any
If *Content And ContentLength > 0
CopyMemory(*Content, *Packet + #HEADER_SIZE, ContentLength)
EndIf
; Zero padding
If PaddingLength > 0
FillMemory(*Packet + #HEADER_SIZE + ContentLength, PaddingLength, 0)
EndIf
; Send
Result = SendAllData(ClientID, *Packet, PacketSize)
FreeMemory(*Packet)
ProcedureReturn Bool(Result > 0)
EndProcedure
; Send FCGI_END_REQUEST record
Procedure SendEndRequest(ClientID, RequestID.u, AppStatus.l, ProtocolStatus.a)
Protected Body.FCGI_EndRequestBody
Body\appStatusB3 = (AppStatus >> 24) & $FF
Body\appStatusB2 = (AppStatus >> 16) & $FF
Body\appStatusB1 = (AppStatus >> 8) & $FF
Body\appStatusB0 = AppStatus & $FF
Body\protocolStatus = ProtocolStatus
ProcedureReturn SendRecord(ClientID, #FCGI_END_REQUEST, RequestID, @Body, SizeOf(FCGI_EndRequestBody))
EndProcedure
; Parse name-value pairs from FCGI_PARAMS
Procedure ProcessNameValuePairs(*Data, *Request.Request, Length)
Protected Offset = 0
Protected NameLength.l, ValueLength.l
Protected Name.s, Value.s
While Offset < Length
; Read name length
NameLength = PeekA(*Data + Offset)
If NameLength & $80 ; High bit set = 4-byte length
If Offset + 4 > Length : Break : EndIf
NameLength = ((PeekA(*Data + Offset) & $7F) << 24) |
(PeekA(*Data + Offset + 1) << 16) |
(PeekA(*Data + Offset + 2) << 8) |
PeekA(*Data + Offset + 3)
Offset + 4
Else
Offset + 1
EndIf
; Read value length
If Offset >= Length : Break : EndIf
ValueLength = PeekA(*Data + Offset)
If ValueLength & $80 ; High bit set = 4-byte length
If Offset + 4 > Length : Break : EndIf
ValueLength = ((PeekA(*Data + Offset) & $7F) << 24) |
(PeekA(*Data + Offset + 1) << 16) |
(PeekA(*Data + Offset + 2) << 8) |
PeekA(*Data + Offset + 3)
Offset + 4
Else
Offset + 1
EndIf
; Bounds check
If Offset + NameLength + ValueLength > Length
Break
EndIf
; Read name and value
If NameLength > 0
Name = PeekS(*Data + Offset, NameLength, #PB_Ascii)
Offset + NameLength
If ValueLength > 0
Value = PeekS(*Data + Offset, ValueLength, #PB_Ascii)
Offset + ValueLength
Else
Value = ""
EndIf
*Request\Parameters(Name) = Value
Else
Offset + ValueLength
EndIf
Wend
EndProcedure
;}
;{ Public procedures - Server
Procedure Close(*Server.Server)
If *Server
*Server\Stop = #True
; Wait for thread to finish
If IsThread(*Server\Thread)
WaitThread(*Server\Thread, 5000)
If IsThread(*Server\Thread)
KillThread(*Server\Thread)
EndIf
EndIf
If *Server\Mutex
FreeMutex(*Server\Mutex)
EndIf
FreeMemory(*Server)
EndIf
EndProcedure
Procedure Open(Port, *Callback, BindedIP.s = "")
Protected ServerID, *Server.Server
If Not *Callback
ProcedureReturn #Null
EndIf
ServerID = CreateNetworkServer(#PB_Any, Port, #PB_Network_TCP, BindedIP)
If ServerID
*Server = AllocateMemory(SizeOf(Server))
If *Server
*Server\ServerID = ServerID
*Server\Callback = *Callback
*Server\Mutex = CreateMutex()
*Server\Thread = CreateThread(@ServerThread(), *Server)
If Not *Server\Thread
CloseNetworkServer(ServerID)
If *Server\Mutex : FreeMutex(*Server\Mutex) : EndIf
FreeMemory(*Server)
*Server = #Null
EndIf
Else
CloseNetworkServer(ServerID)
EndIf
EndIf
ProcedureReturn *Server
EndProcedure
;}
;{ Public procedures - Request
Procedure FinishResponse(*Request.Request)
Protected HeaderString.s, *HeaderData, HeaderLength
Protected *ContentData, ContentLength, TotalLength
Protected Offset, ChunkSize, Result
If Not *Request
ProcedureReturn
EndIf
; Build HTTP headers string
ForEach *Request\ResponseHeaders()
HeaderString + MapKey(*Request\ResponseHeaders()) + ": " + *Request\ResponseHeaders() + #CRLF$
Next
ForEach *Request\Cookies()
HeaderString + "Set-Cookie: " + *Request\Cookies() + #CRLF$
Next
HeaderString + #CRLF$ ; End of headers
; Calculate total response size
HeaderLength = StringByteLength(HeaderString, #PB_Ascii)
ContentLength = 0
ForEach *Request\ResponseData()
ContentLength + MemorySize(*Request\ResponseData())
Next
TotalLength = HeaderLength + ContentLength
; Allocate combined buffer
*ContentData = AllocateMemory(TotalLength, #PB_Memory_NoClear)
If Not *ContentData
ProcedureReturn
EndIf
; Copy headers
PokeS(*ContentData, HeaderString, -1, #PB_Ascii | #PB_String_NoZero)
Offset = HeaderLength
; Copy response data
ForEach *Request\ResponseData()
CopyMemory(*Request\ResponseData(), *ContentData + Offset, MemorySize(*Request\ResponseData()))
Offset + MemorySize(*Request\ResponseData())
FreeMemory(*Request\ResponseData())
Next
ClearList(*Request\ResponseData())
; Send STDOUT records (split if necessary)
Offset = 0
While Offset < TotalLength
ChunkSize = TotalLength - Offset
If ChunkSize > #MAX_RECORD_SIZE
ChunkSize = #MAX_RECORD_SIZE
EndIf
Result = SendRecord(*Request\ClientID, #FCGI_STDOUT, *Request\RequestID, *ContentData + Offset, ChunkSize)
If Not Result
Break
EndIf
Offset + ChunkSize
Wend
FreeMemory(*ContentData)
; Send empty STDOUT to signal end of output
SendRecord(*Request\ClientID, #FCGI_STDOUT, *Request\RequestID, #Null, 0)
; Send END_REQUEST
SendEndRequest(*Request\ClientID, *Request\RequestID, 0, #FCGI_REQUEST_COMPLETE)
; Close connection if not keep-alive
If Not *Request\KeepConnection
CloseNetworkConnection(*Request\ClientID)
EndIf
; Clean up POST data buffer if allocated
If *Request\PostDataBuffer
FreeMemory(*Request\PostDataBuffer)
*Request\PostDataBuffer = #Null
EndIf
EndProcedure
Procedure.s GetCookie(*Request.Request, Cookie.s)
Protected CookieHeader.s, Result.s
Protected SearchStr.s, StartPos, EndPos
If Not *Request
ProcedureReturn ""
EndIf
CookieHeader = *Request\Parameters("HTTP_COOKIE")
If CookieHeader = ""
ProcedureReturn ""
EndIf
SearchStr = Cookie + "="
StartPos = FindString(CookieHeader, SearchStr)
If StartPos
StartPos + Len(SearchStr)
EndPos = FindString(CookieHeader, ";", StartPos)
If EndPos = 0
EndPos = Len(CookieHeader) + 1
EndIf
Result = Trim(Mid(CookieHeader, StartPos, EndPos - StartPos))
EndIf
ProcedureReturn Result
EndProcedure
Procedure.s GetParameter(*Request.Request, Parameter.s)
If *Request
ProcedureReturn *Request\Parameters(Parameter)
EndIf
ProcedureReturn ""
EndProcedure
Procedure.s GetPostData(*Request.Request)
If *Request
ProcedureReturn *Request\PostData
EndIf
ProcedureReturn ""
EndProcedure
Procedure WriteResponseHeader(*Request.Request, Header.s, Value.s)
If *Request
If LCase(Header) = "set-cookie" Or Header = #PB_CGI_HeaderSetCookie
AddElement(*Request\Cookies())
*Request\Cookies() = Value
Else
*Request\ResponseHeaders(Header) = Value
EndIf
EndIf
EndProcedure
Procedure WriteResponseString(*Request.Request, String.s, Format = #PB_UTF8)
Protected *Buffer, Length
If *Request And String <> ""
Length = StringByteLength(String, Format)
*Buffer = AllocateMemory(Length, #PB_Memory_NoClear)
If *Buffer
PokeS(*Buffer, String, -1, Format | #PB_String_NoZero)
AddElement(*Request\ResponseData())
*Request\ResponseData() = *Buffer
EndIf
EndIf
EndProcedure
Procedure WriteResponseData(*Request.Request, *Buffer, Length)
Protected *Copy
If *Request And *Buffer And Length > 0
*Copy = AllocateMemory(Length, #PB_Memory_NoClear)
If *Copy
CopyMemory(*Buffer, *Copy, Length)
AddElement(*Request\ResponseData())
*Request\ResponseData() = *Copy
EndIf
EndIf
EndProcedure
Procedure WriteResponseContentType(*Request.Request, File.s)
Protected Extension.s, MIMEType.s
If *Request
Extension = LCase(GetExtensionPart(File))
If FindMapElement(MIMETypes(), Extension)
MIMEType = MIMETypes()
Else
MIMEType = "application/octet-stream"
EndIf
*Request\ResponseHeaders("Content-Type") = MIMEType
EndIf
EndProcedure
Procedure RespondFile(*Request.Request, File.s)
Protected Result = #False
Protected FileID, FileSize, *FileData
If Not *Request
ProcedureReturn #False
EndIf
FileID = ReadFile(#PB_Any, File, #PB_File_SharedRead)
If FileID
FileSize = Lof(FileID)
If FileSize > 0
*FileData = AllocateMemory(FileSize, #PB_Memory_NoClear)
If *FileData
ReadData(FileID, *FileData, FileSize)
WriteResponseContentType(*Request, File)
WriteResponseData(*Request, *FileData, FileSize)
FreeMemory(*FileData)
Result = #True
EndIf
EndIf
CloseFile(FileID)
If Result
FinishResponse(*Request)
EndIf
EndIf
ProcedureReturn Result
EndProcedure
;}
;{ Server thread
Procedure ServerThread(*Server.Server)
Protected Header.FCGI_Header
Protected BeginBody.FCGI_BeginRequestBody
Protected ContentLength, PaddingLength
Protected *ContentBuffer, *PaddingBuffer
Protected Event, ClientID
Protected NewMap Requests.Request()
Protected *Request.Request
Protected RequestKey.s
*ContentBuffer = AllocateMemory(#MAX_CONTENT_LENGTH)
*PaddingBuffer = AllocateMemory(256)
If Not *ContentBuffer Or Not *PaddingBuffer
If *ContentBuffer : FreeMemory(*ContentBuffer) : EndIf
If *PaddingBuffer : FreeMemory(*PaddingBuffer) : EndIf
ProcedureReturn
EndIf
Repeat
Event = NetworkServerEvent(*Server\ServerID)
Select Event
Case #PB_NetworkEvent_None
Delay(1)
Case #PB_NetworkEvent_Connect
ClientID = EventClient()
; New connection - request will be created on BEGIN_REQUEST
Case #PB_NetworkEvent_Data
ClientID = EventClient()
RequestKey = Str(ClientID)
; Read header
If ReceiveAllData(ClientID, @Header, #HEADER_SIZE) = #HEADER_SIZE
ContentLength = (Header\contentLengthB1 << 8) | Header\contentLengthB0
PaddingLength = Header\paddingLength
; Read content
If ContentLength > 0
If ReceiveAllData(ClientID, *ContentBuffer, ContentLength) <> ContentLength
Continue
EndIf
EndIf
; Read padding
If PaddingLength > 0
ReceiveAllData(ClientID, *PaddingBuffer, PaddingLength)
EndIf
; Process based on record type
Select Header\type
Case #FCGI_BEGIN_REQUEST
; Create new request
*Request = AddMapElement(Requests(), RequestKey)
If *Request
*Request\ClientID = ClientID
*Request\RequestID = (Header\requestIdB1 << 8) | Header\requestIdB0
If ContentLength >= SizeOf(FCGI_BeginRequestBody)
CopyMemory(*ContentBuffer, @BeginBody, SizeOf(FCGI_BeginRequestBody))
*Request\Role = (BeginBody\roleB1 << 8) | BeginBody\roleB0
*Request\KeepConnection = Bool(BeginBody\flags & #FCGI_KEEP_CONN)
EndIf
EndIf
Case #FCGI_PARAMS
*Request = FindMapElement(Requests(), RequestKey)
If *Request
If ContentLength > 0
ProcessNameValuePairs(*ContentBuffer, *Request, ContentLength)
Else
; Empty PARAMS record signals end of parameters
*Request\ParamsComplete = #True
EndIf
EndIf
Case #FCGI_STDIN
*Request = FindMapElement(Requests(), RequestKey)
If *Request
If ContentLength > 0
; Accumulate POST data
Protected *NewBuffer, NewLength
NewLength = *Request\PostDataLength + ContentLength
*NewBuffer = AllocateMemory(NewLength)
If *NewBuffer
If *Request\PostDataBuffer
CopyMemory(*Request\PostDataBuffer, *NewBuffer, *Request\PostDataLength)
FreeMemory(*Request\PostDataBuffer)
EndIf
CopyMemory(*ContentBuffer, *NewBuffer + *Request\PostDataLength, ContentLength)
*Request\PostDataBuffer = *NewBuffer
*Request\PostDataLength = NewLength
EndIf
Else
; Empty STDIN record signals end of input
*Request\StdinComplete = #True
; Convert POST data to string
If *Request\PostDataBuffer And *Request\PostDataLength > 0
*Request\PostData = PeekS(*Request\PostDataBuffer, *Request\PostDataLength, #PB_UTF8 | #PB_ByteLength)
EndIf
; Request is complete - call handler
If *Request\ParamsComplete
Protected Callback.i = *Server\Callback
If Callback
CallFunctionFast(Callback, *Request)
EndIf
; Clean up request from map after response
DeleteMapElement(Requests(), RequestKey)
EndIf
EndIf
EndIf
Case #FCGI_ABORT_REQUEST
*Request = FindMapElement(Requests(), RequestKey)
If *Request
If *Request\PostDataBuffer
FreeMemory(*Request\PostDataBuffer)
EndIf
ForEach *Request\ResponseData()
FreeMemory(*Request\ResponseData())
Next
DeleteMapElement(Requests(), RequestKey)
EndIf
EndSelect
EndIf
Case #PB_NetworkEvent_Disconnect
ClientID = EventClient()
RequestKey = Str(ClientID)
; Clean up any pending request
*Request = FindMapElement(Requests(), RequestKey)
If *Request
If *Request\PostDataBuffer
FreeMemory(*Request\PostDataBuffer)
EndIf
ForEach *Request\ResponseData()
FreeMemory(*Request\ResponseData())
Next
DeleteMapElement(Requests(), RequestKey)
EndIf
EndSelect
Until *Server\Stop
; Cleanup
FreeMemory(*ContentBuffer)
FreeMemory(*PaddingBuffer)
; Clean up remaining requests
ForEach Requests()
If Requests()\PostDataBuffer
FreeMemory(Requests()\PostDataBuffer)
EndIf
ForEach Requests()\ResponseData()
FreeMemory(Requests()\ResponseData())
Next
Next
CloseNetworkServer(*Server\ServerID)
EndProcedure
;}
;{ MIME Types
Procedure LoadMimeTypes()
Protected.s Ext, Type
Restore MimeData
Read.s Ext
While Ext <> "END"
Read.s Type
MIMETypes(Ext) = Type
Read.s Ext
Wend
EndProcedure
LoadMimeTypes()
DataSection
MimeData:
Data.s "aac", "audio/aac", "abw", "application/x-abiword", "apng", "image/apng", "avi", "video/x-msvideo", "bin", "application/octet-stream", "bmp", "image/bmp"
Data.s "css", "text/css", "csv", "text/csv", "doc", "application/msword", "gif", "image/gif", "htm", "text/html", "html", "text/html", "ico", "image/x-icon"
Data.s "jpeg", "image/jpeg", "jpg", "image/jpeg", "js", "text/javascript", "json", "application/json", "mp3", "audio/mpeg", "mp4", "video/mp4", "mpeg", "video/mpeg"
Data.s "otf", "font/otf", "png", "image/png", "pdf", "application/pdf", "php", "application/x-httpd-php", "svg", "image/svg+xml", "txt", "text/plain"
Data.s "wav", "audio/wav", "webm", "video/webm", "webp", "image/webp", "woff", "font/woff", "woff2", "font/woff2", "xml", "application/xml", "zip", "application/zip"
Data.s "END"
EndDataSection
;}
EndModule
;{ Test/Demo code
CompilerIf #PB_Compiler_IsMainFile
Global *ImageData
Global ImageSize
; Load test image
If ReadFile(0, #PB_Compiler_Home + "examples/sources/Data/Map.bmp")
ImageSize = Lof(0)
*ImageData = AllocateMemory(ImageSize)
ReadData(0, *ImageData, ImageSize)
CloseFile(0)
Else
; Create a simple test response if image not found
*ImageData = #Null
ImageSize = 0
EndIf
Procedure RequestHandler(*Request)
Protected URI.s
URI = FastCGI::GetParameter(*Request, "REQUEST_URI")
Debug "Request received: " + URI
Debug "Method: " + FastCGI::GetParameter(*Request, "REQUEST_METHOD")
Debug "Query: " + FastCGI::GetParameter(*Request, "QUERY_STRING")
If *ImageData And ImageSize > 0
FastCGI::WriteResponseContentType(*Request, "test.bmp")
FastCGI::WriteResponseData(*Request, *ImageData, ImageSize)
Else
FastCGI::WriteResponseHeader(*Request, "Content-Type", "text/html")
FastCGI::WriteResponseString(*Request, "<html><body><h1>FastCGI Test</h1><p>Request URI: " + URI + "</p></body></html>")
EndIf
FastCGI::FinishResponse(*Request)
EndProcedure
OpenConsole("FastCGI Demo")
PrintN("Starting FastCGI server on port 5600...")
PrintN("Press Enter to stop")
Global Server = FastCGI::Open(5600, @RequestHandler())
If Server
PrintN("Server started successfully")
Input()
FastCGI::Close(Server)
PrintN("Server closed")
Else
PrintN("Failed to start server!")
EndIf
If *ImageData
FreeMemory(*ImageData)
EndIf
PrintN("Press Enter to exit")
Input()
CompilerEndIf
;}
; IDE Options = PureBasic 6.30 (Windows - x64)
; ExecutableFormat = Console
; CursorPosition = 55
; Folding = GAAAA9
; EnableXP
; DPIAware