Bob Swart (aka Dr.Bob)
ISAPI Filters bouwen in Delphi

Dit artikel gaat over ISAPI filters (voor Microsoft's Internet Information Server). We zullen zien wat een ISAPI filter is, hoe ze met IIS "praten", en hoe we ze kunnen bouwen in Delphi met behulp van een speciaal "skelet". Daarnaast laat ik in detail zien hoe we ISAPI filters kunnen installeren onder Windows 2000 of hoger. Met behulp van het ISAPI Filter Skelet project in Delphi bouwen we alle ISAPI filter projecten in deze sessie, te beginnen met een eenvoudige logfile filter, een eenvoudige scripting engine (voor het vertalen van speciale tags in dynamische HTML), en tot slot een eenvoudige versie van de "memberschip" ISAPI filter die gebruik maakt van Windows authenticatie om directories op een web server te beveiligen en een soort "members only" mogelijkheid op je website te kunnen bieden (reeds in gebruik bij de web servers van TDMWeb).

ISAPI DLLs en Filters
ISAPI staat voor de Internet Information Server (IIS) API van Microsoft, en komt voor in de begrippen ISAPI DLL en ISAPI Filter. Een ISAPI DLL is een "normale" web server toepassing, en kan gebouwd worden met WebBroker, WebSnap, IntraWeb, ExpressWeb Framework of verschillende andere third-party libraries. Dat is niet het onderwerp van deze sessie. Wij gaan het hebben over ISAPI Filters - uitbreidingen van IIS zelf doordat ze als plug-in bovenop de web server geplaatst kunnen worden (en daadwerkelijk kunnen ingrijpen op de binnenkomende requests en uitgaande responses).

Isapi2.pas
De eerdergenoemde tools en techniques zijn met name geschikt voor het bouwen van ISAPI web server toepassingen, maar niet voor ISAPI Filters. De bouw van een ISAPI Filter is low-level werk, en ziet er hetzelfde uit of we nu Delphi 3, 4, 5, 6 of 7 gebruiken. Wat belangrijk is, is de unit in de Source\Rtl\Win directory, namelijk Isapi2.pas. Hierin kunnen we de constantes, type definities en function prototypes terugvinden voor versie 2.0 van de HTTP Server Extension interface (ook wel HSE genoemd). De function prototypes vertellen ons dat een ISAPI Filter een DLL is die twee functions exporteert (de entry points), namelijk GetFilterVersion en HttpFilterProc. Dit lijkt op een "normale" ISAPI DLL (die ook de TerminateExtension exporteert).

ISAPI Filter Skeleton
Het eerste wat ik dan ook wil doen is een ISAPI Filter "skelet" bouwen dat niks anders doet dan gecompileerd en geladen worden door IIS. Het enige nut van dit skelet is kijken of we überhaubt iets voor elkaar kunnen krijgen (en geladen kunnen krijgen), zodat we alle latere ISAPI Filters hierop kunnen baseren. De source code van het skelet is hieronder te vinden:

  library ISAFilterSkelet;
  {$R *.res}
  uses
    Windows, Isapi2;

  function GetFilterVersion(var Ver: THTTP_FILTER_VERSION): BOOL; stdcall;
  begin
    Ver.lpszFilterDesc := 'Delphi ISAPI Filter';
    Ver.dwFilterVersion := MakeLong(HSE_VERSION_MINOR, HSE_VERSION_MAJOR);
    Ver.dwFlags := SF_NOTIFY_NONSECURE_PORT or SF_NOTIFY_SECURE_PORT or
                   SF_NOTIFY_ORDER_DEFAULT or
                   SF_NOTIFY_LOG;
    Result := True // Continue to Load Filter
  end;

  function HttpFilterProc(var pfc: THTTP_FILTER_CONTEXT;
    NotificationType: DWORD; pvNotification: pointer): DWORD; stdcall;
  begin
    Result := SF_STATUS_REQ_NEXT_NOTIFICATION // Notify next Filter
  end;

  exports
    GetFilterVersion,
    HttpFilterProc;

  begin
    IsMultiThread := True
  end.

GetFilterVersion
Ik maak in de GetFilterVersion gebruik van een aantal SF_ waarden, en het is belangrijk hier altijd de juiste combinatie aan te geven. Onderstaande tabel geeft een kort overzicht van de mogelijkheden:

FlagValueMeaning
SF_NOTIFY_SECURE_PORT$00000001The filter is notified for secure transactions on server ports supporting data encryption (such as SSL).
SF_NOTIFY_NONSECURE_PORT$00000002The filter is notified for non-secure transactions.
SF_NOTIFY_READ_RAW_DATA$00008000The filter is notified after the server receives a "raw" block of data (before any processing by the server is done). We can examine and modify the raw data.
SF_NOTIFY_PREPROC_HEADERS$00004000The filter is notified after the server has pre-processed the HTTP headers. We can examine and even modify the headers.
SF_NOTIFY_AUTHENTICATION$00002000The filter is notified when the server needs to authenticate the user. We can watch or even take over authentication.
SF_NOTIFY_URL_MAP$00001000The filter is notified when the server has mapped a URL to a physical file on disk. We can examine and modify the mapping.
SF_NOTIFY_ACCESS_DENIED$00000800The filter is notified when the server is about to send an "Access Denied" message. We can examine and modify the message.
SF_NOTIFY_SEND_RAW_DATA$00000400The filter is notified when a "raw" block of data is being sent by the server to the client. We can still examine and modify the raw data.
SF_NOTIFY_LOG$00000200The filter is notified that a record is written to the logfile. We can examine the record and even add or modify the entry in the logfile.
SF_NOTIFY_END_OF_NET_SESSION$00000100The filter is notified that a session (between client and server) has expired or is terminated.

We moeten in ieder geval altijd of de SF_NOTIFY_SECURE_PORT of de SF_NOTIFY_NONSECURE_PORT opgeven (of allebei), anders zal de filter niet eens gebruikt worden. Behalve bovenstaande opties, is het ook mogelijk om de prioriteit van de ISAPI Filter op te geven met de volgende opties:

FlagValueMeaning
SF_NOTIFY_ORDER_HIGH$00080000High priority (first)
SF_NOTIFY_ORDER_MEDIUM$00040000Medium priority
SF_NOTIFY_ORDER_LOW$00020000Low priority (default)
SF_NOTIFY_ORDER_DEFAULTSF_NOTIFY_ORDER_LOWDefault priority is low.

HttpFilterProc
Waar de GetFilterVersion eenmalig gebruikt wordt om informatie over de ISAPI Filter te geven, daar zal de HttpFilterProc worden uitgevoerd om het echte werk te doen. Voor de opgegeven events zal de HttpFilterProc worden aangeroepen, en kunnen we verschillende dingen doen. Afhankelijk van het type notificatie zal de pvNotification (untyped) pointer naar een andere structuur wijzen waar de informatie uit kunnen halen (en weer in kunnen stoppen). Tot slot kunnen we als resultaat aangeven wat IIS vervolgens zou moeten doen (de volgende ISAPI Filter aanroepen bijvoorbeeld, zoals te zien is in het eerste voorbeeld).

Installatie ISAPI Filters
De voorbeeld ISAPI Filter levert een DLL op van minder van 10K (we gebruiken helemaal niks, en zeker geen VCL of andere componenten). Het installeren van een ISAPI Filter kan door deze naar een handige plek te kopiëren (in zet ze in de root van m'n C:\InetPub directory neer). Onder Windows 2000 kun je met de rechtermuisknop op "My Computer" klikken en kiezen voor Manage om de Computer Management console te krijgen. Kies daarbinnen voor Services and Applications, en vervolgens voor Internet Information Service en de Default Web Site. Deze moeten we stoppen voordat we een ISAPI Filter installeren (of vlak voordat we deze activeren om precies te zijn). Met de rechtermuisknop kunnen we via properties de dialoog krijgen voor deze default website, en op de ISAPI Filter tab de nieuwe filter instaleren. De Add button geeft de volgende dialoog waar we de gegevens kunnen invullen:

In het totaaloverzicht zal de ISAPI Filter pas aktief worden als de website weer gestart is. Het ziet er uiteindelijk als volgt uit:

De status is nu Loaded, en de priority staat of Low. Merk op dat de Default Web Site nog geen ISAPI Filters had, terwijl IIS zelf (voor de gehele machine) wel al een lijstje met ISAPI Filters laat zien. Voor testdoeleinden is het echter aan te raden om een ISAPI Filter is voor een specifiek (test) domain te laden en pas later voor een gehele web server.

ISAFilterLog
Als eerste demo wil ik een eenvoudige logfile filter schrijven - eentje die alle binnenkomende events in een logfile wehschrijft (ik weet dat IIS dat ook doet, maar het gaat even om het idee). Bij de Log notification event kunnen we de pvNotification pointer casten naar een HTTP_FILTER_LOG (te vinden in Isapi2.pas), waar acht velden in zitten: pszClientHostName, pszClientUserName, pszServerName, pszOperation, pszTarget en pszParameters van type PChar, alsmede dwHttpStatus en dwWin32Status van type DWORD. Omdat de ISAPI Filter een multi-threaded toepassing is, is het gebuik van een extern bestand eigenlijk niet zo'n goed idee: we zijn dan niet thread-safe. Vandaar dat ik een speciale Log routine heb geschreven die het bestand opent en weer sluit rondom het wegschrijven van de tekst die we willen loggen. Als een tweede log request binnenkomt terwijl de eerst bezig is, zal het bestand "op slot" zitten, en via {$I-} en IOResult kan ik daarop reageren door het tweede event dan maar niks te laten doen (we missen dus potentieel een aantal regels in de logfile, maar dat is beter dan een crash). Ik heb speciaal hiervoor een Log routine geschreven die als parameters een string meekrijgt alsmede een default parameter NewLogFile om aan te geven of we een nieuwe logfile willen maken (dat is handig bij het laden/activeren van de ISAPI Filter, zodat we voor iedere "run" een nieuwe logfile starten). Al met al levert dit de volgende code op voor onze eerste echte ISAPI Filter:

  library ISAFilterLog;
  {$R *.res}
  uses
    SysUtils, Windows, Isapi2;

  const
    LogFileName = 'c:\filter42.log';

  procedure Log(const Message: String; NewLogFile: Boolean = False);
  var
    LogFile: TextFile;
  begin
    Assign(LogFile, LogFileName);
    {$I-}
    if NewLogFile then Rewrite(LogFile)
                  else Append(LogFile);
    if IOResult = 0 then
    begin
      writeln(LogFile,TimeToStr(Now),': ',Message); // Requires SysUtils
      Close(LogFile)
    end
  end;

  function GetFilterVersion(var Ver: THTTP_FILTER_VERSION): BOOL; stdcall;
  begin
    Ver.lpszFilterDesc := 'Delphi ISAPI Filter';
    Ver.dwFilterVersion := MakeLong(HSE_VERSION_MINOR, HSE_VERSION_MAJOR);
    Ver.dwFlags := SF_NOTIFY_NONSECURE_PORT or SF_NOTIFY_SECURE_PORT or
                   SF_NOTIFY_ORDER_DEFAULT or
                   SF_NOTIFY_LOG;
    Log(Ver.lpszFilterDesc+' Loaded',True);
    Result := True // Continue to Load Filter
  end;

  function HttpFilterProc(var pfc: THTTP_FILTER_CONTEXT;
    NotificationType: DWORD; pvNotification: pointer): DWORD; stdcall;
  var
    FilterLog: PHTTP_FILTER_LOG;
  begin
    case NotificationType of
      SF_NOTIFY_LOG:
      begin
        FilterLog := PHTTP_FILTER_LOG(pvNotification);
        Log('ClientHostName: ' + FilterLog.pszClientHostName);
        Log('ClientUserName: ' + FilterLog.pszClientUserName);
        Log('ServerName: ' + FilterLog.pszServerName);
        Log('Operation: ' + FilterLog.pszOperation);
        Log('Target: ' + FilterLog.pszTarget);
        Log('Parameters: ' + FilterLog.pszParameters)
      end
      else
        Log('HttpFilterProc unknown notification')
    end;
    Result := SF_STATUS_REQ_NEXT_NOTIFICATION // Notify next Filter
  end;

  exports
    GetFilterVersion,
    HttpFilterProc;

  begin
    IsMultiThread := True
  end.
Merk op dat dit ISAPI Filter nog steeds alleen maar voor de Log events geactiveerd zal worden, maar het kan geen kwaad voor de zekerheid te controleren of de NotificationType ook echt SF_NOTIFY_LOG is zodat we zeker weten dat we de pvNotification kunnen casten naar een PHTTP_FILTER_LOG record.

Installatie
Dit levert al met al een ISAPI Filter van zo'n 50K op (we hebben de SysUtils nodig voor de Now en TimeToStr functies, vandaar dat het wat groter is dan eerder). We kunnen ook deze ISAPI Filter installeren door hem naar een bepaalde directory te kopiëren (ik stop ze in m'n C:\Inetpub directory) en met de Computer Management console te laden. Let op dat je de default website moet stoppen en weer starten om de ISAPI Filter daadwerkelijk "in de lucht" te krijgen. De nieuwe filter zal dan ook als "Low" in de lijst met ISAPI Filters komen. Je kunt overigens altijd de volgorde veranderen van de ISAPI Filters (binnen een prioriteitsverzameling dan - dus alle Lows in dit geval).

ISAFilterLog Test
Zodra de ISAPI Filter geïnstalleerd is hoeven we alleen maar in een browser de default website te benaderen om de ISAPI Filter in aktie te krijgen. Het resultaat van een paar keer heen-en-weer lopen is hieronder te zien (de eerste paar regels van de logfile dan):

  02:09:17 PM: Delphi ISAPI Filter Loaded
  02:15:52 PM: ClientHostName: 192.168.92.201
  02:15:52 PM: ClientUserName:
  02:15:52 PM: ServerName: 192.168.92.245
  02:15:52 PM: Operation: GET
  02:15:52 PM: Target: /index.htm
Nu we dit gezien hebben, is het natuurlijk zaak om wat efficienter met de logfile om te gaan, en alleen de belangrijke gegevens (op één regel) bij te houden. Dat laat ik over aan de lezer, maar ik let op dat het vervangen van een ISAPI Filter met voorbedachte rade moet gebeuren. De stappen zijn als volgt:
  1. Stop de Default Web Site
  2. Restart de World Wide Web Publishing Service
  3. Vervang de ISAPI Filter
  4. Start de Default Web Site
Merk op dat het niet voldoende is om slechts de World Wide Web Publishing Service opnieuw te starten (zonder de Default Web Site eerst te stoppen), omdat anders de huidige ISAPI Filter onmiddel weer geladen zal worden.

Authorisatie
Behalve het loggen van gegevens, is er nog veel meer mogelijk. Het SF_NOTIFY_AUTHENTICATION event bevat bijvoorbeeld ook de username en password die gebruikt zijn (om de pop-up login dialoog van de browser in te vullen) en onze ISAPI Filter kan dat gebruiken om een eigen inlog procedure aan te roepen. We moeten eerst de pvNotification naar een record van type HTTP_FILTER_AUTHENT casten, en kunnen vervolgens bij de pszUser en pszPassword velden, waar we ze kunnen gebruiken en zelfs veranderen waar nodig. Om dit in praktijk te testen, moeten we in de Computer Management console weer naar de Default Web Site gaan, en deze keer een (virtual) sub-directory kiezen en deze directory beschermen met onze ISAPI Filter. Ga met de rechter muisknop naar de properties dialoog, en kies voor de Directory Security tab. Druk op de Edit knop om de authenticatie methode voor de subdirectory aan te passen. Default zal de Authentication Methods op Anonymous Access staan. Als we deze optie uitzetten is er dus een username/password combinatie nodig om pagina's uit deze directory te mogen bekijken. We hebben nu twee mogelijkheden voor Authentication Access: Basic authentication (de username en password worden als gewone tekst over de lijn gestuurd), of integrated Windows authentication. Omdat ik mijn eigen authenticatie wil doen (met een eigen lijst van gebruikers en passwords) hebben we de eerste keuze nodig, ook al wordt nu alles "gewoon" over de lijn gestuurd.

Het gevolg van deze aktie is dat de subdirectory nu beschermd is, en de gebruiker een login dialoog krijgt als hij/zij de pagina's uit deze (virtual) directory wil bekijken. En de opgegeven username en password combinatie komen bij de ISAPI Filter die vervolgens kan bepalen of het toegestaan is om de pagina's te bekijken (mits de ISAPI Filter ervoor zorgt dat aan de basic authentication wordt voldaan door een geldige username/password van de betreffende web server machine door te sturen, wat we nu in de ISAPI Filter zullen inbouwen).

Members
De truc die ik zelf gebruik voor de ISAPI Membership Filter is een lijst van externe gebruikersnamen (username en password) op te slaan in een private deel van de web server (waar dus niemand van buiten bij kan - tenzij er ingebroken is op de machine), en daarnaast één Windows login account dat niks mag behalve inloggen (een guest). Dit account heet bijvoorbeeld "member" en heeft een "geheim" password (iets wat verder alleen de ISAPI Filter weet). Binnen de ISAPI Filter wordt gekeken of een binnenkomende username/password combinatie overeenkomt met eentje die in de lijst staat. Deze lijst moet in ieder geval snel zijn (zodat de ISAPI Filter niet teveel tijd kwijt is met het zoeken naar de username/password combinatie), en ik gebruik daartoe een .ini file - dat draait op iedere machine en kost verder niks. De routine om voor een username het password uit de .ini file te halen is als volgt:

  function GetUserNamePassword(const UserName: String): String;
  const
    PasswdFileName = '.\private\passwd.ini';
  begin
    with TIniFile.Create(PasswdFileName) do // Requires IniFiles
    try
      Result := ReadString(UserName,'password','')
    finally
      Free
    end
  end {GetUserNamePassword};
Als de gebruiker niet wordt gevonden krijgen we een leeg password terug. In praktijk verdient het overigens de voorkeur om de passwords niet in originele staat in de .ini file neer te zetten, maar daar een (hash) codering overheen te gooien. Op die manier zal iemand die "per ongeluk" het passwd.ini in handen krijgt alleen maar de gecodeerde passwords zien, en niet de originele passwords. Dat laat ik verder als oefening voor de lezer (het is wel onderdeel van de commerciële "Windows 2000 Password-Protected Directories" mogelijkheid die met de commerciële edtie van de membership ISAPI Filter op dit moment gebruikt wordt door bijvoorbeeld TDMWeb).

HttpFilterProc
Binnen HttpFilterProc moeten we kijken of het binnenkomend event van type SF_NOTIFY_AUTHENTICATION of SF_NOTIFY_ACCESS_DENIED is. Zo niet, dan moeten we SF_STATUS_REQ_NEXT_NOTIFICATION als resultaat teruggeven. Als het SF_NOTIFY_ACCESS_DENIED is, dan heeft de gebruiker zijn kans gehad en kunnen we een eigen foutmelding vertonen ("inloggen mislukt - wordt lid om toegang te krijgen"), en kunnen er daarna met SF_STATUS_REQ_FINISHED een eind aan maken. Is het echter de SF_NOTIFY_AUTHENTICATION, dan moeten we de pfc pointer naar een structuur van type PHTTP_FILTER_AUTHENT casten (uit unit Isapi2.pas). Deze structuur bevat de username (in pszUser) en password (in pszPassword) als PChar buffers (van 257 bytes - inclusief het laatste byte met een #0). We kunnen de waarde van username en password ophalen, controleren of ze niet leeg zijn, en de GetUserNamePassword gebruiken om te kijken of het een correct paar is (eventueel na het coderen of hashen van het binnengekomen password). Als er een match is gevonden, dan kunnen we de Windows username en password in de pszUser en pszPassword kopiëren om te zorgen dat er inderdaad ingelogd kan worden (zonder dat de bezoeker ziet dat hij als iemand anders inlogt). Als er geen match is gevonden, dan moeten we pszUser leeg maken, zodat het inloggen in ieder geval zal mislukken. In beide gevallen moeten we eindigen met de waarde SF_STATUS_REQ_HANDLED_NOTIFICATION om aan IIS aan te geven dat wij klaar zijn met deze notificatie en geen andere ISAPI Filter er meer aan zou moeten komen. De complete source code voor HttpFilterProc- inclusief wat extra Log statements - is als volgt:

  function HttpFilterProc(var pfc: THTTP_FILTER_CONTEXT;
    NotificationType: DWORD; pvNotification: pointer): DWORD; stdcall;
  const
    MemberUserName = 'member';
    MemberPassword = 'geheim'; // not a safe example, of course
  var
    Authentication: PHTTP_FILTER_AUTHENT;
    UserName: String;
    Password: String;

    function GetServerVariable(const Variable: PChar): String;
    var
      MaxLen: Cardinal;
    begin
      MaxLen := 255;
      SetLength(Result,MaxLen);
      if pfc.GetServerVariable(pfc, Variable, PChar(Result), MaxLen) then
        SetLength(Result,StrLen(PChar(Result)))
      else
        Result := ''
    end {GetServerVariable};

    procedure ShowAccessDenied;
    const
      ErrorHeader: PChar = '401 Access Denied'#13#10'content-type: text/html';
      Error: PChar = '<h1>Access Denied!</h1>' +
        '<hr>Sorry, this area is only available to members!';
    var
      Size: Cardinal;
    begin
      Size := Length(ErrorHeader);
      pfc.ServerSupportFunction(pfc, SF_REQ_SEND_RESPONSE_HEADER, ErrorHeader, 0, Size);
      Size := Length(Error);
      pfc.WriteClient(pfc, Error, Size, 0)
    end {AccessDenied};

    function GetUserNamePassword(const UserName: String): String;
    const
      PasswdFileName = '.\private\passwd.ini';
    begin
      with TIniFile.Create(PasswdFileName) do // Requires IniFiles
      try
        Result := ReadString(UserName,'password','')
      finally
        Free
      end
    end {GetUserNamePassword};

  begin
    Result := SF_STATUS_REQ_NEXT_NOTIFICATION; // Notify next Filter
    if NotificationType = SF_NOTIFY_AUTHENTICATION then
    begin
      Authentication := PHTTP_FILTER_AUTHENT(pvNotification);
      UserName := Authentication.pszUser;
      if UserName <> '' then
      begin
        Password := Authentication.pszPassword;
        if (Password <> '') and (Password = GetUserNamePassword(UserName)) then
        begin
          Log('Member login ' + UserName +
              ' at ' + GetServerVariable('REMOTE_ADDR') +
              ' using ' + GetServerVariable('HTTP_USER_AGENT'));
          strcopy(Authentication.pszUser,MemberUserName);
          strcopy(Authentication.pszPassword,MemberPassword);
        end
        else
          strcopy(Authentication.pszUser,'');
        Result := SF_STATUS_REQ_HANDLED_NOTIFICATION
      end
    end
    else
      if NotificationType = SF_NOTIFY_ACCESS_DENIED then
      begin
        Log('Access Denied of ' + GetServerVariable('REMOTE_ADDR') +
            ' using ' + GetServerVariable('HTTP_USER_AGENT'));
        ShowAccessDenied;
        Result := SF_STATUS_REQ_FINISHED
      end
  end {HttpFilterProc};
Installeren gaat weer als vanouds, en we hebben dan alleen nog een passwd.ini file nodig met daarin enkele username en passwords, zoals bijvoorbeeld de volgende:
  [bob]
  Password=swart

  [guest]
  Password=guest
Username "bob" met als password "swart" of user "guest" met als password "guest" zullen allebei naar de Windows user "member" met als password "geheim" mappen en in kunnen loggen. Alle andere gebruikers komen er niet in. Bovendien wordt er in een logfile bijgehouden wie er een poging heeft gedaan:
  02:47:42 PM: Delphi ISAPI Membership Login Filter Loaded
  02:47:47 PM: Member login guest at 192.168.92.201 using Mozilla/4.7 [en] (WinNT; I)
  02:48:11 PM: Member login guest at 192.168.92.201 using Mozilla/4.7 [en] (WinNT; I)
  02:48:24 PM: Access Denied of 192.168.92.201 using Mozilla/4.0 (MSIE 5.01; Windows NT 5.0)
  02:48:29 PM: Member login guest at 192.168.92.201 using Mozilla/4.0 (MSIE 5.01; Windows NT 5.0)
Als je iemand wilt toevoegen of uitsluiten hoef je nu alleen maar de passwd.ini file te editen.

Scripting
Een ander gebied waar ISAPI Filters uitermate handig kunnen zijn is server-side scripting (ASP is in feite ook een soort ISAPI Filter). In het laatste voorbeeld wil ik dan ook een ISAFilterScripting bouwen die zowel de SF_NOTIFY_SEND_RAW_DATA als de SF_NOTIFY_URL_MAP events zal ontvangen. De URL_MAP wordt bepaald om te kijken of de scripting moeten doen, en zo ja zal dat moeten doorgeven aan de SF_NOTIFY_SEND_RAW_DATA events die volgen voor deze pagina. De grote vraag is hoe het ene event invloed kan uitoefenen op het andere event. Het enige wat we weten is dat de events in de juiste volgorde zullen aankomen (eerst een SF_NOTIFY_URL_MAP gevolgd door een of meerdere SF_NOTIFY_SEND_RAW_DATA events), maar we weten niet zeker of een SF_NOTIFY_SEND_RAW_DATA event hoort bij een pagina die we moeten scripten (voor ieder plaatje krijgen we bijvoorbeeld ook een SF_NOTIFY_URL_MAP gevolgd door een of meerdere SF_NOTIFY_SEND_RAW_DATA events). Gelukkig heeft Microsoft dit zelf ook voorzien en het pfc argument van de HttpFilterProc functie is van type THTTP_FILTER_CONTEXT en bevat een veld genaamd pFilterContext - een untyped pointer naar de custom Filter Context die we zelf kunnen invullen (mits we geen geheugen laten lekken uiteraard)

SF_NOTIFY_URL_MAP
De SF_NOTIFY_URL_MAP moet bepalen of we deze pagina moeten gaan scripten (.htm pagina's wel, maar plaatjes niet). Een simpele check kijkt of de URL een substring .HTM bevat. Als dat het geval is, kunnen we iets in de pFilterContext pointer stoppen. Ik koos ervoor om een PChar variable met StrNew en waarde 'NEW' erin te stoppen. Dan hoeft de SF_NOTIFY_SEND_RAW_DATA alleen maar te kijken of er iets in pFilterContext staat om te zien er or iets gedaan moet worden.

SF_NOTIFY_SEND_RAW_DATA
Een klein probleem dat we nu nog moeten oplossen is het feit dat de binnenkomende data niet in één aanroep van de SF_NOTIFY_SEND_RAW_DATA aankomt, maar vaak in meerdere keren (ieder zo'n 8192 bytes bij mij). De eerste keer zal de pFilterContext de string 'NEW' bevatten, maar alle latere keren staat er iets anders in zodat we weten dat we onderdeel zijn van het groter geheel.

  library ISAFilterScripting;
  {$R *.res}
  uses
    Windows, SysUtils, IniFiles, Isapi2, Classes,
    HTTPProd;

  procedure Log(const Message: String; NewLogFile: Boolean = False);
  const
    LogFileName = 'c:\script42.log';
  var
    LogFile: TextFile;
  begin
    Assign(LogFile, LogFileName);
    {$I-}
    if NewLogFile then Rewrite(LogFile)
                  else Append(LogFile);
    if IOResult = 0 then
    begin
      writeln(LogFile,TimeToStr(Now),': ',Message); // Requires SysUtils
      Close(LogFile)
    end
  end {Log};

  function GetFilterVersion(var Ver: THTTP_FILTER_VERSION): BOOL; stdcall;
  begin
    Ver.lpszFilterDesc := 'Delphi ISAPI Server Side Scripting Filter';
    Ver.dwFilterVersion := MakeLong(HSE_VERSION_MINOR, HSE_VERSION_MAJOR);
    Ver.dwFlags := SF_NOTIFY_NONSECURE_PORT or SF_NOTIFY_SECURE_PORT or
                   SF_NOTIFY_ORDER_DEFAULT or
                   SF_NOTIFY_URL_MAP or
                   SF_NOTIFY_SEND_RAW_DATA;
    Log(Ver.lpszFilterDesc+' Loaded',True);
    Result := True // Continue to Load Filter
  end {GetFilterVersion};

  function HttpFilterProc(var pfc: THTTP_FILTER_CONTEXT;
    NotificationType: DWORD; pvNotification: pointer): DWORD; stdcall;
  var
    UrlMap: PHTTP_FILTER_URL_MAP;
    RawData: PHTTP_FILTER_RAW_DATA;
  begin
    Result := SF_STATUS_REQ_NEXT_NOTIFICATION; // Notify next Filter
    case NotificationType of
      SF_NOTIFY_URL_MAP:
        begin
          UrlMap := PHTTP_FILTER_URL_MAP(pvNotification);
          if Pos('.HTM',UpperCase(UrlMap.pszURL)) > 0 then
            pfc.pFilterContext := StrNew('NEW') // start new URL
        end;
      SF_NOTIFY_SEND_RAW_DATA:
        if Assigned(pfc.pFilterContext) then // URL
        begin
          RawData := PHTTP_FILTER_RAW_DATA(pvNotification);
          if PChar(pfc.pFilterContext) = 'NEW' then // header
            ProcessHeader(pfc,RawData)
          else ProcessBody(pfc,RawData)
        end
    end
  end {HttpFilterProc};

  exports
    GetFilterVersion,
    HttpFilterProc;

  begin
    IsMultiThread := True;
  end.

ProcessHeader
De code die ik hierboven heb geschreven splits de afhandeling van SF_NOTIFY_SEND_RAW_DATA in twee delen: ProcessHeader en ProcessBody. De ProcessHeader zal de HTML headers in de RawData vinden. Maar die mogen we nog niet doorgeven, omdat er een aantal zaken zullen veranderen door het scripten (zoals de Content-Length). We zullen dus moeten wachten tot alles binnen is, het scripten gedaan is, en pas daarna kunnen we de nieuwe lengte berekenen en in de Content-Length van de header zetten. We zullen dus de header - en ook de body - moeten bewaren tot we klaar zijn, en kunnen deze kwijt in een PFilterContext object van eigen makelij:

  type
    PFilterContext = ^TFilterContext;
    TFilterContext = record
      HeaderLength: Cardinal;
      Header: PChar;
      BodyLength: Cardinal;
      Body: PChar;
    end;
Alloceren van een nieuwe PChar moet met StrNew, met als argument het aantal bytes dat binnenkwam (dat kunnen we vinden in het RawData.pvIndata veld, en de plek om de data van de kopiëren is het RawData.cbInData veld. Helaas kunnen we het RawData.cbInData veld niet als PChar behandelen omdat het niet de afsluitende #0 bevat. Daarom moeten we een redelijk complexe regel code schrijven om de data te alloceren en kopiëren:
  StrNew(PChar(Copy(PChar(RawData.pvInData),1,RawData.cbInData)));
Als we de Header en HeaderLength in de pFilterContext structuur hebben geplaatst kunnen we de Header bekijken om de Content-Length te bepalen. Dit is nodig wat we moeten het aantal bytes van de binnenkomende blokken optellen om te zien wanneer we klaar zijn met SF_NOTIFY_SEND_RAW_DATA en aan het scripten kunnen slaan.
  procedure ProcessHeader(var pfc: THTTP_FILTER_CONTEXT;
    var RawData: PHTTP_FILTER_RAW_DATA);
  var
    FilterContext: PFilterContext;
    i: Integer;
  begin
    StrDispose(pfc.pFilterContext);
    pfc.pFilterContext := nil;
    if (Pos('200 OK',PChar(RawData.pvInData)) in [1..20]) and
       (Pos('text/html',PChar(RawData.pvInData)) in [1..255]) then
    begin
      FilterContext := New(PFilterContext);
      FilterContext.Header :=
        StrNew(PChar(Copy(PChar(RawData.pvInData),1,RawData.cbInData)));
      FilterContext.HeaderLength := RawData.cbInData;
      FilterContext.Body := nil;
      FilterContext.BodyLength := 0;
      i := Pos('Content-Length:',PChar(FilterContext.Header));
      if i > 0 then
      begin
        repeat
          Inc(i)
        until FilterContext.Header[i] in ['1'..'9'];
        repeat
          FilterContext.BodyLength := 10 * FilterContext.BodyLength +
            Ord(FilterContext.Header[i]) - Ord('0');
          Inc(i)
        until not (FilterContext.Header[i] in ['0'..'9'])
      end;
      Log(Format('Header: 200 OK - %d bytes for Body of %d bytes',
            [RawData.cbInData,FilterContext.BodyLength]));
      if StrLen(FilterContext.Header) <> FilterContext.HeaderLength then
        Log(Format('Warning: StrLen %d <> %d HeaderLength',
          [StrLen(FilterContext.Header),FilterContext.HeaderLength]));
      pfc.pFilterContext := FilterContext;
      RawData.cbInData := 0; // no header to be sent!
    end
    else
      Log(Format('Header: %d bytes (ignored)',[RawData.cbInData]));
  end {ProcessHeader};

ProcessBody
Na de header is het de beurt aan ProcessBody om de binnenkomende brokken te verzamelen. We kunnen hierbij drie situaties onderkennen. De belangrijkste situatie doet zich voor als we het laatste blok hebben ontvangen, zodat we de ExecuteScript routine moeten uitvoeren, de lengte van de nieuwe body moeten tellen en in de Content-Length moeten zetten en zowel de header als de nieuwe body moeten terugsturen. Als het niet het laatste blok is, is het dus of het eerste blok of een tussenblok. Als het de eerste keer is hoeven we alleen maar het inkomende blok te kopiëren naar de Body pointer, en anders moeten we de binnenkomende RawData achter de Body plakken door deze PChar opnieuw te alloceren (met een nieuwe omvang), wat er weer redelijk complex uit ziet:

  FilterContext.Body := StrNew(PChar(StrPas(OldBody) +
    StrPas(PChar(Copy(PChar(RawData.pvInData),1,RawData.cbInData)))));
We moeten uiteraard ook de oude waarde van de Body vrijgeven (anders hebben we meerdere geheugenlekken), en de uiteindelijke code van ProcessBody ziet er dan ook als volgt uit:
  procedure ProcessBody(var pfc: THTTP_FILTER_CONTEXT;
    var RawData: PHTTP_FILTER_RAW_DATA);
  var
    FilterContext: PFilterContext;
    NewContent: String;
    OldBody: PChar;
    i: Cardinal;
  begin
    FilterContext := pfc.pFilterContext;
    if Assigned(FilterContext.Body) then
      i := StrLen(FilterContext.Body)
    else i := 0;
    Inc(i,RawData.cbInData);
    if i >= FilterContext.BodyLength then // Done!
    begin
      Log(Format('Final Body: %d of %d', [RawData.cbInData,FilterContext.BodyLength]));
      if Assigned(FilterContext.Body) then
      begin
        NewContent := StrPas(FilterContext.Body);
        StrDispose(FilterContext.Body);
        FilterContext.Body := nil;
      end
      else NewContent := '';
      NewContent := NewContent +
        StrPas(PChar(Copy(PChar(RawData.pvInData),1,RawData.cbInData)));
      Log('Content: '+IntToStr(FilterContext.BodyLength)+' bytes');

      ExecuteScript(pfc,NewContent);

      FilterContext.BodyLength := Length(NewContent); // new Content-Length;
      NewContent := StrPas(FilterContext.Header) + NewContent;
      i := Pos('Content-Length:',NewContent);
      if i > 0 then
      begin
        repeat Inc(i) until NewContent[i] in ['0'..'9'];
        repeat
          Delete(NewContent,i,1)
        until not (NewContent[i] in ['0'..'9']);
        Insert(IntToStr(FilterContext.BodyLength),NewContent,i);
      end;
      Log('New Content: '+IntToStr(FilterContext.BodyLength)+' bytes');
      StrDispose(FilterContext.Header);
      FilterContext.Header := nil;
      Dispose(pfc.pFilterContext);
      RawData.pvInData := pfc.AllocMem(pfc, Length(NewContent)+1, 0);
      for i:=1 to Length(NewContent) do
        PChar(RawData.pvInData)[i-1] := NewContent[i];
      PChar(RawData.pvInData)[Length(NewContent)] := #0;
      RawData.cbInBuffer := Length(NewContent)+1;
      RawData.cbInData := Length(NewContent);
    end
    else
    if not Assigned(FilterContext.Body) then // first block
    begin
      FilterContext.Body :=
        StrNew(PChar(Copy(PChar(RawData.pvInData),1,RawData.cbInData)));
      Log(Format('First Body: %d of %d', [RawData.cbInData,FilterContext.BodyLength]));
      if StrLen(FilterContext.Body) <> RawData.cbInData then
          Log(Format('Warning: StrLen %d <> %d BlockLength',
          [StrLen(FilterContext.Body),RawData.cbInData]));
      RawData.cbInData := 0 // no body to be sent, yet!
    end
    else  // additional block
    begin
      OldBody := FilterContext.Body;
      FilterContext.Body := StrNew(PChar(StrPas(OldBody) +
        StrPas(PChar(Copy(PChar(RawData.pvInData),1,RawData.cbInData)))));
      StrDispose(OldBody);
      Log(Format('Next Body: %d of %d', [RawData.cbInData,FilterContext.BodyLength]));
      RawData.cbInData := 0 // no body to be sent, yet!
    end
  end {ProcessBody};
Als we uiteindelijk klaar zijn met het scripten en de header en body willen teruggeven, moeten we ervoor zorgen het stuk geheugen hiervoor op een speciale manier te alloceren: namelijk met een callback functie genaamd AllocMem van het pfc argument. Alleen als we op die manier het geheugen alloceren zal het van IIS zelf zijn, en niet van onze ISAPI Filter.

Script Engine
Tot slot nog de Script Engine zelf, en die heb ik gewoon op een PageProducer gebouwd, waarbij ik #-tags vervang door te kijken of ze voorkomen als ServerVariable van de ISAPI Filter (zoals REMOTE_ADDR, SERVER_NAME en HTTP_USER_AGENT). Daarnaast heb ik enkele extra zaken ingebouwd zoals een teller (counter) en date, time en random getallen. De source code zou duidelijk moeten zijn:

  type
    TPfcPageProducer = class(TPageProducer)
    public
      FilterContext: THTTP_FILTER_CONTEXT;
      procedure TagExpander(Sender: TObject; Tag: TTag; const TagString: String;
        TagParams: TStrings; var ReplaceText: String);
    end;

  procedure TPfcPageProducer.TagExpander(Sender: TObject; Tag: TTag;
    const TagString: String; TagParams: TStrings; var ReplaceText: String);
  var
    i: Integer;

    function NextCounter: Integer;
    const
      CounterFileName = 'c:\script.ini';
    begin
      with TIniFile.Create(CounterFileName) do // requires IniFiles
      try
        Result := ReadInteger('counter', 'count', 0);
        WriteInteger('counter', 'count', Succ(Result));
      finally
        UpdateFile;
        Free
      end
    end {NextCounter};

    function GetServerVariable(const Variable: PChar): String;
    var
      MaxLen: Cardinal;
    begin
      MaxLen := 255;
      SetLength(Result,MaxLen);
      if FilterContext.GetServerVariable(FilterContext, Variable, PChar(Result), MaxLen) then
        SetLength(Result,StrLen(PChar(Result)))
      else
        Result := ''
    end {GetServerVariable};

  begin
    if TagString = 'time' then ReplaceText := TimeToStr(Now)
    else
    if TagString = 'date' then ReplaceText := DateToStr(Now)
    else
    if TagString = 'random' then
    begin
      i := StrToIntDef(TagParams.Values['max'],100);
      ReplaceText := IntToStr(Random(i))
    end
    else
    if TagString = 'counter' then
      ReplaceText := IntToStr(NextCounter)
    else
    if TagString = 'copyright' then
      ReplaceText := '<p><hr><font face="verdana"size=1><center>© 2002 ' +
               'by Bob Swart (aka Dr.Bob - <a href="http://www.drbob42.com">' +
               'www.drbob42.com</a>). All Rights Reserved.</font></p>'
    else
      ReplaceText :=
        GetServerVariable(PChar(UpperCase(TagString))) // empty if doesn't exist!
  end {TagExpander};

ExecuteScript
De aanroep van ExecuteScript kan nu vervangen worden door de volgende regels code die van de NewContent een nieuwe content maken:

  with TPfcPageProducer.Create(nil) do
  try
    FilterContext := Pfc;
    OnHTMLTag := TagExpander;
    HTMLDoc.Clear;
    HTMLDoc.Add(NewContent);
    NewContent := Content
  finally
    Free
  end;
Als test pagina kunnen we bijvoorbeeld de volgende gebruiken (hierop heb ik ongeveer alle Server variabelen gebruikt die ik kon vinden, en die een aardig overzicht geven):
  <html>
  <body bgcolor=ffffcc>
  <font face="verdana"size=2>
  <h1>Hello, ISAPI Filter world!</h1>
  <p>
  It's now <#time> on <#date> and this page has been processed
  by the ISAPI Filter Scripting engine for the <#counter>th time!
  <p>
  <table border="1">
  <tr><td bgcolor=white><font size=2><b>Server Variable</td>
         <td bgcolor=white><font size=2><b>Value</td></tr>
  <tr><td><font size=2>AUTH_TYPE</td><td><font size=2><#AUTH_TYPE></td></tr>
  <tr><td><font size=2>CONTENT_LENGTH</td><td><font size=2><#CONTENT_LENGTH></td></tr>
  <tr><td><font size=2>CONTENT_TYPE</td><td><font size=2><#CONTENT_TYPE></td></tr>
  <tr><td><font size=2>PATH_INFO</td><td><font size=2><#PATH_INFO></td></tr>
  <tr><td><font size=2>PATH_TRANSLATED</td><td><font size=2><#PATH_TRANSLATED></td></tr>
  <tr><td><font size=2>QUERY_STRING</td><td><font size=2><#QUERY_STRING></td></tr>
  <tr><td><font size=2>REMOTE_ADDR</td><td><font size=2><#REMOTE_ADDR></td></tr>
  <tr><td><font size=2>REMOTE_HOST</td><td><font size=2><#REMOTE_HOST></td></tr>
  <tr><td><font size=2>REMOTE_USER</td><td><font size=2><#REMOTE_USER></td></tr>
  <tr><td><font size=2>UNMAPPED_REMOTE_USER</td><td><font size=2><#UNMAPPED_REMOTE_USER></td></tr>
  <tr><td><font size=2>REQUEST_METHOD</td><td><font size=2><#REQUEST_METHOD></td></tr>
  <tr><td><font size=2>SCRIPT_NAME</td><td><font size=2><#SCRIPT_NAME></td></tr>
  <tr><td><font size=2>SERVER_NAME</td><td><font size=2><#SERVER_NAME></td></tr>
  <tr><td><font size=2>SERVER_PORT</td><td><font size=2><#SERVER_PORT></td></tr>
  <tr><td><font size=2>SERVER_PORT_SECURE</td><td><font size=2><#SERVER_PORT_SECURE></td></tr>
  <tr><td><font size=2>SERVER_PROTOCOL</td><td><font size=2><#SERVER_PROTOCOL></td></tr>
  <tr><td><font size=2>SERVER_SOFTWARE</td><td><font size=2><#SERVER_SOFTWARE></td></tr>
  <tr><td><font size=2>ALL_HTTP</td><td><font size=2><#ALL_HTTP></td></tr>
  <tr><td><font size=2>HTTP_ACCEPT</td><td><font size=2><#HTTP_ACCEPT></td></tr>
  <tr><td><font size=2>HTTP_USER_AGENT</td><td><font size=2><#HTTP_USER_AGENT></td></tr>
  <tr><td><font size=2>URL</td><td><font size=2><#URL></td></tr>
  </table>
  <p>
  <br>Random = <#random>, <#random>, <#random>, <#random>, <#random>, <#random>
  <br>Random(6) = <#random max=6>
  <br>Random(42) = <#random max=42>
  <p><#copyright>
  </body>
  </html>

Meer Informatie...
In deze sessie heb ik aan de hand van een aantal practische voorbeelden laten zien hoe we ISAPI Filters kunnen bouwen en installeren. De source code van alle voorbeelden is beschikbaar, en schroom niet om mij te benaderen als je nog vragen mocht hebben over het gebruik of de installatie van ISAPI Filters. Een commerciële editie van de "memberschip" ISAPI filter die gebruik maakt van Windows authenticatie om directories op een web server te beveiligen en een "members only" mogelijkheid op websites te bieden is o.a. in gebruik bij de web servers van TDMWeb.


This webpage © 2006-2008 by webmaster drs. Robert E. Swart (aka - www.drbob42.com). All Rights Reserved.