Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

23KB

Agents Guide — DP Jobs Kanban Board

What this project is

A Kanban Board web application running on IIS (Windows). It uses the RouteKit framework (front-controller pattern) with Keycloak OpenID Connect for authentication. The database is Microsoft Access (.accdb). There is no Node, npm, or build step — everything is server-side VBScript served by IIS.


Project layout

public/          IIS site root — point IIS here
  Default.asp    Front controller and route table
  web.config     App settings, URL rewrite rules

core/            Framework internals — do not modify
  autoload_core.asp
  mvc.asp
  router.wsc
  lib.*.asp      Core libraries (see below)
  helpers.asp    Global utility functions (always available)

app/
  controllers/   Controller classes (one per feature area)
  views/         View partials (folders match controller name)
    shared/      Header, footer, layout partials
  models/        POBO (plain-old business objects)
  repositories/  Data access classes

db/
  migrations/    Sequential migration scripts
  webdata.accdb  Access database

tests/           Dev-only aspunit test harness (separate IIS app)
scripts/         VBScript code generators

Core libraries (core/lib.*.asp)

File Purpose
lib.Keycloak.asp OpenID Connect / Keycloak auth helper
lib.Routes.asp URL generation helpers (Routes() singleton)
lib.ControllerRegistry.asp Controller whitelist (security)
lib.DAL.asp Database access layer (DAL singleton)
lib.Data.asp Data helpers
lib.Collections.asp LinkedList_Class and other collection types
lib.Enumerable.asp Collection iteration helpers
lib.Validations.asp Input validation
lib.Flash.asp Flash message helpers
lib.FormCache.asp Form value re-population
lib.HTML.asp HTML rendering helpers
lib.HTML.Security.asp XSS escaping — H() function
lib.Strings.asp String utilities
lib.Automapper.asp Automapper.AutoMap(rs, "POBO_TableName")
lib.ErrorHandler.asp Error handling
lib.Migrations.asp Migration runner
lib.json.asp JSON parsing/serialization
lib.Upload.asp File upload helper
lib.CDOEmail.asp Email via CDO
lib.ad.auth.asp Active Directory auth
lib.crypto.helper.asp Crypto utilities

Global helper functions (core/helpers.asp)

These are always available — never re-implement them.

Function Signature Purpose
H H(s) XSS-safe HTML encode — use on all user data rendered to HTML
GenerateSlug GenerateSlug(title) Converts a title to a safe URL slug (lowercase, hyphens, alphanumeric only)
GetRawJsonFromRequest GetRawJsonFromRequest() Reads raw JSON body from an AJAX POST request
GetAppSetting GetAppSetting(key) Reads a value from web.config appSettings (cached in Application)
IIf IIf(condition, trueVal, falseVal) Inline conditional
TrimQueryParams TrimQueryParams(path) Strips query string from a URL path
Destroy Destroy(obj) Safely closes and sets an object to Nothing
QuoteValue QuoteValue(val) Quotes a value for SQL (prefer parameterized queries via DAL instead)
FormatDateForSql FormatDateForSql(date) Formats a VBScript date as a SQL Server datetime string
Active Active(controllerName) Returns "active" if the current request maps to that controller
SurroundString SurroundString(val) Wraps strings in double-quotes (used internally by dispatcher)
GetDynamicProperty GetDynamicProperty(obj, propName) Reads a property by name from any VBScript object
RenderObjectsAsTable RenderObjectsAsTable(arr, useTabulator) Generates an HTML table from an array of POBOs
RenderFormFromObject RenderFormFromObject(obj) Generates an HTML form from a POBO

Router — URL parameters

Routes are registered in public/Default.asp and support :param segments.

Registering routes:

' Static route
router.AddRoute "GET",  "/boards",           "BoardsController", "Index"
router.AddRoute "POST", "/boards",           "BoardsController", "Store"

' Route with one URL parameter
router.AddRoute "GET",  "/board/:slug",      "BoardsController", "Show"
router.AddRoute "POST", "/board/:slug",      "BoardsController", "Update"

' Route with one URL parameter and a sub-action
router.AddRoute "POST", "/cards/:id/move",   "CardsController",  "Move"
router.AddRoute "POST", "/cards/:id/delete", "CardsController",  "Destroy"

Controller action receives parameters as arguments in order:

' Matches /board/:slug
Public Sub Show(slug)
    ' slug is the extracted URL segment
