Daily Programming Log Comments on technical items of interest collected throughout the day (Mark A. Kruger)

  Thursday, November 06, 2003
 
 
CF Webtools
MX Consulting
Nebraska CFUG
mkruger@cfwebtools.com

Comments on staring out in the consulting business and limiting liabily

It's great being on your own! You think your time is finally yours. You have that first client and it looks like enough income for a month or two. Things are looking up. But.... suddenly the questions start popping up. How do I get paid in a timely manner? What do I do about cash flow? How do I avoid Project creep? What if the customer isn't satisfied - how do I remedy that? How much do I charge? How do I limit my liability?

Yep - it's a brave new world kemosabe'. And you are just starting. Imagine that first really big project (big for one guy working on his own). The estimate is for 15,000 dollars and its slated to take 6 weeks. That's awesome. You already have the money spent. There's a catch. What do you do for 6 weeks while your are waiting for the income? Oh yeah - most customers don't pay immediately when they get your invoice either. Most have a 30 day billing cycle. You can sweet talk them into 15 if you are a good negotiator. Still, that means essentially 2 months (at least) with no income. Hopefully you were savy enought to ask for an advance. This is just one of a myriad of pitfalls that awaits you.

Here's my short list of tips on good consulting practices.

1. Incorporate as a business entity. LLC is usually the way to go. As an LLC, liability falls back on the company rather than the person. That means, your company can go out of business due to a law suite - but your personal assets are not at risk (much... ha). Note: You must have a "right to do business as a corporation" in the sate of your client. For some states (like CA) that means paying a franchise fee. Weigh the risks before you do it. It may not be worth trying if your liability is small. But remember, if you don't pay the fee, whatever liability there is falls back on you - not your business entity. That's something to consider. Even if it's just you, incorporation allows you to refer to yourself as "us"... "my policy" becomes "company policy". Your bill comes from your company - not from you. This has a way of reassuring folks and even making them feel you have more resources.

2. Application development ground rules - Have a document that you send to potential clients that explains how you work, what they can expect, what the ground rules are for change requests (important!) what the billing rate and terms are and your policy on turn-around (how quickly you deliver). This is NOT a sales pitch document. It should be as brutally honest as you can afford to be. This is the document that you refer back to whenever project creep or billing issues begin to occur.

3. document document document..... Every piece of work, every promise, every phone call, every hour you spend, every thing must be in a document somewhere. Emails are great - formal documents (signed and faxed) are much better. For any project that we do we require a signed "specification" document. This document is worked out in a series of meetings with stakeholders in the project. Stakeholders HAVE to be more than just the IT person or main client. That's because it is rare (as in never) that a client grasps all the nuances of a projected work flow. You have to speak with Admin folks, sales folks, customer service, etc - anyone that is affected. If you do not do this your document will not reflect the realities of actual usage. The final document should reflect every use case, every workflow and every functional piece of the new application. If you have done your homework AND if you have made it clear (see item 2) that any item that is NOT in the spec is NOT covered by the project estimate, then you have an excellent base document for billing and review.

3.a. - The document should also include a billing estimate - make a line item billing estimate that shows each functional or design item of the project. We use a "minimum/maxium" spreadsheet - i.e. 'User manager tool ... minimum 22 hours, maximum 28 hours'. The distance between the 2 numbers is determined by how much detail - how thouroughly thought out - the piece is. If the client knows "exactly" what they want, the min/max can be pretty much the same thing - unless there are some unknowns involved.

3.b - Debug and Revision. ALWAYS include a line item called debug and revision as a part of the billing estimate. It should be between 10 and 20 percent of the total hours. Use your best judgement and best read of the client to determine what this number should be. Having this amount in "reserve" is what will enable you to be charitible and say "sure.. .we can change that" when the project is nearing release (ha).

4. Advance payment - Although this is essential in a cash-flow dependent small business, this is NOT just for you. Getting an advance solidifies your relationship with the client and puts you in bed together. Very few clients back out after writing that check. An advance can be from 1/3 to 1/2 of the total - but it should be a significant investment.

5. Get business liability insurance - this is for your corporate entity - see your local insurance agent.

6. Communicate regularly - preferably via email. All liability lawsuites rise and fall based on "he said she said". They generally list promises broken. If you can verify that you have fulfilled your end of the bargain (see item 3 a-b) at the cost promised and in a reasonable time. They will have little reason to argue. Note: Make sure you DO it. Make it good code too - they can always hire a guru to review the code and see for themselves. I myself have been retained twice to write such a review.

