All checks were successful
Build & Release Plugin / build (push) Successful in 14s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1138 lines
31 KiB
ObjectPascal
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.
|