End Sub

' Matches /cards/:id/move
Public Sub Move(id)
    ' id is the extracted URL segment
End Sub

The router does an exact match first, then falls back to :param pattern matching. If nothing matches it returns ErrorController#NotFound.

URL generation from controllers/views:

' /boards
Routes.UrlTo "Boards", "Index", Empty

' /board/my-board  (route param appended as positional segment)
Routes.UrlToWithParams "Boards", "Show", Array("my-board"), Empty

' With query string
Routes.UrlTo "Boards", "Index", Array("page", 2)

All requests are rewritten through Default.asp by the IIS URL Rewrite rule. Static assets (css/, js/, images/, favicon.ico) bypass the rewrite.


POBO pattern (app/models/)

Plain-Old Business Object — one class per table. Generated by scripts/GenerateRepo.vbs.

<%
Class POBO_boards
    Public Properties              ' array of all column names

    Private p_id
    Private p_name
    Private p_slug
    Private p_created_at
    Private p_created_by
    Private p_updated_at
    Private p_updated_by

    Private Sub Class_Initialize()
        p_id         = 0
        p_name       = ""
        p_slug       = ""
        p_created_at = #1/1/1970#
        p_created_by = ""
        p_updated_at = #1/1/1970#
        p_updated_by = ""
        Properties = Array("id","name","slug","created_at","created_by","updated_at","updated_by")
    End Sub

    Public Property Get PrimaryKey() : PrimaryKey = "id" : End Property
    Public Property Get TableName()  : TableName  = "boards" : End Property

    Public Property Get id()   : id   = p_id   : End Property
    Public Property Let id(v)  : p_id = CDbl(v) : End Property

    Public Property Get name()  : name  = p_name   : End Property
    Public Property Let name(v) : p_name = CStr(v) : End Property

    ' ... remaining Get/Let pairs follow same pattern
End Class
%>

Key rules:

  • Private backing fields use the p prefix: p_id, p_name, etc.
  • Properties array lists all column names — required by Automapper and RenderObjectsAsTable.
  • PrimaryKey and TableName must match the actual table.

Repository pattern (app/repositories/)

Generated by scripts/GenerateRepo.vbs. Uses the DAL singleton and Automapper.

<%
Class boards_Repository_Class

    ' Single record by PK
    Public Function FindByID(id)
        Dim sql : sql = "SELECT [id],[name],[slug],[created_at],[created_by],[updated_at],[updated_by] FROM [boards] WHERE [id] = ?"
        Dim rs : Set rs = DAL.Query(sql, Array(id))
        If rs.EOF Then
            Err.Raise 1, "boards_Repository_Class", "boards record not found with id = " & id
        Else
            Set FindByID = Automapper.AutoMap(rs, "POBO_boards")
        End If
        Destroy rs
    End Function

    ' All records, optional order
    Public Function GetAll(orderBy)
        Set GetAll = Find(Empty, orderBy)
    End Function

    ' Filtered list — where_kvarray is a flat key/value array: Array("col", val, "col2", val2)
    Public Function Find(where_kvarray, order_string_or_array)
        Dim sql : sql = "SELECT ... FROM [boards]"
        Dim where_keys, where_values, i
        If Not IsEmpty(where_kvarray) Then
            KVUnzip where_kvarray, where_keys, where_values
            ' appends WHERE clause for each key
        End If
        sql = sql & BuildOrderBy(order_string_or_array, "[id]")
        Dim rs : Set rs = DAL.Query(sql, where_values)
        Dim list : Set list = New LinkedList_Class
        Do Until rs.EOF
            list.Push Automapper.AutoMap(rs, "POBO_boards")
            rs.MoveNext
        Loop
        Set Find = list
        Destroy rs
    End Function

    ' Insert — sets model.id to the new identity value
    Public Sub AddNew(ByRef model)
        Dim sql : sql = "INSERT INTO [boards] ([name],[slug],...) VALUES (?,?,...)"
        DAL.Execute sql, Array(model.name, model.slug, ...)
        Dim rsId : Set rsId = DAL.Query("SELECT @@IDENTITY AS NewID", Empty)
        If Not rsId.EOF Then
            If Not IsNull(rsId(0)) Then model.id = rsId(0)
        End If
        Destroy rsId
    End Sub

    ' Update — all columns except PK, PK appended last for WHERE
    Public Sub Update(model)
        Dim sql : sql = "UPDATE [boards] SET [name]=?,[slug]=?,... WHERE [id]=?"
        DAL.Execute sql, Array(model.name, model.slug, ..., model.id)
    End Sub

    ' Delete by PK
    Public Sub Delete(id)
        DAL.Execute "DELETE FROM [boards] WHERE [id]=?", Array(id)
    End Sub