Remember, the failure rate - yeah.... most ideas on the web fail. Unless you are the proud owner of a porn or gambiling site, or you have an existing business and you are using the web as a new productivity tool or sales conduit to existing customers, chances are you are struggling to figure out how to make money. The real money is (and always has been) in the development of such aps. Web based companies rise and fall... but web application development companies keep chugging right along. I've done my share of aps that went nowhere. But the main thing is - try to fulfill the "promise" of the clients vision.... but rarely buy into it. Clients often want more than programming. They are looking for you to say "Yeah .. that's a great idea! You are going to make a lot of money"..... While that may or may not be true - you are setting yourself up if you choose to take that tack with a client. They will see you as someone who shares their vision - not as a service provider. Then they will expect you to go an extra mile to make the vision happen.

Having said all that - there are still some great ideas out there. Sometimes it's worth taking the risk, even if you fail. At least you will increase your business knowledge. Measuring how great a risk to take is the trick.


:: posted by Mark at 7:17 AM
  Friday, August 29, 2003
 

:: posted by Mark at 2:06 PM
   
CF Webtools
MX Consulting
Nebraska CFUG
mkruger@cfwebtools.com

First Flashed based application

Here's what we learned

For our first Flash project we chose the rather ambitious task of building an Intranet ERP application for a manufacturer/wholesaler. The application was designed to automate the process of conception, planning, design and launch of a new product. Ordinarily we would most certainly do this sort of application in Coldfusion with HTML pages. But, being anxious for an opportunity to dive in and use the new approach that flash remoting represented we decided to do most of the UI in flash. Here's what we learned through that process.

1. Multiple Movie Madness

