- New AdminOrdersController + view: table of Orders with search by email/jurisdiction #, pagination (20/page), and per-row mailto link to resend the order-continue URL - OrdersRepository: add GetAll() and SearchPaged() (LIKE search, PagedQuery-based pagination) - MailtoEncode helper added to core/helpers.asp (mailto-safe URL encoding: %20 not +) - Controller registered in autoload + ControllerRegistry - public-admin/ folder: dedicated Default.asp routes, web.config with 192.168.1.0/24 ipSecurity, production template with same restriction - applicationhost.config: second IIS Express site (Admin Web Site, port 8081) - run_site.cmd: launches both sites (public in separate window, admin in foreground) - build-release.ps1: public-admin in allow-list, swaps its web.config from production template - deploy-iis-remote-apply.ps1: optional admin site/app-pool config + restart - deploy-iis.ps1: -AdminSiteName/-AdminAppPool/-AdminBaseUrl params, admin smoke testmaster
| @@ -0,0 +1,65 @@ | |||||
| <% | |||||
| Class AdminOrdersController_Class | |||||
| Private m_useLayout | |||||
| Private m_title | |||||
| Private Sub Class_Initialize() | |||||
| m_useLayout = True | |||||
| m_title = "Orders" | |||||
| 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: list orders (searchable by email/jurisdiction #, paginated) with a mailto link to | |||||
| ' resend the continuation URL for each. | |||||
| '------------------------------------------------------------------------------------------------------------------- | |||||
| Public Sub Index() | |||||
| Dim orders, searchTerm, pageNum, perPage, pageCount, recordCount | |||||
| perPage = 20 | |||||
| searchTerm = Trim(Request.QueryString("q")) | |||||
| If IsNumeric(Request.QueryString("page")) And CLng(Request.QueryString("page")) >= 1 Then | |||||
| pageNum = CLng(Request.QueryString("page")) | |||||
| Else | |||||
| pageNum = 1 | |||||
| End If | |||||
| Set orders = OrdersRepository().SearchPaged(searchTerm, perPage, pageNum, pageCount, recordCount) | |||||
| ' If the requested page is past the last one (e.g. a stale bookmark after a search | |||||
| ' narrows the results), clamp to the last real page and re-fetch. | |||||
| If pageCount > 0 And pageNum > pageCount Then | |||||
| pageNum = pageCount | |||||
| Set orders = OrdersRepository().SearchPaged(searchTerm, perPage, pageNum, pageCount, recordCount) | |||||
| End If | |||||
| %> | |||||
| <!--#include file="../views/AdminOrders/index.asp" --> | |||||
| <% | |||||
| End Sub | |||||
| End Class | |||||
| Dim AdminOrdersController_Class__Singleton | |||||
| Function AdminOrdersController() | |||||
| If IsEmpty(AdminOrdersController_Class__Singleton) Then | |||||
| Set AdminOrdersController_Class__Singleton = New AdminOrdersController_Class | |||||
| End If | |||||
| Set AdminOrdersController = AdminOrdersController_Class__Singleton | |||||
| End Function | |||||
| %> | |||||
| @@ -10,4 +10,5 @@ | |||||
| <!--#include file="ErrorController.asp" --> | <!--#include file="ErrorController.asp" --> | ||||
| <!--#include file="RequestOrderController.asp" --> | <!--#include file="RequestOrderController.asp" --> | ||||
| <!--#include file="OrderController.asp" --> | <!--#include file="OrderController.asp" --> | ||||
| <!--#include file="OrderApiController.asp" --> | |||||
| <!--#include file="OrderApiController.asp" --> | |||||
| <!--#include file="AdminOrdersController.asp" --> | |||||
| @@ -18,6 +18,80 @@ Class OrdersRepository_Class | |||||
| CreateOrder = token | CreateOrder = token | ||||
| End Function | End Function | ||||
| ' Returns a Scripting.Dictionary of Scripting.Dictionary rows for every order, newest first. | |||||
| Public Function GetAll() | |||||
| Dim rs, results, order | |||||
| Set results = Server.CreateObject("Scripting.Dictionary") | |||||
| Set rs = DAL.Query( _ | |||||
| "SELECT OrderID, Email, JurisdictionNumber, Token, TokenExpiresAt, TokenUsedAt, CreatedAt FROM Orders ORDER BY OrderID DESC", _ | |||||
| Empty) | |||||
| Do While Not rs.EOF | |||||
| Set order = Server.CreateObject("Scripting.Dictionary") | |||||
| order.Add "OrderID", rs("OrderID").Value | |||||
| order.Add "Email", rs("Email").Value | |||||
| order.Add "JurisdictionNumber", rs("JurisdictionNumber").Value | |||||
| order.Add "Token", rs("Token").Value | |||||
| order.Add "TokenExpiresAt", rs("TokenExpiresAt").Value | |||||
| order.Add "TokenUsedAt", rs("TokenUsedAt").Value | |||||
| order.Add "CreatedAt", rs("CreatedAt").Value | |||||
| results.Add rs("OrderID").Value, order | |||||
| rs.MoveNext | |||||
| Loop | |||||
| Destroy rs | |||||
| Set GetAll = results | |||||
| End Function | |||||
| ' Returns a Scripting.Dictionary of Scripting.Dictionary rows (newest first) matching searchTerm | |||||
| ' against Email or JurisdictionNumber (all rows if searchTerm is blank), one page at a time. | |||||
| ' page_count/record_count are returned by reference for building pagination controls. | |||||
| Public Function SearchPaged(searchTerm, per_page, page_num, ByRef page_count, ByRef record_count) | |||||
| Dim sql, params | |||||
| sql = "SELECT OrderID, Email, JurisdictionNumber, Token, TokenExpiresAt, TokenUsedAt, CreatedAt FROM Orders" | |||||
| page_count = 0 | |||||
| record_count = 0 | |||||
| If Len(Trim(searchTerm)) > 0 Then | |||||
| sql = sql & " WHERE Email LIKE ? OR JurisdictionNumber LIKE ?" | |||||
| params = Array("%" & searchTerm & "%", "%" & searchTerm & "%") | |||||
| Else | |||||
| params = Empty | |||||
| End If | |||||
| sql = sql & " ORDER BY OrderID DESC" | |||||
| Dim rs : Set rs = DAL.PagedQuery(sql, params, per_page, page_num) | |||||
| If Not rs.EOF Then | |||||
| rs.PageSize = per_page | |||||
| rs.AbsolutePage = page_num | |||||
| page_count = rs.PageCount | |||||
| record_count = rs.RecordCount | |||||
| End If | |||||
| Dim results, order, x | |||||
| Set results = Server.CreateObject("Scripting.Dictionary") | |||||
| x = 0 | |||||
| Do While (per_page <= 0 Or x < per_page) And Not rs.EOF | |||||
| Set order = Server.CreateObject("Scripting.Dictionary") | |||||
| order.Add "OrderID", rs("OrderID").Value | |||||
| order.Add "Email", rs("Email").Value | |||||
| order.Add "JurisdictionNumber", rs("JurisdictionNumber").Value | |||||
| order.Add "Token", rs("Token").Value | |||||
| order.Add "TokenExpiresAt", rs("TokenExpiresAt").Value | |||||
| order.Add "TokenUsedAt", rs("TokenUsedAt").Value | |||||
| order.Add "CreatedAt", rs("CreatedAt").Value | |||||
| results.Add rs("OrderID").Value, order | |||||
| x = x + 1 | |||||
| rs.MoveNext | |||||
| Loop | |||||
| Destroy rs | |||||
| Set SearchPaged = results | |||||
| End Function | |||||
| ' Returns a Scripting.Dictionary describing the order matching the given token, or Nothing if not found. | ' Returns a Scripting.Dictionary describing the order matching the given token, or Nothing if not found. | ||||
| Public Function FindByToken(token) | Public Function FindByToken(token) | ||||
| Dim rs | Dim rs | ||||
| @@ -0,0 +1,87 @@ | |||||
| <section style="padding: 32px 0;"> | |||||
| <h1>Orders</h1> | |||||
| <form method="get" action="" class="row g-2 align-items-center" style="margin-bottom: 16px;"> | |||||
| <div class="col-auto"> | |||||
| <input type="text" name="q" value="<%= H(searchTerm) %>" class="form-control" placeholder="Search by email or jurisdiction #" /> | |||||
| </div> | |||||
| <div class="col-auto"> | |||||
| <button type="submit" class="button button-secondary">Search</button> | |||||
| </div> | |||||
| <% If Len(searchTerm) > 0 Then %> | |||||
| <div class="col-auto"> | |||||
| <a href="?">Clear</a> | |||||
| </div> | |||||
| <% End If %> | |||||
| </form> | |||||
| <table class="table table-striped table-bordered"> | |||||
| <thead> | |||||
| <tr> | |||||
| <th>Order ID</th> | |||||
| <th>Email</th> | |||||
| <th>Jurisdiction #</th> | |||||
| <th>Created</th> | |||||
| <th>Token Status</th> | |||||
| <th>Resend Link</th> | |||||
| </tr> | |||||
| </thead> | |||||
| <tbody> | |||||
| <% If orders.Count = 0 Then %> | |||||
| <tr><td colspan="6"><% If Len(searchTerm) > 0 Then %>No orders match your search.<% Else %>No orders found.<% End If %></td></tr> | |||||
| <% Else | |||||
| Dim orderKey, order, tokenStatus, continueUrl, mailtoBody, mailtoHref | |||||
| For Each orderKey In orders.Keys | |||||
| Set order = orders(orderKey) | |||||
| If Not IsNull(order("TokenUsedAt")) Then | |||||
| tokenStatus = "Used" | |||||
| ElseIf order("TokenExpiresAt") < Now() Then | |||||
| tokenStatus = "Expired" | |||||
| Else | |||||
| tokenStatus = "Active" | |||||
| End If | |||||
| continueUrl = "https://pe.kentcommunications.com/order/continue?token=" & MailtoEncode(order("Token")) | |||||
| mailtoBody = "Continue your order here: " & continueUrl | |||||
| mailtoHref = "mailto:" & order("Email") & _ | |||||
| "?subject=" & MailtoEncode("Continue your Purple Envelope order") & _ | |||||
| "&body=" & MailtoEncode(mailtoBody) | |||||
| %> | |||||
| <tr> | |||||
| <td><%= H(order("OrderID")) %></td> | |||||
| <td><%= H(order("Email")) %></td> | |||||
| <td><%= H(order("JurisdictionNumber")) %></td> | |||||
| <td><%= H(order("CreatedAt")) %></td> | |||||
| <td><%= H(tokenStatus) %></td> | |||||
| <td><a class="button button-secondary" href="<%= H(mailtoHref) %>">Email link</a></td> | |||||
| </tr> | |||||
| <% | |||||
| Next | |||||
| End If | |||||
| %> | |||||
| </tbody> | |||||
| </table> | |||||
| <% If pageCount > 1 Then | |||||
| Dim qsBase, pn | |||||
| qsBase = "?q=" & Server.URLEncode(searchTerm) & "&page=" | |||||
| %> | |||||
| <nav aria-label="Orders pagination"> | |||||
| <ul class="pagination"> | |||||
| <li class="page-item<% If pageNum <= 1 Then %> disabled<% End If %>"> | |||||
| <a class="page-link" href="<%= H(qsBase & (pageNum - 1)) %>">Previous</a> | |||||
| </li> | |||||
| <% For pn = 1 To pageCount %> | |||||
| <li class="page-item<% If pn = pageNum Then %> active<% End If %>"> | |||||
| <a class="page-link" href="<%= H(qsBase & pn) %>"><%= pn %></a> | |||||
| </li> | |||||
| <% Next %> | |||||
| <li class="page-item<% If pageNum >= pageCount Then %> disabled<% End If %>"> | |||||
| <a class="page-link" href="<%= H(qsBase & (pageNum + 1)) %>">Next</a> | |||||
| </li> | |||||
| </ul> | |||||
| </nav> | |||||
| <p><%= recordCount %> order<% If recordCount <> 1 Then %>s<% End If %> found.</p> | |||||
| <% End If %> | |||||
| </section> | |||||
| @@ -162,6 +162,14 @@ | |||||
| <binding protocol="http" bindingInformation=":8080:localhost" /> | <binding protocol="http" bindingInformation=":8080:localhost" /> | ||||
| </bindings> | </bindings> | ||||
| </site> | </site> | ||||
| <site name="Admin Web Site" id="2" serverAutoStart="true"> | |||||
| <application path="/"> | |||||
| <virtualDirectory path="/" physicalPath="%ASPC_STARTER_ROOT%public-admin" /> | |||||
| </application> | |||||
| <bindings> | |||||
| <binding protocol="http" bindingInformation=":8081:localhost" /> | |||||
| </bindings> | |||||
| </site> | |||||
| <siteDefaults> | <siteDefaults> | ||||
| <!-- To enable logging, please change the below attribute "enabled" to "true" --> | <!-- To enable logging, please change the below attribute "enabled" to "true" --> | ||||
| <logFile logFormat="W3C" directory="%AppData%\Microsoft\IISExpressLogs" enabled="false" /> | <logFile logFormat="W3C" directory="%AppData%\Microsoft\IISExpressLogs" enabled="false" /> | ||||
| @@ -201,6 +201,14 @@ Function H(s) | |||||
| End Function | End Function | ||||
| '=============================================================================================================================== | |||||
| ' mailto: links require %20 for spaces, not "+" - Server.URLEncode produces "+", so swap it back after encoding. | |||||
| '=============================================================================================================================== | |||||
| Function MailtoEncode(s) | |||||
| MailtoEncode = Replace(Server.URLEncode(s), "+", "%20") | |||||
| End Function | |||||
| '======================================================================================================================= | '======================================================================================================================= | ||||
| ' Adapted from Tolerable library | ' Adapted from Tolerable library | ||||
| '======================================================================================================================= | '======================================================================================================================= | ||||
| @@ -18,6 +18,7 @@ Class ControllerRegistry_Class | |||||
| RegisterController "requestordercontroller" | RegisterController "requestordercontroller" | ||||
| RegisterController "ordercontroller" | RegisterController "ordercontroller" | ||||
| RegisterController "orderapicontroller" | RegisterController "orderapicontroller" | ||||
| RegisterController "adminorderscontroller" | |||||
| End Sub | End Sub | ||||
| Private Sub Class_Terminate() | Private Sub Class_Terminate() | ||||
| @@ -0,0 +1,13 @@ | |||||
| <!--#include file="..\core\autoload_core.asp" --> | |||||
| <% | |||||
| ' Admin site routes | |||||
| router.AddRoute "GET", "/", "AdminOrdersController", "Index" | |||||
| router.AddRoute "GET", "", "AdminOrdersController", "Index" | |||||
| router.AddRoute "GET", "/orders", "AdminOrdersController", "Index" | |||||
| router.AddRoute "GET", "/404", "ErrorController", "NotFound" | |||||
| ' Dispatch the request (resolves route and executes controller action) | |||||
| MVC.DispatchRequest Request.ServerVariables("REQUEST_METHOD"), _ | |||||
| TrimQueryParams(Request.ServerVariables("HTTP_X_ORIGINAL_URL")) | |||||
| %> | |||||
| @@ -0,0 +1,129 @@ | |||||
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <configuration> | |||||
| <appSettings> | |||||
| <!-- | |||||
| Access connection string. | |||||
| IMPORTANT: Change Data Source to the real physical path | |||||
| to your webdata.accdb file. | |||||
| --> | |||||
| <add key="ConnectionString" | |||||
| value="Provider=Microsoft.ACE.OLEDB.12.0;Data Source=D:\Development\ASP CLASSIC\Purple Envelope Orders Site\db\webdata.accdb;Persist Security Info=False;" /> | |||||
| <!-- Environment flag (Development / Staging / Production) --> | |||||
| <add key="Environment" value="Development" /> | |||||
| <!-- Flash message display duration in milliseconds --> | |||||
| <add key="FlashMessageTimeout" value="8000" /> | |||||
| <!-- 404 error page redirect countdown in seconds --> | |||||
| <add key="Error404RedirectSeconds" value="5" /> | |||||
| <!-- Cache expiration year for static content --> | |||||
| <add key="CacheExpirationYear" value="2030" /> | |||||
| <!-- Maximum characters to display in table cells before truncating --> | |||||
| <add key="TableCellMaxLength" value="90" /> | |||||
| <!-- Character threshold for textarea vs input field in forms --> | |||||
| <add key="FormTextareaThreshold" value="100" /> | |||||
| <!-- Enable error logging to file (true/false) --> | |||||
| <add key="EnableErrorLogging" value="false" /> | |||||
| <!-- Error log file path (if EnableErrorLogging is true) --> | |||||
| <add key="ErrorLogPath" value="C:\YourApp\logs\errors.log" /> | |||||
| <!-- Enable cache-busting for URLs and assets (true/false) --> | |||||
| <add key="EnableCacheBusting" value="false" /> | |||||
| <!-- Cache-bust parameter name (default: "v") --> | |||||
| <add key="CacheBustParamName" value="v" /> | |||||
| <!-- | |||||
| SMTP settings used by CDOEmail for outbound order emails. | |||||
| PLACEHOLDER VALUES - replace with real SMTP server details before going live. | |||||
| --> | |||||
| <add key="SmtpServer" value="kentcommunications-com.mail.protection.outlook.com" /> | |||||
| <add key="SmtpPort" value="25" /> | |||||
| <add key="SmtpUsername" value="" /> | |||||
| <add key="SmtpPassword" value="" /> | |||||
| <add key="SmtpUseSSL" value="false" /> | |||||
| <add key="SmtpFromAddress" value="no-reply@kentcommunications.com" /> | |||||
| <!-- Hours before an order continuation link (token) expires --> | |||||
| <add key="OrderTokenExpirationHours" value="24" /> | |||||
| <!-- | |||||
| Jurisdiction number validation (request-order form). The list is essentially static | |||||
| reference data, so it's cached in Application scope rather than fetched on every | |||||
| submission - see JurisdictionCacheMinutes. | |||||
| --> | |||||
| <add key="JurisdictionApiUrl" value="http://192.168.1.40:8081/api/jurisdictions" /> | |||||
| <add key="JurisdictionCacheMinutes" value="60" /> | |||||
| <!-- | |||||
| Cloudflare Turnstile (CAPTCHA) keys. These are DEV keys - replace with production | |||||
| site/secret keys before going live. | |||||
| --> | |||||
| <add key="TurnstileSiteKey" value="0x4AAAAAAEUPEPHEnA9hPgbw" /> | |||||
| <add key="TurnstileSecretKey" value="0x4AAAAAAEUPEFnbhCe6ttr_Mq6yCozxRkg" /> | |||||
| </appSettings> | |||||
| <system.webServer> | |||||
| <!-- Default document for the site root --> | |||||
| <defaultDocument> | |||||
| <files> | |||||
| <clear /> | |||||
| <add value="Default.asp" /> | |||||
| </files> | |||||
| </defaultDocument> | |||||
| <!-- URL Rewrite: send everything through Default.asp except static assets --> | |||||
| <rewrite> | |||||
| <rules> | |||||
| <rule name="Rewrite to Default.asp" stopProcessing="true"> | |||||
| <match url="^(?!Default\.asp$|css/|js/|images/|aspunit/|favicon\.ico$).*$" /> | |||||
| <conditions> | |||||
| <add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" /> | |||||
| <add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" /> | |||||
| </conditions> | |||||
| <action type="Rewrite" url="/Default.asp" /> | |||||
| </rule> | |||||
| </rules> | |||||
| </rewrite> | |||||
| <!-- Strip the Server response header (reveals IIS version) - IIS 10+ only --> | |||||
| <!-- Restrict this admin site to the internal 192.168.1.0/24 network only. | |||||
| Requires the IIS "IP and Domain Restrictions" role feature to be installed. --> | |||||
| <security> | |||||
| <requestFiltering removeServerHeader="true" /> | |||||
| <ipSecurity allowUnlisted="false" denyAction="Forbidden"> | |||||
| <add ipAddress="192.168.1.0" subnetMask="255.255.255.0" allowed="true" /> | |||||
| </ipSecurity> | |||||
| </security> | |||||
| <!-- Strip framework-identifying headers that IIS/ASP.NET modules may add --> | |||||
| <httpProtocol> | |||||
| <customHeaders> | |||||
| <remove name="X-Powered-By" /> | |||||
| <remove name="X-AspNet-Version" /> | |||||
| <remove name="X-AspNetMvc-Version" /> | |||||
| <!-- URL Rewrite module adds this in IIS Express, leaking the physical file path --> | |||||
| <remove name="X-SourceFiles" /> | |||||
| </customHeaders> | |||||
| </httpProtocol> | |||||
| </system.webServer> | |||||
| <location path="css"> | |||||
| <system.webServer> | |||||
| <staticContent> | |||||
| <clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="01:00:00" /> | |||||
| </staticContent> | |||||
| </system.webServer> | |||||
| </location> | |||||
| </configuration> | |||||
| @@ -0,0 +1,119 @@ | |||||
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <configuration> | |||||
| <appSettings> | |||||
| <!-- Access connection string. Points at the persistent data folder, outside the | |||||
| deploy directory so a redeploy (which wipes and re-extracts the deploy dir) never | |||||
| touches it. --> | |||||
| <add key="ConnectionString" | |||||
| value="Provider=Microsoft.ACE.OLEDB.12.0;Data Source=C:\inetpub\data\webdata.accdb;Persist Security Info=False;" /> | |||||
| <!-- Environment flag (Development / Staging / Production) --> | |||||
| <add key="Environment" value="Production" /> | |||||
| <!-- Flash message display duration in milliseconds --> | |||||
| <add key="FlashMessageTimeout" value="8000" /> | |||||
| <!-- 404 error page redirect countdown in seconds --> | |||||
| <add key="Error404RedirectSeconds" value="5" /> | |||||
| <!-- Cache expiration year for static content --> | |||||
| <add key="CacheExpirationYear" value="2030" /> | |||||
| <!-- Maximum characters to display in table cells before truncating --> | |||||
| <add key="TableCellMaxLength" value="90" /> | |||||
| <!-- Character threshold for textarea vs input field in forms --> | |||||
| <add key="FormTextareaThreshold" value="100" /> | |||||
| <!-- Enable error logging to file (true/false) --> | |||||
| <add key="EnableErrorLogging" value="true" /> | |||||
| <!-- Error log file path - also outside the deploy directory so logs survive a redeploy. --> | |||||
| <add key="ErrorLogPath" value="C:\inetpub\data\logs\errors.log" /> | |||||
| <!-- Enable cache-busting for URLs and assets (true/false) --> | |||||
| <add key="EnableCacheBusting" value="false" /> | |||||
| <!-- Cache-bust parameter name (default: "v") --> | |||||
| <add key="CacheBustParamName" value="v" /> | |||||
| <!-- SMTP settings used by CDOEmail for outbound order emails. --> | |||||
| <add key="SmtpServer" value="kentcommunications-com.mail.protection.outlook.com" /> | |||||
| <add key="SmtpPort" value="25" /> | |||||
| <add key="SmtpUsername" value="" /> | |||||
| <add key="SmtpPassword" value="" /> | |||||
| <add key="SmtpUseSSL" value="false" /> | |||||
| <add key="SmtpFromAddress" value="no-reply@kentcommunications.com" /> | |||||
| <!-- Hours before an order continuation link (token) expires --> | |||||
| <add key="OrderTokenExpirationHours" value="24" /> | |||||
| <!-- | |||||
| Jurisdiction number validation (request-order form). The list is essentially static | |||||
| reference data, so it's cached in Application scope rather than fetched on every | |||||
| submission - see JurisdictionCacheMinutes. | |||||
| --> | |||||
| <add key="JurisdictionApiUrl" value="http://192.168.1.40:8081/api/jurisdictions" /> | |||||
| <add key="JurisdictionCacheMinutes" value="60" /> | |||||
| <!-- Cloudflare Turnstile (CAPTCHA) keys. --> | |||||
| <add key="TurnstileSiteKey" value="0x4AAAAAAEUPEPHEnA9hPgbw" /> | |||||
| <add key="TurnstileSecretKey" value="0x4AAAAAAEUPEFnbhCe6ttr_Mq6yCozxRkg" /> | |||||
| </appSettings> | |||||
| <system.webServer> | |||||
| <!-- Default document for the site root --> | |||||
| <defaultDocument> | |||||
| <files> | |||||
| <clear /> | |||||
| <add value="Default.asp" /> | |||||
| </files> | |||||
| </defaultDocument> | |||||
| <!-- URL Rewrite: send everything through Default.asp except static assets --> | |||||
| <rewrite> | |||||
| <rules> | |||||
| <rule name="Rewrite to Default.asp" stopProcessing="true"> | |||||
| <match url="^(?!Default\.asp$|css/|js/|images/|aspunit/|favicon\.ico$).*$" /> | |||||
| <conditions> | |||||
| <add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" /> | |||||
| <add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" /> | |||||
| </conditions> | |||||
| <action type="Rewrite" url="/Default.asp" /> | |||||
| </rule> | |||||
| </rules> | |||||
| </rewrite> | |||||
| <!-- Strip the Server response header (reveals IIS version) - IIS 10+ only --> | |||||
| <!-- Restrict this admin site to the internal 192.168.1.0/24 network only. | |||||
| Requires the IIS "IP and Domain Restrictions" role feature to be installed. --> | |||||
| <security> | |||||
| <requestFiltering removeServerHeader="true" /> | |||||
| <ipSecurity allowUnlisted="false" denyAction="Forbidden"> | |||||
| <add ipAddress="192.168.1.0" subnetMask="255.255.255.0" allowed="true" /> | |||||
| </ipSecurity> | |||||
| </security> | |||||
| <!-- Strip framework-identifying headers that IIS/ASP.NET modules may add --> | |||||
| <httpProtocol> | |||||
| <customHeaders> | |||||
| <remove name="X-Powered-By" /> | |||||
| <remove name="X-AspNet-Version" /> | |||||
| <remove name="X-AspNetMvc-Version" /> | |||||
| <!-- URL Rewrite module adds this in IIS Express, leaking the physical file path --> | |||||
| <remove name="X-SourceFiles" /> | |||||
| </customHeaders> | |||||
| </httpProtocol> | |||||
| </system.webServer> | |||||
| <location path="css"> | |||||
| <system.webServer> | |||||
| <staticContent> | |||||
| <clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="01:00:00" /> | |||||
| </staticContent> | |||||
| </system.webServer> | |||||
| </location> | |||||
| </configuration> | |||||
| @@ -1,4 +1,11 @@ | |||||
| @echo off | @echo off | ||||
| setlocal | setlocal | ||||
| set "ASPC_STARTER_ROOT=%~dp0" | set "ASPC_STARTER_ROOT=%~dp0" | ||||
| "C:\Program Files (x86)\IIS Express\iisexpress.exe" /config:"%~dp0applicationhost.config" | |||||
| set "IISEXPRESS=C:\Program Files (x86)\IIS Express\iisexpress.exe" | |||||
| set "CONFIG=%~dp0applicationhost.config" | |||||
| rem IIS Express only starts one site per process when /site is specified, so the | |||||
| rem public-facing site is launched in its own window and the admin site runs in | |||||
| rem this one. Close both windows to stop both sites. | |||||
| start "Public Site (port 8080)" "%IISEXPRESS%" /config:"%CONFIG%" /site:"Development Web Site" | |||||
| "%IISEXPRESS%" /config:"%CONFIG%" /site:"Admin Web Site" | |||||
| @@ -30,7 +30,7 @@ $ErrorActionPreference = 'Stop' | |||||
| # the migration runner (+ its generator siblings, harmless to ship alongside it). Only | # the migration runner (+ its generator siblings, harmless to ship alongside it). Only | ||||
| # db\migrations is kept under db - never db\webdata.accdb, even if something ever put a | # db\migrations is kept under db - never db\webdata.accdb, even if something ever put a | ||||
| # stray copy there. | # stray copy there. | ||||
| $KeepTopLevel = @('app', 'core', 'public', 'scripts', 'db') | |||||
| $KeepTopLevel = @('app', 'core', 'public', 'public-admin', 'scripts', 'db') | |||||
| $KeepUnderDb = @('migrations') | $KeepUnderDb = @('migrations') | ||||
| $repoRoot = Split-Path $PSScriptRoot -Parent | $repoRoot = Split-Path $PSScriptRoot -Parent | ||||
| @@ -82,6 +82,16 @@ try { | |||||
| Copy-Item $templatePath $liveConfig -Force | Copy-Item $templatePath $liveConfig -Force | ||||
| Remove-Item (Join-Path $OutDir 'public\web.config.production.template') -ErrorAction SilentlyContinue | Remove-Item (Join-Path $OutDir 'public\web.config.production.template') -ErrorAction SilentlyContinue | ||||
| $adminTemplatePath = Join-Path $repoRoot 'public-admin\web.config.production.template' | |||||
| if(Test-Path (Join-Path $OutDir 'public-admin')){ | |||||
| if(Test-Path $adminTemplatePath){ | |||||
| $adminLiveConfig = Join-Path $OutDir 'public-admin\web.config' | |||||
| Copy-Item $adminTemplatePath $adminLiveConfig -Force | |||||
| Remove-Item (Join-Path $OutDir 'public-admin\web.config.production.template') -ErrorAction SilentlyContinue | |||||
| Write-Host " public-admin\web.config swapped in from web.config.production.template" | |||||
| } | |||||
| } | |||||
| Write-Host "Release built at $OutDir" | Write-Host "Release built at $OutDir" | ||||
| Write-Host " public\web.config swapped in from web.config.production.template" | Write-Host " public\web.config swapped in from web.config.production.template" | ||||
| } finally { | } finally { | ||||
| @@ -17,6 +17,8 @@ param( | |||||
| [Parameter(Mandatory = $true)][string]$SiteName, | [Parameter(Mandatory = $true)][string]$SiteName, | ||||
| [Parameter(Mandatory = $true)][string]$AppPool, | [Parameter(Mandatory = $true)][string]$AppPool, | ||||
| [Parameter(Mandatory = $true)][string]$DbPath, | [Parameter(Mandatory = $true)][string]$DbPath, | ||||
| [string]$AdminSiteName = '', | |||||
| [string]$AdminAppPool = '', | |||||
| [switch]$RunMigrations = $true | [switch]$RunMigrations = $true | ||||
| ) | ) | ||||
| @@ -69,6 +71,23 @@ Set-ItemProperty ('IIS:\Sites\' + $SiteName) -Name applicationPool -Value $AppPo | |||||
| # dev machine, which needs the 32-bit one - so the app pool stays 64-bit here. | # dev machine, which needs the 32-bit one - so the app pool stays 64-bit here. | ||||
| Set-ItemProperty ('IIS:\AppPools\' + $AppPool) -Name enable32BitAppOnWin64 -Value $false | Set-ItemProperty ('IIS:\AppPools\' + $AppPool) -Name enable32BitAppOnWin64 -Value $false | ||||
| # --- Admin site --- | |||||
| $adminPublicDir = Join-Path $RemoteDir 'public-admin' | |||||
| if(![string]::IsNullOrWhiteSpace($AdminSiteName) -and (Test-Path $adminPublicDir)){ | |||||
| if(!(Get-Website -Name $AdminSiteName -ErrorAction SilentlyContinue)){ | |||||
| throw "IIS admin site '$AdminSiteName' does not exist yet. Create the site and app pool once manually before running this deploy." | |||||
| } | |||||
| Write-Host "Configuring admin site $AdminSiteName" | |||||
| Stop-Website -Name $AdminSiteName -ErrorAction SilentlyContinue | |||||
| if(![string]::IsNullOrWhiteSpace($AdminAppPool)){ | |||||
| Stop-WebAppPool -Name $AdminAppPool -ErrorAction SilentlyContinue | |||||
| Set-ItemProperty ('IIS:\Sites\' + $AdminSiteName) -Name applicationPool -Value $AdminAppPool | |||||
| Set-ItemProperty ('IIS:\AppPools\' + $AdminAppPool) -Name enable32BitAppOnWin64 -Value $false | |||||
| icacls $dataDir /grant ("IIS AppPool\" + $AdminAppPool + ":(OI)(CI)(M)") /T | Out-Null | |||||
| } | |||||
| Set-ItemProperty ('IIS:\Sites\' + $AdminSiteName) -Name physicalPath -Value $adminPublicDir | |||||
| } | |||||
| if($RunMigrations){ | if($RunMigrations){ | ||||
| Write-Host 'Running pending migrations' | Write-Host 'Running pending migrations' | ||||
| Push-Location $RemoteDir | Push-Location $RemoteDir | ||||
| @@ -88,4 +107,15 @@ if((Get-WebAppPoolState -Name $AppPool).Value -eq 'Started'){ | |||||
| } | } | ||||
| Start-Website $SiteName | Start-Website $SiteName | ||||
| if(![string]::IsNullOrWhiteSpace($AdminSiteName)){ | |||||
| if(![string]::IsNullOrWhiteSpace($AdminAppPool)){ | |||||
| if((Get-WebAppPoolState -Name $AdminAppPool).Value -eq 'Started'){ | |||||
| Restart-WebAppPool -Name $AdminAppPool | |||||
| } else { | |||||
| Start-WebAppPool -Name $AdminAppPool | |||||
| } | |||||
| } | |||||
| Start-Website $AdminSiteName | |||||
| } | |||||
| Write-Host 'Remote apply complete.' | Write-Host 'Remote apply complete.' | ||||
| @@ -26,8 +26,11 @@ param( | |||||
| [string]$RemoteDir = 'C:\inetpub\wwwroot\Purple_Envelop_Order_Site', | [string]$RemoteDir = 'C:\inetpub\wwwroot\Purple_Envelop_Order_Site', | ||||
| [string]$SiteName = 'PurpleEnvelopes', | [string]$SiteName = 'PurpleEnvelopes', | ||||
| [string]$AppPool = 'PurpleEnvelopes', | [string]$AppPool = 'PurpleEnvelopes', | ||||
| [string]$AdminSiteName = '', | |||||
| [string]$AdminAppPool = '', | |||||
| [string]$DbPath = 'C:\inetpub\data\webdata.accdb', | [string]$DbPath = 'C:\inetpub\data\webdata.accdb', | ||||
| [string]$BaseUrl = 'https://pe.kentcommunications.com/', | [string]$BaseUrl = 'https://pe.kentcommunications.com/', | ||||
| [string]$AdminBaseUrl = '', | |||||
| [switch]$RunMigrations = $true, | [switch]$RunMigrations = $true, | ||||
| [string]$SshExe = 'ssh', | [string]$SshExe = 'ssh', | ||||
| [string]$ScpExe = 'scp' | [string]$ScpExe = 'scp' | ||||
| @@ -101,6 +104,12 @@ $remoteCommandParts = @( | |||||
| '-AppPool', ('"' + $AppPool + '"'), | '-AppPool', ('"' + $AppPool + '"'), | ||||
| '-DbPath', ('"' + $DbPath + '"') | '-DbPath', ('"' + $DbPath + '"') | ||||
| ) | ) | ||||
| if(![string]::IsNullOrWhiteSpace($AdminSiteName)){ | |||||
| $remoteCommandParts += @('-AdminSiteName', ('"' + $AdminSiteName + '"')) | |||||
| } | |||||
| if(![string]::IsNullOrWhiteSpace($AdminAppPool)){ | |||||
| $remoteCommandParts += @('-AdminAppPool', ('"' + $AdminAppPool + '"')) | |||||
| } | |||||
| if($RunMigrations){ $remoteCommandParts += '-RunMigrations' } | if($RunMigrations){ $remoteCommandParts += '-RunMigrations' } | ||||
| Write-Host "Applying release on $RemoteTarget" | Write-Host "Applying release on $RemoteTarget" | ||||
| @@ -147,4 +156,36 @@ if($failed){ | |||||
| throw 'Smoke test failed - see above' | throw 'Smoke test failed - see above' | ||||
| } | } | ||||
| # --- 6. Admin site smoke test --- | |||||
| if(![string]::IsNullOrWhiteSpace($AdminBaseUrl)){ | |||||
| Write-Host 'Smoke testing admin site...' | |||||
| $adminChecks = @( | |||||
| @{ Path = '/'; Expect = 200 } | |||||
| ) | |||||
| foreach($check in $adminChecks){ | |||||
| $url = $AdminBaseUrl.TrimEnd('/') + $check.Path | |||||
| try { | |||||
| $response = Invoke-WebRequest -UseBasicParsing -Uri $url -TimeoutSec 30 | |||||
| $status = [int]$response.StatusCode | |||||
| } catch { | |||||
| if($_.Exception.Response){ | |||||
| $status = [int]$_.Exception.Response.StatusCode | |||||
| } else { | |||||
| Write-Host ("FAIL (admin) " + $check.Path + ' -> request failed: ' + $_.Exception.Message) | |||||
| $failed = $true | |||||
| continue | |||||
| } | |||||
| } | |||||
| if($status -eq $check.Expect){ | |||||
| Write-Host ("OK (admin) " + $check.Path + ' -> ' + $status) | |||||
| } else { | |||||
| Write-Host ("FAIL (admin) " + $check.Path + ' -> ' + $status + ' (expected ' + $check.Expect + ')') | |||||
| $failed = $true | |||||
| } | |||||
| } | |||||
| if($failed){ | |||||
| throw 'Admin smoke test failed - see above' | |||||
| } | |||||
| } | |||||
| Write-Host 'Deploy complete.' | Write-Host 'Deploy complete.' | ||||
Powered by TurnKey Linux.