End Class

Dim boards_Repository__Singleton
Function boards_Repository()
    If IsEmpty(boards_Repository__Singleton) Then
        Set boards_Repository__Singleton = New boards_Repository_Class
    End If
    Set boards_Repository = boards_Repository__Singleton
End Function
%>

Controller pattern (app/controllers/)

Generated by scripts/generateController.vbs. Singleton per request.

<%
Class BoardsController_Class
    Private m_useLayout
    Private m_title

    Private Sub Class_Initialize()
        m_useLayout = True
        m_title     = "Boards"
    End Sub

    Public Property Get useLayout()    : useLayout = m_useLayout : End Property
    Public Property Let useLayout(v)   : m_useLayout = v         : End Property
    Public Property Get Title()        : Title = m_title         : End Property
    Public Property Let Title(v)       : m_title = v             : End Property

    ' GET /boards
    Public Sub Index()
        If Not KeycloakRequireLogin("") Then Exit Sub
        ' ... load data, render view
    End Sub

    ' GET /board/:slug  — slug comes from the URL parameter
    Public Sub Show(slug)
        If Not KeycloakRequireLogin("") Then Exit Sub
        ' ... load board by slug, render view
    End Sub

    ' POST /boards  — reads from Request.Form
    Public Sub Store()
        If Not KeycloakRequireLogin("") Then Exit Sub
        ' ... validate, save, redirect
    End Sub

    ' JSON endpoint — set useLayout = False, write JSON response
    Public Sub Move(id)
        m_useLayout = False
        Response.ContentType = "application/json"
        If Not KeycloakIsLoggedIn() Then
            Response.Write "{""ok"":false,""error"":""Unauthorized""}"
            Exit Sub
        End If
        ' ... process, write JSON
    End Sub

End Class

Dim BoardsController_Class__Singleton
Function BoardsController()
    If IsEmpty(BoardsController_Class__Singleton) Then
        Set BoardsController_Class__Singleton = New BoardsController_Class
    End If
    Set BoardsController = BoardsController_Class__Singleton
End Function
%>

JSON / AJAX actions must set m_useLayout = False and Response.ContentType = "application/json".

Reading a JSON request body (sent by fetch / XHR):

Dim rawJson : rawJson = GetRawJsonFromRequest()
' then parse with lib.json.asp

Audit columns

Every table must include these four audit columns:

Column Type Populated by
created_at DateTime Set once on insert: Now()
created_by Text(255) Set once on insert: preferred_username from Keycloak
updated_at DateTime Updated on every save: Now()
updated_by Text(255) Updated on every save: preferred_username from Keycloak

Getting the current username in a controller action:

Dim currentUser : Set currentUser = KeycloakCurrentUser()
Dim currentUsername : currentUsername = ""
If Not currentUser Is Nothing Then
    currentUsername = currentUser.Item("preferred_username")
End If

Setting audit fields before insert:

model.created_at = Now()
model.created_by = currentUsername
model.updated_at = Now()
model.updated_by = currentUsername
repo.AddNew model

Setting audit fields before update:

model.updated_at = Now()
model.updated_by = currentUsername
repo.Update model

created_at and created_by are never changed after the initial insert.


Slug generation

GenerateSlug(title) is already in core/helpers.asp. Always use it — never write your own slug logic.

Dim slug : slug = GenerateSlug(Request.Form("name"))
' "My Board & Things!" → "my-board-and-things"

After generating, check uniqueness against the database before saving. If the slug already exists, append a suffix (e.g., -2, -3).


Wiring up a new controller — checklist

  1. Generate: cscript //nologo scripts\generateController.vbs MyController "Index;Show(slug);Store"
  2. Move generated file to app/controllers/
  3. Register in core/lib.ControllerRegistry.asp: RegisterController "mycontroller"
  4. Include in app/controllers/autoload_controllers.asp: <!--#include file="MyController.asp"-->
  5. Add routes in public/Default.asp
  6. Create views in app/views/MyController/