Always looking to break our code into small manageable and reusable chunks, we made the decision to build a main "container" movie and several "sub-movies" that represented different views (summary, financial's, approvals etc.). This seemed analogous to creating several classes to be used within a main() class. Having this as our plan and having outlined the functions and properties of each sub movie, we started to code.

To perform better we decided to load the sub-movies as needed. This did indeed make our application very responsive. However, one tricky problem that we quickly discovered was that our action script for loading the movies did not appear to work. Instead of loading a movie once and simply re displaying it when called for, it re-loaded the movie again and again. Here was our initial try, taken from the script that controls the "menu bar":

// function controlling the "overview" menu item
	function evtSummary(){
	
		if(_root.containter1_mc == undefined)
		{	
			// create the clip
			_root.createEmptyMovieClip("container1_mc", 1);
			
			// reposition the clip
			_root.container1_mc._x=5;
			_root.container1_mc._y=50;
			
			//load into the container
			_root.container1_mc.loadMovie(moviePath + "Summary.swf");
		}	
	
		_global.view == 'summary';
		container1_mc._visible = true;
	}
	
The idea was to create the clip when it was called for, load in the appropriate movie and simply make all the _visible properties "false" and set the current view _visible to "true" after creating the clip. Of course once it was created all that was needed was to make it visible when called for, but for some reason the "createEmptyMovieClip( ) function ran every time - regardless of whether the container movie ran or not.

We tried to fix this by loading to different levels, then to different depths, but no matter what, the "create" script would run if our logic was "if(_root.container1_mc == undefined)" (we also tried "null"). Finally we changed our approach and used what I consider to be a workaround. At the top of the menu script we set create an object with a group of variables set to "false". Like so:

	mcs = new object();
	mcs.IsLoaded1	= new boolean(false);
	mcs.IsLoaded2	= new boolean(false);
	mcs.IsLoaded3	= new boolean(false);
	mcs.IsLoaded4	= new boolean(false);
	mcs.IsLoaded5	= new boolean(false);	
Then we check this variable in our logic instead of checking if the containter_mc clip was undefined. Our new block of code looked like this:
function evtSummary(){	
		
		if(mcs.IsLoaded1 == false) // check our variable
		{	// create it		
			_root.createEmptyMovieClip("container1_mc", 1);
			// position it
			_root.container1_mc._x=5;
			_root.container1_mc._y=50;
			//load it
			_root.container1_mc.loadMovie(moviePath + "Summary.swf");
			
			// now we set our variable to true	
			mcs.IsLoaded1 = true;
		}	
		_global.view == 'summary';
		
		container1_mc._visible = true;
	}	

This definitely fixed our loading problem, but now it did not work as expected. The _visible property did not seem to eliminate the visiblity of the other clips and show the "top" clip. Instead, the container clip seemed to take the top most position.

Since we were loading to depths we decided to try a "swap depths" approach. My thought was that depth zero would always be at the top, but this is not the case. The top depth is always the highest number of depths. So I couldn't just swap depths with whatever movie was on zero. Our decision was to simply create a variable at n that is greater than the greatest depth. Then, we swap with this depth and increment it by one. The result is the swap command always loads to a depth higher than the highest depth. This enables us to add other clips, popups etc. without retooling for depth. On a side note, I'm sure there is a better way to do this. The concept of depth is not well documented in the reference material of the flash IDE. But the documents there are already woefully inadequate. More about our choices in the next blog.


:: posted by Mark at 2:01 PM
  Thursday, January 02, 2003
 
CF Webtools
MX Consulting
Nebraska CFUG
mkruger@cfwebtools.com

SQL injection Attacks

When it comes to web development, most developers are already familiar with a dangerous and malicious hacking technique known as the "SQL injection attack". Among CF users this is a particularly eggregious problem - mainly due to the fact that CF queries are so easy to create in plain text using output variables. What is less commonly recognized is that CF programmers who use the "dynamic string" approach to running queries are particularly vulnerable to this sort of attack if they fail to scrub the form or URL data prior to creating the string.

What is it?

While it is generally bad form to discuss hacking techniques in detail, this one is so widely known (and so easy to stumble across) that it is of little value to try to obfuscate it's use. The technique is pretty simple. When a developer chooses to pass FORM or URL type variables directly into a query without attempting to truly validate that item, a malicious user may attempt to pass other types of DB code into the query as a part of the URL or form elements. Here's what has to be in place for it to work.

  • Form or URL elements must be unvalidated on the server. For example, if the DB is expecting a date, but the server doesn't look at the value of the form field ans say "..yes it's a date!", the server is open to attack. Note: It is not enough to only do client side validation. It is a pretty simple matter to disable Javascript, and it is unlikely that an attack will come from your own form anyway.
  • The database context must have permissions for the malicious code in question. For example, if your DB user does not have permissions to run "DROP" or "CREATE" statements on the DB, then malicious code using those keywords cannot succeed.
  • The code must be unscrubbed. It must be passed into the query pretty much As is without escaping the single quotes or keywords.
  • It must be passed to the query as output text. In other words, the query is a string that is passed to the SQL execute function of the ODBC driver. The contents of the string are really DB server agnostic. The DB server does not know in advance the type of the variables in question.
If all of these requirements are met, then the malicious attack has the potential to succeed. Let's look at a couple of common cases where these attacks occur.

The PK attack

It's pretty common to use an int as a primary key in a table. The "identity" field in MSSQL, the "sequence" field in Oracle and the "counter" or "autonumber" field in Access make it easy to set up a table and assign a PK that is guaranteed to be unique. This isn't bad practice, but it can lead to some unfortunate short cuts. One of them is to simplify the passing of parameters on the URL. For example, a search form for products on an e-commerce site might have links to the detail that read: product_detail.cfm?Product_id=44. That's ok as long as the product is open to the public for everyone to view, and as long as the URL variable is validated to be an int on the detail page. Why those 2 conditions? Consider the first case:

If the products are not public and the user is only supposed to see a list of certain ones, it's pretty common to run the "list" query using the User data - as in "where userid=#session.userid# and product_cat = 3". If you do this on the "category" page, but fail to do it on the "detail" page, all a user must do to see unathorized products is change the int "44" to a different int. How about that second case:

This is where that SQL injection attack can be so pernicious. If the int isn't validated as an int and is just passed into the query, i.e. "where product_id = #url.product_id#", then the user is free to pass in anything he wants as "url.product_id". If he's clever, he could pass in "44 ; drop table products". This would result in the products table being deleted completely (as long as the conditions above where met. To solve this problem, and still use this approach, make sure and qualify non-public data on all queries, and make sure and use the VAL( ) function when passing integer data directly into a query. The VAL( ) function simply takes the first number it finds starting on the left. So if you passed in "44 ; drop table products" VAL( ) would make sure that "44" was used. Of course <cfqueryparam ... > is a better choice, but more about that later. One more thing to note about VAL( ); it also accepts scientific notation as valid numeric data. So, if you pass in 444d or 43e2, it will read that as a very large number - usually creating an arithmetic overflow error on the DB platform. This can be tricky to troubleshoot.

The other area where SQL injection can be a danger is in the use of character data. Now, you might think that character data was exempt because of the way that CF escapes all single quotes. So, for example, if you pass in the words I'm going to the mall, CF automatically converts that sentence into I''m going to the mall. If you do something like "SET myField = '#form.sentence#'", the result is "SET myField = 'I''m going to the mall'. This makes SQL injection quit difficult. If the hacker sends in "I'm going to the mall' ; drop table products, the resultant query would insert that entire string into the field and ignore the keywords - making it impossible to run the drop command - right? Well, yes and no.

Unfortunately it is fairly common to build an "SQL string" out of form fields and pass the string into the query. Consider the following code for example.


<cfscript>
NewSQL = SELECT * FROM Products 
WHERE 	Title = ' form.title;
</cfscript>

Of course this is a pretty simple example. The real purpose of such an approach is to handle a large number of variable cases. The problem with this approach is that, in order to make it work, you must use the tag "PRESERVESINGLEQUOTES( )" in your query. Otherwise, CF would automatically escape all those painstakingly placed single quotes with double single quotes - killing your query code.


<cfquery ....>
	
	#preservesinglequotes(Newsql)#

</cfquery>

It should be obvious, that if you fail to scrub these form variables, a clever hacker can easily penetrate your database by using carefully placed singel quotes. For example, in the above query, if the hacker decided to put ' OR 1 = 1 AND 'BOB' = 'BOB into the form field, the resulting query would be


SELECT * FROM Products 
WHERE 	Title = '' OR 1 = 1 AND 'BOB' = 'BOB'

Obviously, this query will pull every item in the products table.

Solutions?

The best solution to this vulnerability is the use of the <cfqueryparam...> tag. This tag automatically "types" the form field to be a character, date, number, etc. If the value of the item in question is NOT of that type, it throws an error. Additionally, CFQUERYPARAM allows high-end DB platforms like MSSQL or Oracle to take advantage of DB Server-side execution-plan caching. This can result in huge performance improvements for you - especially on very complicated queries. I've been using this tag for over a year now on every query, and I'm amazed at the difference it makes.


:: posted by Mark at 2:46 PM
  Monday, December 16, 2002
 
CF Webtools
MX Consulting
Nebraska CFUG
mkruger@cfwebtools.com

CFMX, Java and SSL

These notes are the result of solving a particularly tricky problem with webservices on CF Talk. This helpful "keystore" procedure came from the diligent investigation of Mike Chambers and Trevor Baker.

There is a tricky nuance to using <CFHTTP...> with SSL in CFMX. In order to make an outgoing SSL request, the requesting agent must first obtain the "public" key. This public key is available from a "trusted certificate authority". Verisign, Thawt, and equifax are 3 well-known "trusted authorities". In your browser, if the certificate is not a "trusted athority", a warning message informs you that, while the cert may be good in other ways (not expired etc.) it is not from a source you have listed as trusted. If you choose, you can simply accept the certificate anyway. Note, encryption is determined by the type and size of the key - not by whether the authority is trusted or not. All things being equal, a certificate from a non-trusted authority will result in the same level of protection as that from a trusted authority.

In CF 5 an outgoing SSL request using <CFHTTP...> was successfully negotiated if the cert was found in the "root certificate store" of the server. In other words, if you had indicated the cert was trusted and allowed the key to be installed, CF 5 was able to use it. In CFMX however, the Java Run-time is unaware of the root certificate store. Instead, it has it's own cache of "trusted authorities" and it installs certs as needed based on this cache.

To discover the list of trusted authorities in your Java run time, try the following command line code:

C:\CFusionMX\runtime\jre\lib>keytool -list -storepass changit -noprompt -keystore C:\CFusionMX\runtime\jre\lib\security\cacerts

Of course you will want to change the path to your CFMX runtime directory. When I run this command on my CFMX dev box with the standard 1.3 JRE I get the following:

Keystore type: jks
Keystore provider: SUN

Your keystore contains 10 entries

thawtepersonalfreemailca, Feb 12, 1999, trustedCertEntry,
Certificate fingerprint (MD5): 1E:74:C3:86:3C:0C:35:C5:3E:C2:7F:EF:3C:AA:3C:D9
thawtepersonalbasicca, Feb 12, 1999, trustedCertEntry,
Certificate fingerprint (MD5): E6:0B:D2:C9:CA:2D:88:DB:1A:71:0E:4B:78:EB:02:41
verisignclass3ca, Jun 29, 1998, trustedCertEntry,
Certificate fingerprint (MD5): 78:2A:02:DF:DB:2E:14:D5:A7:5F:0A:DF:B6:8E:9C:5D
thawtepersonalpremiumca, Feb 12, 1999, trustedCertEntry,
Certificate fingerprint (MD5): 3A:B2:DE:22:9A:20:93:49:F9:ED:C8:D2:8A:E7:68:0D
thawteserverca, Feb 12, 1999, trustedCertEntry,
Certificate fingerprint (MD5): C5:70:C4:A2:ED:53:78:0C:C8:10:53:81:64:CB:D0:1D
verisignclass4ca, Jun 29, 1998, trustedCertEntry,
Certificate fingerprint (MD5): 1B:D1:AD:17:8B:7F:22:13:24:F5:26:E2:5D:4E:B9:10
verisignclass1ca, Jun 29, 1998, trustedCertEntry,
Certificate fingerprint (MD5): 51:86:E8:1F:BC:B1:C3:71:B5:18:10:DB:5F:DC:F6:20
verisignserverca, Jun 29, 1998, trustedCertEntry,
Certificate fingerprint (MD5): 74:7B:82:03:43:F0:00:9E:6B:B3:EC:47:BF:85:A5:93
thawtepremiumserverca, Feb 12, 1999, trustedCertEntry,
Certificate fingerprint (MD5): 06:9F:69:79:16:66:90:02:1B:8C:8C:A2:C3:07:6F:3A
verisignclass2ca, Jun 29, 1998, trustedCertEntry,
Certificate fingerprint (MD5): EC:40:7D:2B:76:52:67:05:2C:EA:F2:3A:4F:65:F0:D8

Obviously I trust Thawte and Versign certificates.

What happens when I need to make an SSL request to a site using a cert that is not from one of these authorities? The request will fail. For it to succeed, I must tell my JRE to "trust" the authority in question. To do that you will need to use the keytool to "import" the cert from the authority in question. For example, if you wanted to import the "instant SSL" certificate, you would need to import the 2 signing certificates they use to create their own certs. They use the following 2 certs:

GTE CyberTrust Root CA
Comodo Class 3 Security Services CA

Both of these certs are available through links at instant SSL installation support. Save each of them into a text file - cert1.crt and cert2.crt (or whatever), then use the key tool to import them into your store. Here is the command line syntax:

C:\CFusionMX\runtime\jre\lib>keytool -import -keystore c:\CFusionMx\runtime\jre\ lib\security\cacerts -alias instantssl -storepass changeit -noprompt -trustcacer ts -file c:\temp\cert1.crt

More information regarding the "keytool" is available at Sun Keytool docs. Once this code is run, you can re-run the initial command line code and look at your entries again. You should see something like this:

instantssl, Dec 5, 2002, trustedCertEntry,
Certificate fingerprint (MD5): C4:D7:F0:B2:A3:C5:7D:61:67:F0:04:CD:43:D3:BA:58


Now, when your outgoing <CFHTTP...> request negotiates the connection it will "trust" the public key provided to complete the SSL negotiation.


:: posted by Mark at 8:00 AM
  Tuesday, November 05, 2002
 
CF Webtools
MX Consulting
Nebraska CFUG
mkruger@cfwebtools.com

Adaptive Tags

Among the many ingenious presentations I've seen on the subject of the new CFMX server, the one by Tim Buntel (Product Manager for Cold Fusion) was the best. He demonstrated the use of "adaptive tags" using the new <Cfimport...> tag.

<Cfimport...> is use to import one or more "tags" (either CF custom tags or java custom tags) into a page process. The syntax is quite easy

<cfimport taglib="somedirectory/" prefix="tgLib">

The prefix is then used to reference that library, followed by a colon, followed by the name of the tag. For example, if you had a tag named "calculate" in the /somedirectory directory, you would do something like the following (assuming that "tgLIb" is your prefix):

<tgLib:calculate number_x="10" number_y="15" method="Add">

"Great..." you say, "an easy way to group methods and tags together and use a single call to get them into the request. But wait, there's more (with my apologies to Ron Poppeal)! CFMX, either by design or by design flaw, allows you to use the <Cfimport...> tag without a valid prefix. You can actually do this:

<cfimport taglib="somedirectory/" prefix="">

How is that cool? Consider what would happen if you created a simple CF template called "b.cfm" with the following code in it:

if(thisTag.executionMode is "end"){
thisTag.generatedContent = '<b style="font-size: 14pt; font-weight: bold">';
	}

If you imported the tag library folder without a prefix, this code would run whenever you placed a <b> </b> around any code you wished to bold face. This would enforce, not only bold face type, but a particular style of bold face. Yeah, I know, you can do the same thing with style sheets (designers always think they are so clever - sheesh). Ok, before we start rewriting the entire HTML standard, let's look at an example that is really useful. How about solving the "cookieless browser" problem once and for all.

Every CF programmer knows about the cookieless browser problem. It's a big pain in the keester. The problem is pretty simple to explain. Most really useful applications rely on some form of user identification. For example, a user logs in, you identify who he or she is by their username and password, then you let them have access to something. Simple - right? Wrong! A ColdFusion session (whether you are using client variables or session variables) relies on something being passed back and forth from the client to the web server. Because HTTP is a stateless protocol, it is necessary to constantly remind the web server of who you are. Essentially, every http request has to say, "hey! it's me again... remember me?"

The simplest way to do this is with cookies. This is CF's "default implementation". 1 or more cookies are set that are unique to the user. In CFMX you can choose between the traditional CFID/CFTOKEN, a UUID (new in CF 5 and carried forward) or a JSessionID (which does not work very well with an IIS installation - go figure). This item is stored in a cookie and passed back to the server as a way of identifying their unique session.

The problem with this approach has always been that not every user or browser supports cookies. Some users turn them off for security reasons. Some firewalls and proxies deny cookies as well. Therefore, to support sessions on a cookieless browser, a developer had to append the CFID and CFTOKEN (or whatever session identifier he was using) to every URL in a given application. Believe me, that's a real headache.

In CFMX Macromedia has made it much easier. They have provided a function (called UrlSessionFormat()) that you can wrap around your href that will append the variables if the browser doesn't support cookies. This is nice. Cookie people will see nice clean URLs, and non-cookie people will see the session Identifier passed with every URL. But this is still not quite convenient is it? A developer will still have to wrap the function around every Href - making it tedious to use. All of his link code will resemble the following:

<a href="<cfoutput>#UrlSessionFormat(blah.cfm)#</cfoutput>">click here</a>

Ok Macromedia - I appreciate the effort, but my keester is still bugging me (maybe I need more roughage). This is where the adaptive tag can come to the rescue. What if we rewrote the anchor tag (<a...>) to automatically wrap the href in the UrlSessionFormat() function. That aught to be easy. Let's try it. Here's a tag we call "a.cfm" and we store it in the /tagLib/ mapping.

<cfscript>
	if(thisTag.executionMode is "end"){
		
		/* Copy out the Href into a local 
		variable so we can safely delete it */
		href = attributes.href;
		
		/* delete the Href key so our loop 
		through the attributes doesn't duplicate it */
		structDelete(attributes,"href");
		
		// loop through attributes and write link
		strTemp	=	' ';
		FOR(att IN Attributes)
		{
			strTemp = strTemp & att & '="' & Attributes[att] & '" ';
		}
		
	// Write out to the page (line breaks are for clarity)
	thisTag.generatedContent = '<a href="' & UrlSessionFormat(href) & '" ' 
			& strTemp &  ">" thisTag.generatedContent & "</a>";	
	}
</cfscript>





Now, all we need to do is include the <Cfimport...> call shown below at the top of our page request, or perhaps in the application.cfm file.

<cfimport taglib="somedirectory/" prefix="">

Every anchor tag included in the document from that point forward will automatically support cookieless browsers. That is one real-world example of how adaptive tags can solve a long standing programming problem.


:: posted by Mark at 9:30 AM

© 2001 M.A Kruger

-->
_______________
_______________

Comments on technical items of interest collected throughout the day (Mark A. Kruger)



Powered by Blogger