Files
fastway-plugin-webui/webui.pp
Ken Johnson dcff1f5dd2
All checks were successful
Build & Release Plugin / build (push) Successful in 14s
v0.1.6: Rebuild with updated fw_plugin_api.pas (SessionSetNodeStatus)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 20:18:42 -07:00

1138 lines
31 KiB
ObjectPascal

{ ======================================================================== }
{ webui.pp — WebUI thin client plugin }
{ HTTP server serving user-facing BBS web interface. }
{ Proxies API calls to the primary server; manages user sessions via }
{ cookies with server-side JWT storage. }
{ ======================================================================== }
library webui;
{$mode objfpc}{$H+}
{$INTERFACES CORBA}
uses
{$IFDEF UNIX}cmem, cthreads,{$ENDIF}
Classes, SysUtils, fpjson, jsonparser, syncobjs,
fphttpserver, httpdefs, httpprotocol,
httpsend, synautil, ssl_openssl3,
fw_plugin_api;
const
WEBUI_VERSION = '0.1.6';
SESSION_COOKIE = 'fw_session';
DEFAULT_PORT = 8888;
SESSION_MAX_AGE = 86400; { 24 hours }
type
{ Session record stored server-side }
TWebUISession = record
Username: string;
JWTToken: string;
Expires: TDateTime;
end;
TFWWebUIPlugin = class;
{ HTTP server thread }
TFWWebUIServer = class(TThread)
private
FPlugin: TFWWebUIPlugin;
FServer: TFPHTTPServer;
procedure DoHandleRequest(Sender: TObject;
var ARequest: TFPHTTPConnectionRequest;
var AResponse: TFPHTTPConnectionResponse);
protected
procedure Execute; override;
public
constructor Create(APlugin: TFWWebUIPlugin);
destructor Destroy; override;
procedure StopServer;
end;
{ Main plugin class }
TFWWebUIPlugin = class(TInterfacedObject, IFWPlugin, IFWProtocolPlugin)
private
FHost: IFWPluginHost;
FServer: TFWWebUIServer;
FRunning: Boolean;
FSessionLock: TCriticalSection;
FSessions: TStringList; { session_id=JSON }
FPort: Integer;
FAddress: string;
FWebRoot: string;
FPrimaryURL: string;
{ Session management }
function GenerateSessionID: string;
function FindSession(const ASessionID: string; out ASess: TWebUISession): Boolean;
procedure StoreSession(const ASessionID: string; const ASess: TWebUISession);
procedure RemoveSession(const ASessionID: string);
function GetSessionIDFromCookie(ARequest: TFPHTTPConnectionRequest): string;
{ Request handling }
procedure HandleRequest(ARequest: TFPHTTPConnectionRequest;
AResponse: TFPHTTPConnectionResponse);
procedure HandleLogin(ARequest: TFPHTTPConnectionRequest;
AResponse: TFPHTTPConnectionResponse);
procedure HandleLogout(ARequest: TFPHTTPConnectionRequest;
AResponse: TFPHTTPConnectionResponse);
procedure HandleLocalStatus(AResponse: TFPHTTPConnectionResponse);
procedure ProxyRequest(ARequest: TFPHTTPConnectionRequest;
AResponse: TFPHTTPConnectionResponse; const AToken: string);
procedure ServeStaticFile(ARequest: TFPHTTPConnectionRequest;
AResponse: TFPHTTPConnectionResponse);
{ Helpers }
function GetClientIP(ARequest: TFPHTTPConnectionRequest): string;
function GetMimeType(const AExt: string): string;
procedure SendJSON(AResponse: TFPHTTPConnectionResponse;
ACode: Integer; AObj: TJSONObject);
procedure SendError(AResponse: TFPHTTPConnectionResponse;
ACode: Integer; const AMsg: string);
procedure SetSessionCookie(AResponse: TFPHTTPConnectionResponse;
const ASessionID: string);
procedure ClearSessionCookie(AResponse: TFPHTTPConnectionResponse);
public
constructor Create;
{ IFWPlugin }
function GetName: string;
function GetVersion: string;
function GetDescription: string;
function GetAuthor: string;
function GetDependencies: TStringArray;
function GetPermissions: TFWPermissionSet;
function GetCapabilities: TFWPluginCapabilities;
function GetTarget: TFWPluginTarget;
function AsProtocol: IFWProtocolPlugin;
function AsRoute: IFWRoutePlugin;
function AsAdmin: IFWAdminPlugin;
function AsDBPlugin: IFWDatabasePlugin;
function AsEvent: IFWEventPlugin;
function AsService: IFWServicePlugin;
function AsProtocolDetector: IFWProtocolDetector;
function AsScheduled: IFWScheduledPlugin;
function Initialize(AHost: IFWPluginHost): Boolean;
procedure Finalize;
{ IFWProtocolPlugin }
function GetProtocolName: string;
function GetListenPort: Integer;
function GetListenAddress: string;
function Start: Boolean;
procedure Stop;
function GetConnectionCount: Integer;
function IsRunning: Boolean;
end;
{ ======================================================================== }
{ TFWWebUIServer }
{ ======================================================================== }
constructor TFWWebUIServer.Create(APlugin: TFWWebUIPlugin);
begin
inherited Create(True); { suspended }
FreeOnTerminate := False;
FPlugin := APlugin;
FServer := TFPHTTPServer.Create(nil);
FServer.Port := FPlugin.FPort;
FServer.QueueSize := 50;
FServer.AcceptIdleTimeout := 1000;
FServer.OnRequest := @DoHandleRequest;
FServer.Threaded := True;
end;
destructor TFWWebUIServer.Destroy;
begin
FreeAndNil(FServer);
inherited;
end;
procedure TFWWebUIServer.DoHandleRequest(Sender: TObject;
var ARequest: TFPHTTPConnectionRequest;
var AResponse: TFPHTTPConnectionResponse);
begin
try
FPlugin.HandleRequest(ARequest, AResponse);
except
on E: Exception do
begin
FPlugin.FHost.LogError('WebUI request error: ' + E.Message);
try
AResponse.Code := 500;
AResponse.ContentType := 'application/json';
AResponse.Content := '{"success":false,"error":"Internal server error"}';
except
{ Response may already be committed }
end;
end;
end;
end;
procedure TFWWebUIServer.Execute;
var
RetryCount: Integer;
begin
RetryCount := 0;
while not Terminated do
begin
try
FPlugin.FHost.LogInfo('WebUI HTTP server starting on port ' + IntToStr(FPlugin.FPort));
FServer.Active := True;
{ Active=True returns when server stops normally }
if not Terminated then
FPlugin.FHost.LogInfo('WebUI HTTP server stopped unexpectedly');
except
on E: Exception do
begin
if Terminated then Break;
Inc(RetryCount);
FPlugin.FHost.LogError(Format('WebUI server error (attempt %d): %s',
[RetryCount, E.Message]));
if RetryCount > 50 then
begin
FPlugin.FHost.LogError('WebUI server exceeded max retries, giving up');
Break;
end;
{ Brief pause before retry — back off slightly }
Sleep(2000);
{ Recreate the server object in case it's in a bad state }
try
FreeAndNil(FServer);
FServer := TFPHTTPServer.Create(nil);
FServer.Port := FPlugin.FPort;
FServer.QueueSize := 50;
FServer.AcceptIdleTimeout := 1000;
FServer.OnRequest := @DoHandleRequest;
FServer.Threaded := True;
except
on E2: Exception do
begin
FPlugin.FHost.LogError('WebUI server recreation failed: ' + E2.Message);
Break;
end;
end;
end;
end;
end;
end;
procedure TFWWebUIServer.StopServer;
begin
Terminate;
if Assigned(FServer) then
FServer.Active := False;
end;
{ ======================================================================== }
{ TFWWebUIPlugin — IFWPlugin }
{ ======================================================================== }
constructor TFWWebUIPlugin.Create;
begin
inherited Create;
FPort := DEFAULT_PORT;
FAddress := '0.0.0.0';
FWebRoot := '';
FPrimaryURL := '';
FRunning := False;
end;
function TFWWebUIPlugin.GetName: string;
begin
Result := 'webui';
end;
function TFWWebUIPlugin.GetVersion: string;
begin
Result := WEBUI_VERSION;
end;
function TFWWebUIPlugin.GetDescription: string;
begin
Result := 'User-facing BBS web interface (HTTP server + API proxy)';
end;
function TFWWebUIPlugin.GetAuthor: string;
begin
Result := 'Fastway BBS';
end;
function TFWWebUIPlugin.GetDependencies: TStringArray;
begin
SetLength(Result, 0);
end;
function TFWWebUIPlugin.GetPermissions: TFWPermissionSet;
begin
Result := [fpNetworkListen, fpNetworkConnect, fpConfigRead];
end;
function TFWWebUIPlugin.GetCapabilities: TFWPluginCapabilities;
begin
Result := [pcProtocol];
end;
function TFWWebUIPlugin.GetTarget: TFWPluginTarget;
begin
Result := ptClient;
end;
function TFWWebUIPlugin.AsProtocol: IFWProtocolPlugin;
begin
Result := Self;
end;
function TFWWebUIPlugin.AsRoute: IFWRoutePlugin;
begin
Result := nil;
end;
function TFWWebUIPlugin.AsAdmin: IFWAdminPlugin;
begin
Result := nil;
end;
function TFWWebUIPlugin.AsDBPlugin: IFWDatabasePlugin;
begin
Result := nil;
end;
function TFWWebUIPlugin.AsEvent: IFWEventPlugin;
begin
Result := nil;
end;
function TFWWebUIPlugin.AsService: IFWServicePlugin;
begin
Result := nil;
end;
function TFWWebUIPlugin.AsProtocolDetector: IFWProtocolDetector;
begin
Result := nil;
end;
function TFWWebUIPlugin.AsScheduled: IFWScheduledPlugin;
begin
Result := nil;
end;
function TFWWebUIPlugin.Initialize(AHost: IFWPluginHost): Boolean;
var
PluginDir: string;
begin
FHost := AHost;
FSessionLock := TCriticalSection.Create;
FSessions := TStringList.Create;
FSessions.Sorted := True;
FSessions.Duplicates := dupIgnore;
{ Load configuration }
FPort := StrToIntDef(FHost.ConfigGet('port', '8888'), DEFAULT_PORT);
FAddress := FHost.ConfigGet('address', '0.0.0.0');
{ Determine web root: relative to plugin .so or config override }
FWebRoot := FHost.ConfigGet('web_root', '');
if FWebRoot = '' then
begin
PluginDir := ExtractFilePath(FHost.ConfigGet('plugin_path', ''));
if PluginDir = '' then
PluginDir := 'client_plugins/webui/';
FWebRoot := PluginDir + 'web';
end;
{ Ensure trailing separator }
if (FWebRoot <> '') and (FWebRoot[Length(FWebRoot)] <> '/') then
FWebRoot := FWebRoot + '/';
{ Primary server URL }
FPrimaryURL := FHost.ConfigGet('primary_url', '');
if FPrimaryURL = '' then
FPrimaryURL := 'http://' + FHost.ConfigGet('primary_host', '127.0.0.1') +
':' + FHost.ConfigGet('primary_port', '8080');
{ Strip trailing slash }
if (FPrimaryURL <> '') and (FPrimaryURL[Length(FPrimaryURL)] = '/') then
Delete(FPrimaryURL, Length(FPrimaryURL), 1);
FHost.LogInfo(Format('WebUI plugin initialized (port=%d, web_root=%s, primary=%s)',
[FPort, FWebRoot, FPrimaryURL]));
Result := True;
end;
procedure TFWWebUIPlugin.Finalize;
begin
FHost.LogInfo('WebUI plugin shutting down');
if Assigned(FSessions) then
FreeAndNil(FSessions);
if Assigned(FSessionLock) then
FreeAndNil(FSessionLock);
FHost := nil;
end;
{ ======================================================================== }
{ IFWProtocolPlugin }
{ ======================================================================== }
function TFWWebUIPlugin.GetProtocolName: string;
begin
Result := 'HTTP/WebUI';
end;
function TFWWebUIPlugin.GetListenPort: Integer;
begin
Result := FPort;
end;
function TFWWebUIPlugin.GetListenAddress: string;
begin
Result := FAddress;
end;
function TFWWebUIPlugin.Start: Boolean;
begin
FHost.LogInfo(Format('Starting WebUI HTTP server on %s:%d', [FAddress, FPort]));
try
FServer := TFWWebUIServer.Create(Self);
FServer.Start;
FRunning := True;
Result := True;
except
on E: Exception do
begin
FHost.LogError('Failed to start WebUI server: ' + E.Message);
FRunning := False;
Result := False;
end;
end;
end;
procedure TFWWebUIPlugin.Stop;
begin
if Assigned(FServer) then
begin
FHost.LogInfo('Stopping WebUI HTTP server');
FServer.StopServer;
FServer.WaitFor;
FreeAndNil(FServer);
end;
FRunning := False;
end;
function TFWWebUIPlugin.GetConnectionCount: Integer;
begin
Result := 0; { HTTP is stateless — no persistent connections }
end;
function TFWWebUIPlugin.IsRunning: Boolean;
begin
Result := FRunning and Assigned(FServer);
end;
{ ======================================================================== }
{ Session Management }
{ ======================================================================== }
function TFWWebUIPlugin.GenerateSessionID: string;
var
G: TGUID;
begin
CreateGUID(G);
Result := LowerCase(StringReplace(StringReplace(GUIDToString(G),
'{', '', [rfReplaceAll]), '}', '', [rfReplaceAll]));
end;
function TFWWebUIPlugin.FindSession(const ASessionID: string;
out ASess: TWebUISession): Boolean;
var
Idx: Integer;
J: TJSONObject;
P: TJSONParser;
begin
Result := False;
ASess.Username := '';
ASess.JWTToken := '';
ASess.Expires := 0;
if ASessionID = '' then Exit;
FSessionLock.Enter;
try
Idx := FSessions.IndexOfName(ASessionID);
if Idx < 0 then Exit;
P := TJSONParser.Create(FSessions.ValueFromIndex[Idx]);
try
J := P.Parse as TJSONObject;
try
ASess.Username := J.Get('username', '');
ASess.JWTToken := J.Get('token', '');
ASess.Expires := J.Get('expires', Double(0));
Result := ASess.Expires > Now;
if not Result then
begin
{ Session expired — clean up }
FSessions.Delete(Idx);
end;
finally
J.Free;
end;
finally
P.Free;
end;
finally
FSessionLock.Leave;
end;
end;
procedure TFWWebUIPlugin.StoreSession(const ASessionID: string;
const ASess: TWebUISession);
var
J: TJSONObject;
begin
J := TJSONObject.Create;
try
J.Add('username', ASess.Username);
J.Add('token', ASess.JWTToken);
J.Add('expires', Double(ASess.Expires));
FSessionLock.Enter;
try
FSessions.Values[ASessionID] := J.AsJSON;
finally
FSessionLock.Leave;
end;
finally
J.Free;
end;
end;
procedure TFWWebUIPlugin.RemoveSession(const ASessionID: string);
var
Idx: Integer;
begin
if ASessionID = '' then Exit;
FSessionLock.Enter;
try
Idx := FSessions.IndexOfName(ASessionID);
if Idx >= 0 then
FSessions.Delete(Idx);
finally
FSessionLock.Leave;
end;
end;
function TFWWebUIPlugin.GetSessionIDFromCookie(
ARequest: TFPHTTPConnectionRequest): string;
var
CookieStr, Pair, Key, Val: string;
P: Integer;
begin
Result := '';
{ Parse from Cookie header }
CookieStr := ARequest.GetFieldByName(HeaderCookie);
if CookieStr = '' then Exit;
while CookieStr <> '' do
begin
P := Pos(';', CookieStr);
if P > 0 then
begin
Pair := Trim(Copy(CookieStr, 1, P - 1));
Delete(CookieStr, 1, P);
end
else
begin
Pair := Trim(CookieStr);
CookieStr := '';
end;
P := Pos('=', Pair);
if P > 0 then
begin
Key := Trim(Copy(Pair, 1, P - 1));
Val := Trim(Copy(Pair, P + 1, Length(Pair)));
if Key = SESSION_COOKIE then
begin
Result := Val;
Exit;
end;
end;
end;
end;
{ ======================================================================== }
{ Cookie Helpers }
{ ======================================================================== }
procedure TFWWebUIPlugin.SetSessionCookie(AResponse: TFPHTTPConnectionResponse;
const ASessionID: string);
var
Cookie: TCookie;
begin
Cookie := AResponse.Cookies.Add;
Cookie.Name := SESSION_COOKIE;
Cookie.Value := ASessionID;
Cookie.Path := '/';
Cookie.HttpOnly := True;
end;
procedure TFWWebUIPlugin.ClearSessionCookie(AResponse: TFPHTTPConnectionResponse);
var
Cookie: TCookie;
begin
Cookie := AResponse.Cookies.Add;
Cookie.Name := SESSION_COOKIE;
Cookie.Value := '';
Cookie.Path := '/';
Cookie.HttpOnly := True;
Cookie.Expires := EncodeDate(1970, 1, 1);
end;
{ ======================================================================== }
{ Request Handling }
{ ======================================================================== }
procedure TFWWebUIPlugin.HandleRequest(ARequest: TFPHTTPConnectionRequest;
AResponse: TFPHTTPConnectionResponse);
var
Path, Method: string;
SessionID: string;
Sess: TWebUISession;
begin
Path := ARequest.PathInfo;
Method := UpperCase(ARequest.Method);
{ CORS headers }
AResponse.SetCustomHeader('Access-Control-Allow-Origin', '*');
AResponse.SetCustomHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
AResponse.SetCustomHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
{ Handle preflight }
if Method = 'OPTIONS' then
begin
AResponse.Code := 204;
AResponse.Content := '';
Exit;
end;
{ Login / Logout — no session required }
if (Path = '/login') and (Method = 'POST') then
begin
HandleLogin(ARequest, AResponse);
Exit;
end;
if (Path = '/logout') and (Method = 'POST') then
begin
HandleLogout(ARequest, AResponse);
Exit;
end;
{ Login page and static assets — no session required }
if (Path = '') or (Path = '/') or (Path = '/index.html') or (Path = '/login') then
begin
ServeStaticFile(ARequest, AResponse);
Exit;
end;
{ CSS and JS files — no session required for basic page loading }
if (Pos('/css/', Path) = 1) or (Pos('/js/', Path) = 1) then
begin
ServeStaticFile(ARequest, AResponse);
Exit;
end;
{ Everything else requires a valid session }
SessionID := GetSessionIDFromCookie(ARequest);
if not FindSession(SessionID, Sess) then
begin
{ If requesting HTML page, redirect to login }
if (Pos('.html', Path) > 0) or (Path = '/messages') then
begin
AResponse.Code := 302;
AResponse.SetCustomHeader('Location', '/');
AResponse.Content := '';
Exit;
end;
{ API request — return 401 }
SendError(AResponse, 401, 'Not authenticated');
Exit;
end;
{ Local status proxy — fetches from thin client status server }
if Path = '/api/local/status' then
begin
HandleLocalStatus(AResponse);
Exit;
end;
{ API proxy: /api/v1/* and /plugins/* }
if (Pos('/api/v1/', Path) = 1) or (Pos('/plugins/', Path) = 1) then
begin
ProxyRequest(ARequest, AResponse, Sess.JWTToken);
Exit;
end;
{ Static file (authenticated pages like messages.html) }
ServeStaticFile(ARequest, AResponse);
end;
{ ======================================================================== }
{ Login Handler }
{ ======================================================================== }
procedure TFWWebUIPlugin.HandleLogin(ARequest: TFPHTTPConnectionRequest;
AResponse: TFPHTTPConnectionResponse);
var
ReqBody, RespJSON: TJSONObject;
JP: TJSONParser;
Username, Password: string;
HTTP: THTTPSend;
RespStr: string;
Token: string;
SessionID: string;
Sess: TWebUISession;
begin
{ Parse request body }
try
JP := TJSONParser.Create(ARequest.Content);
try
ReqBody := JP.Parse as TJSONObject;
finally
JP.Free;
end;
except
SendError(AResponse, 400, 'Invalid JSON');
Exit;
end;
try
Username := ReqBody.Get('username', '');
Password := ReqBody.Get('password', '');
finally
ReqBody.Free;
end;
if (Username = '') or (Password = '') then
begin
SendError(AResponse, 400, 'Username and password required');
Exit;
end;
{ Authenticate with primary server }
HTTP := THTTPSend.Create;
try
HTTP.Headers.Add('Content-Type: application/json');
HTTP.Document.Position := 0;
RespStr := '{"username":"' + Username + '","password":"' + Password + '"}';
HTTP.Document.Write(RespStr[1], Length(RespStr));
HTTP.MimeType := 'application/json';
if not HTTP.HTTPMethod('POST', FPrimaryURL + '/api/v1/auth/login') then
begin
SendError(AResponse, 502, 'Cannot reach primary server');
Exit;
end;
{ Read response }
SetLength(RespStr, HTTP.Document.Size);
HTTP.Document.Position := 0;
if HTTP.Document.Size > 0 then
HTTP.Document.Read(RespStr[1], HTTP.Document.Size)
else
RespStr := '';
if HTTP.ResultCode <> 200 then
begin
{ Try to extract error message }
try
JP := TJSONParser.Create(RespStr);
try
RespJSON := JP.Parse as TJSONObject;
try
SendError(AResponse, 401, RespJSON.Get('error', 'Authentication failed'));
finally
RespJSON.Free;
end;
finally
JP.Free;
end;
except
SendError(AResponse, 401, 'Authentication failed');
end;
Exit;
end;
{ Parse success response to get JWT }
try
JP := TJSONParser.Create(RespStr);
try
RespJSON := JP.Parse as TJSONObject;
finally
JP.Free;
end;
except
SendError(AResponse, 502, 'Invalid response from primary');
Exit;
end;
try
{ Response format: {"success": true, "data": {"token": "..."}} }
Token := '';
if RespJSON.Find('data') <> nil then
Token := TJSONObject(RespJSON.Find('data')).Get('token', '');
if Token = '' then
Token := RespJSON.Get('token', '');
finally
RespJSON.Free;
end;
if Token = '' then
begin
SendError(AResponse, 502, 'No token received from primary');
Exit;
end;
finally
HTTP.Free;
end;
{ Create session }
SessionID := GenerateSessionID;
Sess.Username := Username;
Sess.JWTToken := Token;
Sess.Expires := Now + (SESSION_MAX_AGE / 86400);
StoreSession(SessionID, Sess);
{ Set cookie }
SetSessionCookie(AResponse, SessionID);
{ Return success }
RespJSON := TJSONObject.Create;
try
RespJSON.Add('success', True);
RespJSON.Add('username', Username);
SendJSON(AResponse, 200, RespJSON);
finally
RespJSON.Free;
end;
FHost.LogInfo('WebUI login: ' + Username + ' from ' + GetClientIP(ARequest));
end;
{ ======================================================================== }
{ Logout Handler }
{ ======================================================================== }
procedure TFWWebUIPlugin.HandleLogout(ARequest: TFPHTTPConnectionRequest;
AResponse: TFPHTTPConnectionResponse);
var
SessionID: string;
RespJSON: TJSONObject;
begin
SessionID := GetSessionIDFromCookie(ARequest);
if SessionID <> '' then
RemoveSession(SessionID);
{ Clear cookie }
ClearSessionCookie(AResponse);
RespJSON := TJSONObject.Create;
try
RespJSON.Add('success', True);
SendJSON(AResponse, 200, RespJSON);
finally
RespJSON.Free;
end;
end;
{ ======================================================================== }
{ Local Status Proxy }
{ ======================================================================== }
procedure TFWWebUIPlugin.HandleLocalStatus(AResponse: TFPHTTPConnectionResponse);
var
HTTP: THTTPSend;
StatusPort: Integer;
RespStr: string;
begin
StatusPort := StrToIntDef(FHost.ConfigGet('status_port', '8081'), 8081);
HTTP := THTTPSend.Create;
try
HTTP.Timeout := 3000;
if HTTP.HTTPMethod('GET', Format('http://127.0.0.1:%d/status', [StatusPort])) then
begin
SetLength(RespStr, HTTP.Document.Size);
HTTP.Document.Position := 0;
if HTTP.Document.Size > 0 then
HTTP.Document.Read(RespStr[1], HTTP.Document.Size)
else
RespStr := '{}';
AResponse.Code := 200;
AResponse.ContentType := 'application/json';
AResponse.Content := RespStr;
end
else
SendError(AResponse, 502, 'Cannot reach local status server');
finally
HTTP.Free;
end;
end;
{ ======================================================================== }
{ API Proxy }
{ ======================================================================== }
procedure TFWWebUIPlugin.ProxyRequest(ARequest: TFPHTTPConnectionRequest;
AResponse: TFPHTTPConnectionResponse; const AToken: string);
var
HTTP: THTTPSend;
TargetURL, Method, Body, RespStr: string;
ContentType: string;
I: Integer;
HdrLine: string;
ClientIP, ExistingFF, Proto, Host: string;
begin
Method := UpperCase(ARequest.Method);
{ /plugins/* needs /api/v1 prefix on the primary; /api/v1/* goes as-is }
if (Pos('/plugins/', ARequest.PathInfo) = 1) then
TargetURL := FPrimaryURL + '/api/v1' + ARequest.PathInfo
else
TargetURL := FPrimaryURL + ARequest.PathInfo;
if ARequest.QueryString <> '' then
TargetURL := TargetURL + '?' + ARequest.QueryString;
HTTP := THTTPSend.Create;
try
HTTP.Headers.Add('Authorization: Bearer ' + AToken);
{ Forward real client IP to primary for logging/security }
ClientIP := GetClientIP(ARequest);
HTTP.Headers.Add('X-Real-IP: ' + ClientIP);
{ Build X-Forwarded-For chain: append our proxy to existing chain }
ExistingFF := ARequest.GetFieldByName('X-Forwarded-For');
if ExistingFF <> '' then
HTTP.Headers.Add('X-Forwarded-For: ' + ExistingFF + ', ' + ARequest.RemoteAddress)
else
HTTP.Headers.Add('X-Forwarded-For: ' + ClientIP);
{ Forward protocol and host }
Proto := ARequest.GetFieldByName('X-Forwarded-Proto');
if Proto = '' then
begin
if Pos('https', LowerCase(ARequest.URL)) = 1 then
Proto := 'https'
else
Proto := 'http';
end;
HTTP.Headers.Add('X-Forwarded-Proto: ' + Proto);
Host := ARequest.GetFieldByName('X-Forwarded-Host');
if Host = '' then
Host := ARequest.GetFieldByName('Host');
if Host <> '' then
HTTP.Headers.Add('X-Forwarded-Host: ' + Host);
{ Copy request body for POST/PUT }
if (Method = 'POST') or (Method = 'PUT') then
begin
Body := ARequest.Content;
ContentType := ARequest.ContentType;
if ContentType = '' then
ContentType := 'application/json';
HTTP.MimeType := ContentType;
if Body <> '' then
begin
HTTP.Document.Position := 0;
HTTP.Document.Write(Body[1], Length(Body));
end;
end;
if not HTTP.HTTPMethod(Method, TargetURL) then
begin
SendError(AResponse, 502, 'Cannot reach primary server');
Exit;
end;
{ Copy response }
SetLength(RespStr, HTTP.Document.Size);
HTTP.Document.Position := 0;
if HTTP.Document.Size > 0 then
HTTP.Document.Read(RespStr[1], HTTP.Document.Size)
else
RespStr := '';
AResponse.Code := HTTP.ResultCode;
{ Find Content-Type from response headers }
AResponse.ContentType := 'application/json';
for I := 0 to HTTP.Headers.Count - 1 do
begin
HdrLine := HTTP.Headers[I];
if Pos('Content-Type:', HdrLine) = 1 then
begin
AResponse.ContentType := Trim(Copy(HdrLine, 14, Length(HdrLine)));
Break;
end;
end;
AResponse.Content := RespStr;
finally
HTTP.Free;
end;
end;
{ ======================================================================== }
{ Static File Serving }
{ ======================================================================== }
procedure TFWWebUIPlugin.ServeStaticFile(ARequest: TFPHTTPConnectionRequest;
AResponse: TFPHTTPConnectionResponse);
var
Path, FilePath, Ext: string;
FS: TFileStream;
Buf: string;
begin
Path := ARequest.PathInfo;
{ Default to index.html (also handle /login as login page) }
if (Path = '') or (Path = '/') or (Path = '/login') then
Path := '/index.html';
{ Security: prevent directory traversal }
if Pos('..', Path) > 0 then
begin
SendError(AResponse, 403, 'Forbidden');
Exit;
end;
{ Strip leading slash }
if (Path <> '') and (Path[1] = '/') then
Delete(Path, 1, 1);
FilePath := FWebRoot + Path;
if not FileExists(FilePath) then
begin
SendError(AResponse, 404, 'Not found');
Exit;
end;
Ext := LowerCase(ExtractFileExt(FilePath));
FS := TFileStream.Create(FilePath, fmOpenRead or fmShareDenyNone);
try
SetLength(Buf, FS.Size);
if FS.Size > 0 then
FS.Read(Buf[1], FS.Size);
finally
FS.Free;
end;
AResponse.Code := 200;
AResponse.ContentType := GetMimeType(Ext);
AResponse.SetCustomHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
AResponse.SetCustomHeader('Pragma', 'no-cache');
AResponse.Content := Buf;
end;
{ ======================================================================== }
{ Helpers }
{ ======================================================================== }
function TFWWebUIPlugin.GetClientIP(ARequest: TFPHTTPConnectionRequest): string;
var
ForwardedFor, RealIP: string;
P: Integer;
begin
{ Check X-Real-IP first (set by nginx), then X-Forwarded-For }
RealIP := ARequest.GetFieldByName('X-Real-IP');
if RealIP <> '' then
begin
Result := Trim(RealIP);
Exit;
end;
ForwardedFor := ARequest.GetFieldByName('X-Forwarded-For');
if ForwardedFor <> '' then
begin
{ Take first IP (original client) from "client, proxy1, proxy2" }
P := Pos(',', ForwardedFor);
if P > 0 then
Result := Trim(Copy(ForwardedFor, 1, P - 1))
else
Result := Trim(ForwardedFor);
Exit;
end;
{ Fallback to direct connection address }
Result := ARequest.RemoteAddress;
end;
function TFWWebUIPlugin.GetMimeType(const AExt: string): string;
begin
if AExt = '.html' then Result := 'text/html; charset=utf-8'
else if AExt = '.css' then Result := 'text/css; charset=utf-8'
else if AExt = '.js' then Result := 'application/javascript; charset=utf-8'
else if AExt = '.json' then Result := 'application/json'
else if AExt = '.png' then Result := 'image/png'
else if AExt = '.jpg' then Result := 'image/jpeg'
else if AExt = '.gif' then Result := 'image/gif'
else if AExt = '.svg' then Result := 'image/svg+xml'
else if AExt = '.ico' then Result := 'image/x-icon'
else Result := 'application/octet-stream';
end;
procedure TFWWebUIPlugin.SendJSON(AResponse: TFPHTTPConnectionResponse;
ACode: Integer; AObj: TJSONObject);
begin
AResponse.Code := ACode;
AResponse.ContentType := 'application/json';
AResponse.Content := AObj.AsJSON;
end;
procedure TFWWebUIPlugin.SendError(AResponse: TFPHTTPConnectionResponse;
ACode: Integer; const AMsg: string);
var
J: TJSONObject;
begin
J := TJSONObject.Create;
try
J.Add('success', False);
J.Add('error', AMsg);
AResponse.Code := ACode;
AResponse.ContentType := 'application/json';
AResponse.Content := J.AsJSON;
finally
J.Free;
end;
end;
{ ======================================================================== }
{ Library Exports }
{ ======================================================================== }
var
PluginInstance: TFWWebUIPlugin = nil;
function FWPluginAPIVersion: Integer; cdecl;
begin
Result := 1;
end;
function FWPluginCreate: Pointer; cdecl;
begin
PluginInstance := TFWWebUIPlugin.Create;
Result := Pointer(IFWPlugin(PluginInstance));
end;
procedure FWPluginDestroy; cdecl;
begin
if Assigned(PluginInstance) then
FreeAndNil(PluginInstance);
end;
exports
FWPluginAPIVersion,
FWPluginCreate,
FWPluginDestroy;
end.