Authentication (Keycloak)

The helper in core/lib.Keycloak.asp implements the OpenID Connect authorization-code flow.

Key functions:

KeycloakRequireLogin("")          ' Gate full-page actions — redirects if not logged in; returns False to signal early exit
KeycloakIsLoggedIn()              ' Use this for JSON/AJAX actions instead of RequireLogin
KeycloakCurrentUser()             ' Returns userinfo Dictionary — keys include preferred_username, email, name, sub
KeycloakLogin()                   ' Redirect to Keycloak
KeycloakHandleCallback()          ' Exchange code, store tokens — returns True on success
KeycloakAccessToken()             ' Raw access token string
KeycloakRefreshToken()
KeycloakIdToken()
KeycloakTokenClaims(token)        ' Decode JWT payload into dictionary
KeycloakConsumePostLoginRedirectPath("/")  ' Get and clear stored return path
KeycloakHasRealmRole("admin")     ' Role check against ID token
KeycloakHasClientRole(clientId, role)
KeycloakLogout("")                ' Clear session and redirect to Keycloak logout

Session keys use the Keycloak_ prefix. Tokens are stored in Session, not cookies.


Configuration (public/web.config appSettings)

Key Description
ConnectionString ACE OLEDB path to webdata.accdb
Environment Development, Staging, or Production
KeycloakBaseUrl Keycloak server base URL (no /realms/...)
KeycloakRealm Keycloak realm name
KeycloakClientId Client ID
KeycloakClientSecret Client secret — never commit real values
KeycloakRedirectUri Absolute callback URL
KeycloakLogoutRedirectUri Post-logout redirect URL
KeycloakScope OIDC scopes (default: openid profile email)
KeycloakEnableLogging true/false
KeycloakLogPath Path to Keycloak log file
EnableErrorLogging true/false
ErrorLogPath Path to error log file

Testing

Tests live in tests/ and run as a separate IIS application (never through the production public/ root).

  • Framework: tests/aspunit/ (vendored)
  • Runner: browse to run-all.asp in the test IIS app
  • Manifest: tests/test-manifest.asp — register new test pages here manually
  • Bootstrap: tests/bootstrap.asp — shared setup for all test pages
Folder Use for
tests/unit/ Deterministic helper and registry tests
tests/component/ Controlled controller/object tests
tests/integration/ Router/dispatch smoke tests, config behavior, rendered-page capture

After changing tests/web.config: cscript //nologo tests\sync-webconfigs.vbs


Requirements

  • Windows Server / Windows with IIS
  • Classic ASP enabled
  • IIS URL Rewrite module
  • Microsoft Access Database Engine (ACE OLEDB 12.0)
  • Keycloak server (for auth flows)

LinkedList_Class — correct traversal

LinkedList_Class (from core/lib.Collections.asp) does not have public .First or .Next properties — m_first and m_last are private fields. Calling .First on a list throws Error 438.

Always use the Iterator() pattern:

Dim iter : Set iter = myList.Iterator()
Do While iter.HasNext()
    Set item = iter.GetNext()   ' returns the value directly — NOT a node wrapper
    ' use item
Loop

GetNext() returns the value object directly, so there is no .m_value or .m_next to dereference.

Other valid approaches:

' Convert to array for indexed access
Dim arr : arr = myList.TO_Array()
For i = 0 To UBound(arr)
    Set item = arr(i)
Next

' Get single values at the ends
myList.Front()   ' returns first value
myList.Back()    ' returns last value
myList.Count     ' number of elements
myList.IsEmpty() ' Boolean

Never use .First, .Last, .m_first, .m_next, or .m_value on a list — those are internal node fields, not a public API.


AJAX form data — always use URLSearchParams, never FormData

Classic ASP's Request.Form collection only parses application/x-www-form-urlencoded POST bodies. Using new FormData() in JavaScript sends multipart/form-data, which Classic ASP silently ignores — every field comes back as an empty string.

Always use URLSearchParams for fetch POST requests that the server reads via Request.Form:

function post(url, data, cb) {
    var params = new URLSearchParams();
    Object.keys(data).forEach(function (k) { params.append(k, data[k]); });
    fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: params.toString()
    })
      .then(function (r) { return r.json(); })
      .then(cb)
      .catch(function (e) { console.error(url, e); });
}

