Thursday, July 31, 2008, 4:47:00 AM
A question came up today on how to handle file uploads in the context of a Web Control Framework page in Web Connection. Http File uploads are a common HTTP construct and Web Connection has long supported this process in its classic mode of operation.
Classic Http File Uploads
In classic Web Connection applications file uploads are pretty much handled through the raw mechanics of the Http protocol which involves basically two steps:
Uploads are typically handled through a separate form (or a single form) that posts back to the server via a special enctype of multipart/form data, which is required in order to force the content to be uploaded to the server.
Here's what the HTML of this process looks like:
<form enctype="multipart/form-data" method="post" action="/wconnect/FileUpload.wwd">
<p>
<input type="file" size="20" name="File"/><br/>
<br/>
File Description:<br/>
<textarea cols="43" name="txtFileNotes" rows="4"/><br/>
<input type="submit" name="btnSubmit" value="Upload File"/>
</p>
</form>
To pick up the file on the server, you can simply use the standard wwRequest object which automatically detects the content type of the posted data and returns Form data appropriately for this content. To retrieve the file and its name a little more work is required by calling the special Request.GetMultipartFile() to retrieve the file. All other form vars can be retrieved using the standard Request.Form() method.
Here's is the Fox code to pick up the file in a classic Process method:
FUNCTION FileUpload
************************************************************************
* wwDemo :: FileUpload
*********************************
*** Function: Demonstrates how to upload files from HTML forms
*** This function requires that multipart forms are
*** used on the client (multipart/data)
***************************************************************************
lcFileName = ""
*** This works too but doesn't retrieve the file name
* lcFileBuffer = Request.Form("File")
*** Files must use GetMultipartFile to retreive the file name as well
lcFileBuffer = Request.GetMultiPartFile("File",@lcFileName)
lcNotes = Request.Form("txtFileNotes")
lcFileName = SYS(2023)+"\"+lcFileName
IF LEN(lcFileBuffer) = 0
THIS.StandardPage("Upload Error","An error occurred during the upload or the file was too big.")
RETURN
ENDIF
IF LEN(lcFileBuffer) > 500000
THIS.StandardPage("File upload refused",;
"Files over 500k are not allowed for this sample...<BR>"+;
"File: " + lcFilename)
RETURN
ENDIF
*** Now dump the file to disk - for whatever purpose
File2Var(lcFileName,lcFileBuffer)
THIS.StandardPage("Thank you for your file upload",;
"<b>File Uploaded:</b> " + lcFileName + ;
" (" + TRANSFORM(FileSize(lcFileName),"9,999,999") + " bytes)<p>"+ ;
"<b>Notes:</b><br>"+ CRLF + lcNotes )
ENDFUNC
* wwDemo :: FileUpload
Nothing fancy here. Everything works as with standard forms except for the call to GetMultipartFile() which retrieves the file and filename. Note that the filename is passed by reference so that the value can be updated by the function.
Uploading Files in the Web Control Framework
The process for Web Control Framework pages is actually quite similar, but it's maybe even easier because the framework automatically handles setting the content type of the form for you. The Web Control framework contains a wwWebFileUpload control which you can place on a form and it automatically manipulates the form to switch the form into multipart mode.
The following example is an image uploader application that allows uploading and immediately displaying the uploaded image in the Web page:
<%@ Page Language="C#"
ID="UploadImage_Page"
GeneratedSourceFile="webcontrols\UploadImage_page.prg"
%>
<%@ Register Assembly="WebConnectionWebControls"
Namespace="Westwind.WebConnection.WebControls"
TagPrefix="ww" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>Image Upload Example</title>
<link href="westwind.css" rel="stylesheet" type="text/css" />
</head>
<body style="margin-top:0px;margin-left:0px">
<form id="form1" runat="server">
<h1>Image Upload Example</h1>
<ww:wwWebErrorDisplay runat="server" id="ErrorDisplay" />
<div class="notebox">
This example demonstrates how to upload a file inside from within a Web Form
and retrieve it on the server. Note that the form must be submitted
with enctype="multipart/form-data" in order for uploads to work!
</div>
<div class="containercontent">
Please pick an image to upload:<br />
<ww:wwWebFileUpload ID="FileUpload" runat="server" style="width:400px"/>
<br />
<br />
Leave a note about the file:<br />
<ww:wwWebTextBox ID="txtFileName" runat="server"
TextMode="MultiLine" Width="400px"
/>
<br />
<ww:wwWebButton ID="wwWebButton1" runat="server"
Click="btnSubmit_Click"
Text="Upload" width="80" />
<hr />
<ww:wwWebImage runat="server" id="imgPreview"
style="float: left; margin-right: 10px;" />
<small>
<ww:wwWebLabel runat="server" ID="lblCaption" />
</small>
</div>
</form>
</body>
</html>
Note that the form is NOT set up with enctype="multipart/form-data" although if you do add that manually that will work too. It's just not required here. Note that the upload control MUST live inside of the <form runat="server"> tag block.
Other than that it's just a normal input form. To capture the file now you can use code like the following in the button submit:
************************************************************************
* btnSubmit_Click
****************************************
*** Function:
*** Assume:
*** Pass:
*** Return:
************************************************************************
FUNCTION btnSubmit_Click()
LOCAL lcFileName, lcContent, lcNotes
*** Pick up content and filename
lcFilename = ""
lcContent = Request.GetMultiPartFile("FileUpload",@lcFileName)
lcNotes = this.txtFileName.Text
lcExt = LOWER(JUSTEXT(lcFileName))
IF EMPTY(lcExt) OR NOT lcExt $ "jpg|jpeg|png|gif|tif|bmp"
this.ErrorDisplay.ShowError("Invalid Image type uploaded")
RETURN
ENDIF
IF LEN(lcContent) > 500000
this.ErrorDisplay.ShowError("Image uploaded is too large.")
RETURN
ENDIF
*** Generate output filename and full output path
*** Note reading from demo's cHtmlPagePath from .ini file
lcSaveFileName = "_timg_" + lcFileName
lcSaveFullPath = Server.oConfig.owwDemo.cHtmlPagePath +;
"temp\" + lcSaveFileName
*** Dump the file content string to a file
STRTOFILE( lcContent, lcSaveFullPath )
*** Assign the Web based file path to the image to display
this.imgPreview.ImageUrl = "~/temp/" + lcSaveFileName
this.lblCaption.text = lcNotes + ;
"<hr><i>" + lcFileName + "<br />" + ;
"Size: " + TRANS(LEN(lcContent)) + " bytes</i>"
*** Clean up files that are older than 120 seconds
DeleteFiles("..\temp\_timg*.*",120)
ENDFUNC
* btnsubmit_Click()
This code is more complete than the last example, but it basically picks up the uploaded file by its form variable name in this case FileUpload. The function returns the file content as a binary string. This output can then simply be written to disk after ensuring that it's of the right type.
In this case the file is written to disk in a Web directory and then displayed in an image control, whose ImageUrl is set dynamically with the new name of the file. Obviously you may want to do something different with this file like store it in a database or store it in some other dir and never display it.
If you do chose the file approach to dump to disk and display the image remember that you'll need to clean up the generated files as the filenames are unique as to avoid name overlap. The DeleteFiles() function from wwUtils makes this task very easy – it simply deletes files that are older than a certain number of seconds which serves as a brute force cleanup routine for 'temporary' files.
Looking over this code it also occurred to me that it'd be useful to have a method on the upload control that can retrieve the file and filename. This would avoid having to know the POST variable name (which may be mangled due to naming containers). So I added a new method to wwWebFileUpload called .GetUploadedFile(@lcFileName) that basically makes a call through the Request.GetMultipartFile(). While no easier than the Request method, the method on the control should make it easier to discover how to actually pick up the file once it's been uploaded. With it the code will change to:
*** Pick up content and filename
lcFilename = ""
lcContent = this.FileUpload.GetUploadedFile(@lcFileName)
lcNotes = this.txtFileName.Text
which is a little cleaner and certainly easier to discover.
There you have it. Nothing difficult about this process, but it is a little different for the two mechanisms.
Saturday, July 26, 2008, 10:22:00 AM
Every Web Connection application you build will need to be deployed at some point. Among the required tasks are copying files and then configuring the application to create virtuals, scriptmaps, set permissions and register your servers with COM. There are a lot of different approaches that you can take to configure your application, but Web Connection includes the tools to automate this process completely.
Copying Files to the Server
Copying the files to the server is a task that typically can't and shouldn't be automated unless you strictly go through a deployment server where you can completely install an application, test it before getting the files to the server. This process usually varies considerably for the application and can involve copying Web files, Application files and data files all of which may go into different paths. I'm not going to cover this aspect because this is usually a one time process that's handled via manual FTP.
The best 'generic' advice I can give for this aspect is to first create a local directory that has the full directory structure that you will use intact. It's also helpful if your development setup matches the deployment setup, but that's not strictly necessary. Either way I'd recommend the intermediate step of creating a deployment installation and testing that installation before sending files to the server. The reason for this is simple – dev installs tend to include all source files so even if something in your project file is missing and not getting compiled into a final EXE it may still work because the source files are still available. A standalone EXE file may not have this luxury and fail unceremoniously.
One thing that helps is to create your deployment structure something like this:
AppRoot
ApplicationFiles (EXE,Ini files, Web Connection Tools/html/Scripts/Template folders)
Web (the Web Virtual directory where WCSX etc. scripts and templates live)
Data (Optional data directory if you're using VFP data)
Note that in this scenario the web folder is NOT underneath the actual Web root (ie. c:\inetpub\wwwroot), but this works perfectly fine as long as the path is configured properly and permissions are set appropriately specifically for the IIS anonymous user to have access.
The data path is optional – you can also stick data below your application folder, or in some situations your data may be in different paths on the machine or on the network depending on your security policy.
The advantage of the above layout though is that you have a single directory structure that can be sent to the server for your application and you can test this installation independently of your development environment.
Programmatic Configuration of the Application
Once the files have been copied to the server, the next step is to configure your Web site so that it actually becomes a Web application. This means creating the virtual directory, setting up scriptmaps, setting a few permissions and if running in COM mode registering your server for COM and DCOM operation.
The documentation takes you through these steps manually as well as using the Web Connection Configuration Wizard which performs most of these steps for you.
However, in many cases you may want to complete automate the installation process and it's entirely possible to use a small bit of FoxPro code to handle the process of Web Server configuration automatically. The following script is a typical FoxPro install script that configures a Web application completely (note this script is IIS specific and would need changes to work with Apache):
DO WCONNECT
SET CLASSLIB TO WEBSERVER ADDITIVE
*** Hardcoded values - these would normally come
*** from some sort of UI (popup a form?)
*** Deployment Path
lcWebPath = LOWER(FULLPATH("..\web\"))
lcServerType = "IIS6"
*** Name of the virtual Web directory for IIS
lcVirtual = "Netting"
lcServerExe = "netting.exe"
lcTempPath = ADDBS(SYS(2023)) + "wc"
**** COM Configuration
lcProgId = "netting.nettingServer"
lcDCOMUserName = "Interactive User"
lcDCOMPassword = "" && Fill in if you're using a real account
*** End stock parameters
*** Prompt for a few of the main parameters - ideally this should be a form to
*** prompt for all the inputs
lcServerName = "LOCALHOST"
lcServerName = InputForm(lcServerName,"The local IP Address or domain of the server to configure","IIS Configuration - Server Address",,,"")
IF EMPTY(lcServerName)
return
ENDIF
lcServerType = InputForm(lcServerType,"IIS Version (IIS5, IIS6, IIS7)","IIS Configuration - IIS Server Type",,,"")
IF EMPTY(lcServerType)
return
ENDIF
lcWebPath = InputForm(lcWebPath,"Location of the Web Directory","IIS Configuration - Web Directory",,,"")
IF EMPTY(lcWebPath)
return
ENDIF
*** Create Virtual Directory
oIIS = CREATEOBJECT("wwWebServer")
oIIS.cServerType = lcServerType
oIIS.cIISVirtualPath = "IIS://" + lcServerName + "/W3SVC/1/ROOT" && IIS Schema path
oIIS.cApplicationPool = "West Wind Web Connection"
IF ISNULL(GETOBJECT(oIIS.cIIsVirtualPath))
showStatus( "Unable to connect to IIS Administration COM object." )
return
ENDIF
showStatus("creating virtual directory")
IF !oIIS.CreateVirtual(lcVirtual,lcWebPath)
showStatus("Unable to create virtual directory")
RETURN
ENDIF
*** Turn off Anonymous Permissions
loVirtual = GETOBJECT(oIIs.cIISVirtualPath + "/" + lcVirtual)
loVirtual.AuthAnonymous = .F.
loVirtual.SetInfo()
*** Assume wc.dll lives in BIN directory
lcScriptDLL = lcWebPath + 'bin\wc.dll'
showStatus("creating script maps")
lcIISVirtual = oIIS.cIISVirtualPath + "/" + lcVirtual
*** Create script maps to the DLL
oIIS.CreateScriptMap('wc',lcScriptDll, lcIISVirtual)
oIIS.CreateScriptMap('wcsx',lcScriptDll, lcIISVirtual)
oIIS.CreateScriptMap('wwsoap',lcScriptDll, lcIISVirtual)
oIIS.CreateScriptMap('aspx',lcScriptDll, lcIISVirtual)
showStatus("registering ISAPI dll in IIS")
*** In IIS6 and later we have to register any DLLs with IIS
IF LOWER(lcServerType) = "iis" AND LOWER(lcServerType) > "iis5"
*** Add the DLL as a registered
loIIS = CREATEOBJECT("wwIISAdmin")
loIIS.cPath = "IIS://" + lcServerName + "/W3SVC"
loIIS.AddRegisteredExtension(lcScriptDll,"West Wind Web Connection")
loIIS = .f.
ENDIF
*** Create Temp Directory
IF !ISDIR(lcTempPath)
MD (lcTempPath)
ENDIF
*** Set permissions for IUSR_ Virtual
loVirtual = GETOBJECT(oIIS.cIISVirtualPath)
lcAnonymousUserName = loVirtual.AnonymousUserName
loVirtual = .f.
*!* *** Set access on the Web directory
IF !EMPTY(lcWebPath)
*** Read writes - wwUtils.SetAcl()
llResult = SetAcl(lcWebPath,lcAnonymousUserName,"R",.t.)
ENDIF
*** Not required for Web Connection 5.x – wc.dll runs as SYSTEM
*!* IF lcTempPath
*!* *** Full rights in the temp directory
*!* llResult = SetAcl(lcTempPath,lcAnonymousUserName,"F",.t.)
*!* ENDIF
showStatus("Register Com object")
*** Register COM object and configure DCOM programmatically
IF !EMPTY(lcProgId)
wait window "Registering COM server" nowait
lcCommand = "run " + lcServerExe + " /regserver"
&lcCommand
IF ISWinNT()
IF !EMPTY(lcDCOMPassword) AND !FILE("DCOMPermissions.exe")
MESSAGEBOX("DCOMPermissions.exe file is missing" + CHR(13) +;
"Can't configure DCOM settings. Please configure manually.",48,"DCOM Settings")
ELSE
DO DCOMCNFGServer WITH lcProgId, lcDCOMUserName, lcDCOMPassword
IF FILE("DCOMPermissions.exe")
loAPI = CREATE("wwAPI")
lcMachineName = loAPI.GetComputerName()
DCOMLaunchPermissions(lcProgId,"Administrators")
DCOMLaunchPermissions(lcProgId,"SYSTEM")
DCOMLaunchPermissions(lcProgId,"INTERACTIVE")
ENDIF
ENDIF
ENDIF
ENDIF
FUNCTION showStatus(lcMessage)
WAIT WINDOW NOWAIT (lcMessage)
ENDFUNC
This script is not generic obviously, but it can be customized quite easily in a few places for most configurations. You might want to add or remove some some script maps (like ASPX which was used for this particular project) or you might need additional settings applied say to the virtual directory. Note that the code even does some custom ADSI configuration for setting security – in this case it's removing Anonymous user access from the app so that every user is forced to log in with Windows credentials.
The beauty of this sort of script is that once you have your configuration set up it's very easy to run it for a first time config or even to reconfigure if something should get accidentally removed.
If you want to deploy this you can either choose to build an EXE out of this small program by adding to a project and compiling into an EXE, or maybe even easier by adding it to your main server EXE itself with code like the following in the startup program (like wcDemoMain.prg):
************************************************************************
*FUNCTION NettingMain
******************************
*** Created: 06.05.2008
*** Function: Web Connection Mainline program. Responsible for setting
*** up the Web Connection Server and get it ready to
*** receive requests in file messaging mode.
************************************************************************
LPARAMETERS lcAction
IF !EMPTY(lcAction)
IF UPPER(lcAction) == "CONFIG"
DO configurationScript
RETURN
ENDIF
ENDIF
*** This is the file based start up code that gets
*** the server form up and running
#INCLUDE WCONNECT.H
…
With this code the configuration script is now part of the server and you can simply do:
YouServer.exe Config
To run the configuration code. Simple and self contained.
There's no reason to sweat installation of Web Connection applications when you have a good install script and as you can see it's pretty easy to set up – yes there's a fair bit of code to it, but most of it is truly boilerplate and you can just read through it and adjust as needed. If you are using a deployment folder as mentioned above you can actively test your install script to ensure it works on your deployment server (or local machine) first before moving things to the live site.
It's a win win solution to automate this process and well worth the effort. You will thank yourself next time you'll need to move your application to a new server due to hardware upgrades or – gulp – a hardware failure.
Tuesday, July 15, 2008, 12:44:00 AM
West Wind Web Connection 5.37 introduced a new JavaScript 'hook' function that allows you trap binding error clicks in a page. When using ControlSource binding any binding errors can be displayed in the page and the messages include links to the controls in question. For simple pages and controls this looks something like this:
When you click on any of the links on the error list the field is highlighted and the cursor moved there so you can immediately start editing the entry and fix whatever the problem is.
However, when working with Tab pages things are a bit different. The issue is that you may end up having errors on multiple pages of a wwWebTabControl and some of these controls maybe on pages that are not currently visible. For example, check out this page from the sample if you submitted the page on any page other than the PostBacks page:
If you were to click on the Name cannot be empty link at this point you would find that nothing happens because the field in question is actually on a hidden page (the Postbacks page). It turns out the field is in fact activated - highlighted and focus even goes there, but because the tab is not active that fact is lost to you as the user of the page - to you it appears that nothing happens.
So how can this be fixed?
West Wind Web Connection 5.37 includes a new client static side event called OnBindingErrorLink that is fired when you click on the error link. This allows you some control over what happens in the event of a link click so you can optionally take over the link management and either inject your own processing and let the default behavior still take place or override the behavior completely.
The following implementation activates a tab page if the control that has the error is contained on one:
function OnBindingErrorLink(errorCtl)
{ var control = errorCtl;
// *** Find the parent 'tab' page id
while(control.parentNode)
{ control = control.parentNode;
if (!control)
break;
if (control.id.substr(0,4) == "page")
{ ActivateTab("Tabs",control.id); return;
}
}
}
This code is not totally generic - it makes the assumption that the <div> tags that are the tab content have an ID that starts with "page" (ie. pageInformation,pageSelection etc.). Here's what the page markup looks like:
<ww:wwWebTabControl runat="server" id="Tabs" SelectedTab="pageSelection">
<TabPages>
<ww:wwWebTabPage runat="server" id