Request.BinaryRead vs Request.Form mutual exclusion: Once you call Request.BinaryRead() (which GetRawJsonFromRequest() does internally), Request.Form becomes unavailable for that same request, and vice versa. Never mix the two in the same action. Use GetRawJsonFromRequest() only in actions that receive a raw JSON body (e.g. Reorder), and use Request.Form only in actions that receive URL-encoded form data (e.g. Store, Update, Move).


Critical Classic ASP scoping rule for views

Controller actions include view files via SSI (<!--#include file="..."-->). The included file's content compiles inside the controller action's Sub/Function scope. This means:

  • Never define Function or Sub inside a view file — VBScript forbids nested procedure definitions. You will get Syntax error 800a03ea.
  • All Dim, ReDim, loops, and conditionals in a view are fine — only Function/Sub declarations are forbidden.
  • If a view needs a helper function, define it as a Private Function in the controller class, build the result in the action, and pass it as a variable the view can reference.
  • Keep views to pure HTML rendering: <%= variable %>, H(value), simple <% If / For / Do %> blocks, and includes of other partials.

Things to avoid

  • Do not modify files under core/ — these are framework internals.
  • Do not add controllers without registering them in ControllerRegistry — the MVC dispatcher will reject unregistered names.
  • Do not commit real KeycloakClientSecret values — inject per environment.
  • Do not add test routes or test pages under public/.
  • Do not use the production public/ IIS app to run tests.
  • Always use H() when rendering user-supplied data to prevent XSS.
  • Never write your own slug generator — use GenerateSlug() from helpers.asp.
  • Never use Private Const or Public Const inside a VBScript class — it causes a syntax error. Use a Private Function that returns the value instead.
  • In Access/Jet SQL DDL, common reserved words include COLUMNS, NAME, POSITION, VALUE, DATE, KEY, LEVEL, BY. Always use migration.ExecuteSQL and bracket every identifier — both table names and column names — e.g. CREATE TABLE [board_columns] ([id] AUTOINCREMENT PRIMARY KEY, [name] VARCHAR(255), [position] INTEGER, ...). Never use migration.CreateTable or migration.CreateIndex; use migration.ExecuteSQL for all DDL.
  • Never read raw JSON bodies manually — use GetRawJsonFromRequest() from helpers.asp.
  • Never use new FormData() in JavaScript for requests that Classic ASP reads via Request.Form — it sends multipart/form-data which Classic ASP silently ignores. Use URLSearchParams instead.
  • Never mix Request.BinaryRead / GetRawJsonFromRequest with Request.Form in the same action — they are mutually exclusive in Classic ASP.
  • Never call .First, .Last, .m_first, .m_next, or .m_value on a LinkedList_Class instance — those are private internals. Use .Iterator() / HasNext() / GetNext() or .TO_Array().
  • Always set all four audit columns (created_at, created_by, updated_at, updated_by) on every insert and update.
  • For JSON/AJAX actions, always set m_useLayout = False and Response.ContentType = "application/json" at the top of the action.

Recent lessons (2026-04-22)

  • IIS/runtime bitness matters for Access on this machine. Use 32-bit IIS Express (%ProgramFiles(x86)%\IIS Express\iisexpress.exe) and keep enable32BitAppOnWin64="true" in the active app pool.
  • Run migrations with 32-bit cscript:
    • C:\Windows\SysWOW64\cscript.exe //nologo scripts\runMigrations.vbs up
  • Standalone migration context (scripts/runMigrations.vbs) is not identical to IIS runtime migration context. migration.DB.Execute/Query may fail there; migrations should support fallback via migration.Connection + ADODB.Command.
  • Flash_Class API is AddError and Success property assignment. SetError / SetSuccess are invalid and cause 438.
  • KeycloakCurrentUser() should be read defensively. Do not assume .Exists(...) is always available; use guarded .Item("preferred_username") / .Item("email") reads.
  • Shared navbar should include direct /boards navigation and use Active("boards") for active state.

Additions to Things to avoid

  • Never run scripts\runMigrations.vbs with 64-bit cscript on this machine; use C:\Windows\SysWOW64\cscript.exe.
  • Never assume migration helper parity between IIS and standalone runner; if migration.DB.Execute/Query are used, provide a migration.Connection fallback path.
  • Never call Flash().SetError or Flash().SetSuccess; use Flash().AddError and flash.Success = "...".

Powered by TurnKey Linux.