InfiniTec - Henning Krauses Blog

Don't adjust your mind - it's reality that is malfunctioning

DirectoryServices revisited

As promised in the first article (See here), here is another article on the classes in my DirectoryServices package. Here we go...

Lost in translation...

Ever wanted to translate an accountname like contoso\jdoe to the corresponding distinguished name? The Translator class comes to the rescue:

    1 Translator translator;

    2 TranslationResult result;

    3 

    4 using (Connection connection = newConnection("contoso.local", DirectoryIdentifierType.DnsDomain, false,

    5     newNetworkCredential("administrator", "password", "contoso"), AuthType.Basic))

    6 {

    7     translator = newTranslator(connection);

    8     translator.InputFormat = NameFormat.NT4AccountName;

    9     translator.OutputFormat = NameFormat.DistinguishedName;

   10     translator.Translate("contoso\\administrator");

   11 

   12     result = translator.Results[0];

   13     if (result.Status == TranslationStatus.Success)

   14     {

   15         Console.WriteLine("The " + translator.OutputFormat + " of " + result.InputName + " is " + result.TranslatedName);

   16     }

   17     else

   18     {

   19         Console.WriteLine("Could not translate the name. Error: " + result.Status);

   20     }

   21 }

This class wraps around the DsCrackNames function of the Win32 Directory Services API. Basically, you can translate between these name formats:

  • DistinguishedName
  • NT4AccountName
  • DisplayName
  • UniqueId
  • CanonicalName
  • UserPrincipalName
  • CanonicalNameEx
  • ServicePrincipalName
  • SidOrSidHistory
  • DnsDomainName
  • ListNamingContexts

 

Not every nameformat can be translated to every other. You will get a TranslationStatus.NoMapping error in this case.

The last entry, ListNamingContext, can be used to enumerate all naming contexts in the forest. To use this, set the InputFormat to ListNamingContext. Then, call the Translate method with at least one name (content is completely irrelevant):

    1 Translator translator;

    2 

    3 using (Connection connection = newConnection("contoso.local", DirectoryIdentifierType.Server, false,

    4     newNetworkCredential("administrator", "password", "sub"), AuthType.Basic))

    5 {

    6     translator = newTranslator(connection);

    7     translator.InputFormat = NameFormat.ListNamingContexts;

    8     translator.Translate("any_value");

    9 

   10     foreach (TranslationResult result in translator.Results)

   11     {

   12         Console.WriteLine(result.TranslatedName);

   13     }

   14 }

If the InputFormat is set to NameFormat.Unknown, the directory server tries to determine the format of the name(s) to translate. This causes some performance degration - If you know the format, you should supply it.

Unleashing the power of ambiguous name resolution...

Outlook has a handy feature called ambiguous name resolution: You can type only a part of a name, and Outlook resolves the given name to a complete name, if possible. Active Directory also implements this feature, and it's possible to use it with a special LDAP query: (anr=jo*) will find all items in the Active Directory with a special set of properties (per default, givenName, surname, displayName, legacyExchangeDN, msExchMailNickname, RDN, physicalDeliveryOfficeName, , proxyAddress, sAMAccountName) matches the specified filter.

The PrincipalResolver encapsulates this feature and extends it with an additional feature: If the name being searched for is exactly two characters long, the filter is set to (|(anr=value)(&(givenName=value[0]*)(sn=value[1]*))), which effectively resolves initials. Here an example:

    1 PrincipalResolver resolver;

    2 

    3 using (Connection connection = newConnection("contoso.local", DirectoryIdentifierType.Server, false, newNetworkCredential("administrator", "password", "sub"), AuthType.Basic))

    4 {

    5     resolver = newPrincipalResolver(connection);

    6     resolver.FindAll("al", ResolveTypes.User);

    7 

    8     foreach (ActiveDirectoryUser entry in resolver.SearchResult)

    9     {

   10         Console.WriteLine(entry.DisplayName);

   11     }

   12 }

If the domaincontroller, to which the connection object is bound is a global catalog, the entire forest will be searched. To search only a part of the forest, specify these settings:

    1 resolver.ResolveScope = ResolveScope.Domain;

    2 resolver.SearchRoot = Searcher.RootDomain;

The first line restricts the search to the specified domain, while the second line sets the domain for the search. Two default values are available: Searcher.RootDomain, which searches the root domain of the forest, and Searcher.DefaultDomain, which searches the default domain of the domain controller the current connection is bound to.

The PrincipalSearcher can search either for users, groups or both types. Note, that users do include contacts as well.

Speaking of users and groups

To simplify the handling of users and groups, there are two classes to handle these to types: The ActiveDirectoryUser and the ActiveDirectoryGroup:


(click to enlarge)

The base class for both classes is the ActiveDirectoryEntry, which contains some properties and methods for handling Active Directory entries. Based on the ActiveDirectoryEntry is the ActiveDirectoryPrincipal, which contains some properties regarding group membership and SIDs.

Both, the ActiveDirectoryUser and the ActiveDirectoryGroup inherit from this class: The ActiveDirectoryUser exposes mst the properties available on user objects. The same is true fro the ActiveDirectoryGrup.

For performance reasons, the group memberships are only exposed in SID form (the group memberships are stored in this way). The TranslateSids method can be used to translate those sids to a more readable form.

Thats it for now... I hope this library is of some use to anyone...

Technorati:

Posted by Henning Krause on Sunday, September 17, 2006 12:00 AM, last modified on Sunday, September 17, 2006 12:00 PM
Permalink | Post RSSRSS comment feed

Finding the Junk Email folder

Ever wondered how to get hold of the Junk Email folder? Since it is localized, one can't simply use /exchange/username/Junk E-Mail. The Exchange SDK contains an article on how to get the urls of the wellknown folders like inbox, tasks, calendar folder, etc. (See Exchange 2003 and Exchange 2007). But no sign of the junk email folder. Luckily, that list is simply incomplete; you can get the junk email folder by reading the property urn:schemas:httpmail:junkemail...


Technorati:

Posted by Henning Krause on Wednesday, September 13, 2006 12:00 AM, last modified on Wednesday, September 13, 2006 12:00 PM
Permalink | Post RSSRSS comment feed

A high-level wrapper around System.DirectoryService.Protocols

Thanks to Joe Kaplan, I spent some time recently playing around with the System.DirectoryServices.Protocols (SDS.P)  namespace. The main advantage of this namespace is control and flexibility: The developer decides when to close a connection. What to search, with which scope. This is possible because the SDS.P classes operate at a much lower level than the DirectoryEntry or the DirectorySearcher class.

Nothing, however, comes without a price. In this case, the price is usability - There is simply no DirectoryEntry in the SDS.P namespace - one has to do a search with a scope of Base. Not quite simple. Additionally, the only datatypes supported on the properties returned from a search operation are byte[] and string. And no support for generics anywhere...

Therefore I created this wrapper around the SDS.P namespace, to make it more usable. Additionally, I included a class to translate names between the various formats used throughout Windows. This class wraps around the DsCrackNames function of the Win32 API, and makes GUID binding trivial - Just translate the security-identifier to the corresponding GUID and bind to the Active Directory object (Yes, I know, the SID can also be used to bind to the Active Directory object - but this is limited to the current domain - one cannot bind to an object outside the current domain).


The Connection class (click to enlarge)
When using this class, you will always start with the connection object. The connection class can either bind to a specific server or to a domain name. Server-less binding is also supported.

If you want to perform your own requests using this class, just call the GetSendRequestOperation() which issues the request aynchronously using my InfiniTec.Threading library. This is not trivial if you are not familiar with the library - if you need advice on this topic, drop me a note and I will post an additional article on this topic.

The code used is something like this:

    1 using (Connection connection = newConnection("entdcsub", DirectoryIdentifierType.Server, false))

    2 {

    3     // Do something interesting here...

    4 }

Binding to specific objects

Once you have connection, you can simply bind to a known object:

    1 using (Connection connection = newConnection("dcaw", DirectoryIdentifierType.Server, false))

    2 {

    3     item = newItem("CN=Doe\, John, CN=Users, DC=AdventureWorks, DC=local", connection);

    4     item.Refresh();

    5     displayName = item.Properties.GetProperty<string>("displayName").Value;

    6 }

It's as simple as this...

The Item class has the following characteristics:


But I don't know the distinguished name of the object....

... don't panic. The Searcher class will help you here. This class is by far the most complex class in this library:


The search operation which will be performed by the Searcher class can be extensively customized. Here are the main option:
Constraints - This property accepts a standard LDAP filter like "(mail=*)" or similar.
IncludeDeletedItems - If true, deleted items are returned.
NamingContextScope - This is important. You can specifiy if you want to search the current naming context only, or search the current and all subordinate contexts.
PageSize - Doing a paged search reduces the resources used during the search. This property let you specify the number of items returned per page.
PropertiesToLoad - Which properties should be populated during the search?
Scope - Do you want to search only the search root, the direct descendents of the searchroot, or all levels below the search root?
SearchRoot - Where does the search begin? If NamingContextScope is set to domain scope, a search root must be specified. Otherwise, this property can be left blank. In this case, the entire forest ist searched.
SizeLimit - This property lets you specify the maximum number of items you want to get. But you should use this sparingly - if more items are returned than specified here, an exception is thrown.
SortKeys - Very handy. This allows server-side sorting of the result set

To start a search, populate the desired fields and call FindAll or FindPage. The first method, performs the search and returns once the search is completed. The FindPage method returns once the next page of items is returned by the server.

For scalability reaonse, you should use the FindAllAsync and FindPageAsync methods - these methods return immediately and thus don't block the current thread. The FindCompleted and ProgressChanged events are fired, whenever an operation completes.

The following example performs an ambiguous name resolution and finds all entries which start with the character a:

    1 staticvoid Main(string[] args)

    2 {

    3     Searcher searcher;

    4     SearchToken token;

    5 

    6 

    7     using (Connection connection = newConnection("dcaw", DirectoryIdentifierType.Server, false))

    8     {

    9         searcher = newSearcher(connection);

   10         searcher.PageSize = 1000;

   11 

   12         searcher.Constraints = "(aNR=a*)";

   13         searcher.NamingContextScope = NamingContextScope.IncludeSubDomains;

   14         searcher.SearchRoot = Constants.WellknownDistinguishedNames.RootDse;

   15         searcher.ProgressChanged += searcher_ProgressChanged;

   16         searcher.FindCompleted += searcher_FindCompleted;

   17 

   18         _Event = newManualResetEvent(false);

   19 

   20         searcher.FindPageAsync();

   21 

   22         _Event.WaitOne();

   23 

   24         Console.ReadLine();

   25     }

   26 }

   27 

   28 staticvoid searcher_FindCompleted(object sender, System.ComponentModel.AsyncCompletedEventArgs e)

   29 {

   30     Console.WriteLine("Finished");

   31     if (e.Error != null) Console.WriteLine("Error: " + e.Error.ToString());

   32     _Event.Set();

   33 }

   34 

   35 staticvoid searcher_ProgressChanged(object sender, SearchProgressChangedEventArgs e)

   36 {

   37     Console.WriteLine("Found items: " + e.Items.Count);

   38     foreach (Item item in e.Items)

   39     {

   40         Console.WriteLine("\t" + item.DistinguishedName);

   41     }

   42     Console.WriteLine();

   43 }

If you want to get one page and continue the search at a later point in time (for example from an ASPX page), you can use the SearchToken class to recreate a search operation:

    1 using (Connection connection = newConnection("dcaw", DirectoryIdentifierType.Server, false))

    2 {

    3     searcher = newSearcher(connection);

    4     searcher.PageSize = 1000;

    5 

    6     // Create a searcher and perform the initial search

    7 

    8     // Now, save the current search token.

    9     token = searcher.SearchToken;

   10 

   11 

   12     // Create a new search operation from the saved token:

   13     searcher = newSearcher(connection, token);

   14     searcher.ProgressChanged += searcher_ProgressChanged;

   15     searcher.FindCompleted += searcher_FindCompleted;

   16 

   17     // Continue the search operation

   18     searcher.FindPageAsync();

   19 }

The SearchToken is marked as serializable, so it can be persisted in the viewstate of an ASPX page.

The other very interestingly looking classes...

There are a number of classes in the library I will discuss in another post... those classes can be used to translate names, or provide a stongly-typed access to properties of ActiveDirectory principals (users, groups), and a search class used to find those principals...

Limitations

I have barely touched the surface of the SDS.P namespace - the current classes are read-only, meaning that changes to the Item class cannot be written back to to directory. This will come with a later release.

License

This library is published as freeware. You may use it in you own programs, commercial or freeware at no cost. You may also modify the classes. All I ask for is that you give credit (a link or something like that) to the InfiniTec website.

Downloads

Documentation.zip (341,126 Bytes)
The documentation, as compiled help file
InfiniTec.DirectoryServices_Release.zip (110,671 Bytes)
Release binaries, signed with the InfiniTec private key
InfiniTec.DirectoryServices_Source.zip (66,783 Bytes)
The source code for this release

Technorati:

Posted by Henning Krause on Sunday, September 3, 2006 12:00 AM, last modified on Monday, November 29, 2010 7:30 PM
Permalink | Post RSSRSS comment feed

Why we need &lt; and &gt; instead of < and >

A question which came up lately is why we need to escape < and > characters when issuing a SEARCH request on an Exchange folder...

In fact, this has nothing whatsoever to do with Exchange or the WebDAV protocol - at least, not directly. It just happens that the WebDAV protocol uses XML as the transport medium, and in XML, those characers are reserved ones; After all, they are used to start and end tags...


Technorati:

Posted by Henning Krause on Wednesday, August 30, 2006 12:00 AM, last modified on Wednesday, August 30, 2006 12:00 PM
Permalink | Post RSSRSS comment feed

Time for a new layout...

Well, it was time for a change... so here it is - a new layout and a bunch of new features.

The content is now more blog-based - all the articles are now displayed entirely, instead of a simple grid with all the articles in the categories.

The ATOM-Feed now also contains the content of the articles. Additionally, the feed supports the HTTP HEAD verb, which returns the date/time of the last update, as well as support for the If-Modified-Since header. This should reduce my transfer volume (not that I reached my limit... :-) and improve download speed on your side...

Since this is now more blog-like, I hope to post more often now :-) Well, we will see...


Technorati:

Posted by Henning Krause on Wednesday, August 30, 2006 12:00 AM, last modified on Wednesday, November 24, 2010 8:00 AM
Permalink | Post RSSRSS comment feed

Reading the Spam Confidence Level from an email via WebDAV

Solution

The Spam Confidence level is stored by the Intelligent Message filter on every message. It is stored in a MAPI property called PR_SCL. The value of this property is 0x40760003.

Via WebDAV, this property is available via the property http://schemas.microsoft.com/mapi/proptag/x40760003.


Technorati:

Posted by Henning Krause on Friday, July 7, 2006 12:00 AM, last modified on Friday, July 7, 2006 12:00 PM
Permalink | Post RSSRSS comment feed

Howto: Change the default download directory for Internet Explorer

Solution

The default download directory for Internet Explorer is the My Document folder. To change this location, follow these steps:

  1. Open the registry editor and navigate to HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer.
  2. Create a new value of type String and name it Download Directory
  3. Open the new value and enter the new directory.

The result should look like this:


Registy editor with changed default folder for the Internet Explorer (click to enlarge)


Technorati:

Posted by Henning Krause on Thursday, May 25, 2006 12:00 AM, last modified on Wednesday, August 30, 2006 12:00 PM
Permalink | Post RSSRSS comment feed

InfiniTec.Exchange

This article has been superseeded by this article.

Introduction

This library is the successor to my WebDAVLayer. In fact, the WebDAVLayer was never meant to be released to the public and had no documentation. As such, the library was very difficult to use.

Another problem was the lack of asynchronous methods. Because of this, the library did not scale well.

This new release is a complete rewrite of that library and provides a very simple set of classes. Additionally, everything is documented and an example application is included.

Why only .NET 2.0?

A recent question I got was whether I would release a version of this library which could be compiled with .NET 1.1. The reason, why I will not do this is because of the heavy usage of .NET 2.0 features, like generics and iterators. It would be a major rewrite of the library, and for all future releases I would have to maintain two branches. Since I do this in my spare time, that would be to much work.

Status

This is an early beta version. You should expect that some features still do not work as expected.

Many features are not yet implemented. This includes support for the WebDAV SUBSCRIBE methods, support for batch operations (BMOVE and BCOPY), recovering of deleted items and discovery mechanisms.

Be extremely careful when settings permission. Do set permissions with this version of the library on on a production server, because it may corrupt the security descriptor on the folder. If you accidently messed up a security descriptor, you can use the PfDavAdminTool to correct those descriptors.

Structure

The main classes are displayed below:


InfiniTec.Exchange class diagram (click to enlarge)

The entry point is the Connection class. This class specifies the context information for the WebDAV requests. To create a new connection, this code can be used:

    1 Connection connection = newConnection();

    2 

    3 connection.ConnectionProtocol = ConnectionProtocol.Http;

    4 

    5 connection.Server = "myserver";

    6 connection.Credential.Username = "administrator";

    7 connection.Credential.Password = "password";

    8 connection.Credential.AuthenticationType = AuthenticationType.Basic;

This connection can now be used to instantiate one of the other classes:

    1 Item item = newItem("public/my folder", connection);

    2 item.Refresh();

    3 

    4 bool isHidden = item.Properties.GetProperty<bool>(Namespaces.Dav, "ishidden").Value;

All operations are available in a synchronous version as well as an asynchronous version. The above refresh operation can also be called asynchronously:

    1 Item item = newItem("public/my folder", connection);

    2 item.RefreshCompleted += item_RefreshCompleted;

    3 item.RefreshProgressChanged += item_RefreshProgressChanged;

    4 item.RefreshAsync();

During the refresh operation, the ProgressChanged event is called multiple times. When the operation finishes, the Completed event is called.

Managing security on items

The Folder.GetSecurity() and Item.GetSecurity() methods provide access to the security descriptors on Exchange folder and items. Unlike Outlook, this package allows permissions to be set on every item, not only on folders.

The basic classes for manipulation of security descriptors are displayed in this class diagram:


Classdiagram of Exchange security descriptors

The GetAccessControlEntries() method of either the FolderSecurity or ItemSecurity class returns a collection of ItemAce (or FolderAce respectively), which look like this:


Class diagram of the security system (click to enlarge)

Three different levels of access masks are used:

  • The AccessRole define the eight standard roles also defined by Outlook. Additionally, the FolderVisible role is defined. This enumeration is only defined on folder objects.
  • The AccessMasks enumeration represents the basic access operations used in Outlook. These access masks applies both to items and folders.
  • The FolderAccessMasks and ItemAccessMasks define the low-level permissions on folders and items.

To set permission on folder and items, use the SetSecurity() method of the ItemAce or FolderAce class. Be extremely careful if you want to set the ItemAccessMasks or FolderAccessMasks directly. The corresponding methods do have the Dangerous prefix for a reason!

ChangeLog

Version 0.93 - 07-23-06

  • Fixed several issues with FormBased authentication
  • New FormbasedAuthenticationRequiredException, which is thrown when an attempt is made to access an item which requires form based authentication.
  • Moved the DangerousSetAccess method from the ExchangeSecurity to the ItemSecurity
  • Added a DangererousSetAccess method to the FolderSecurity class.
  • Fixed an error in the ItemSecurity.SetAccess(AccessMasks) and FolderSecurity.SetAccess(AccessMasks) method (NullReferenceException).
  • All methods calling event handler (OnRefreshCompleted, etc.) are now "protected virtual"
  • Added Attachment handling (via the Item.Attachments property). Attachments can be enumerated, added and removed.
  • All EventHandler<ProgressChangedEventArgs<RequestStatus>> have been replaced with a ProgressChangedEventHandler<RequestStatus>
  • The RefreshScope has been removed
  • The RefreshStatus has been removed
  • The Refresh operations of the Item class do not have a parameter any longer
  • RequestStatus.BeginningFormBasedAuthentication renamed to RequestStatus.PerformingFormBasedAuthentication.
  • The Item class now has a GetContent/GetContentAsync and a SetContent/SetContentAsync methods operation which allow downloading/setting the the MIME content of the item.

Version 0.92 - 05-21-06

  • Fixed a bug in the PropertyCollection.Contains method
  • Added several TryGetProperty and TryGetMultiValuedProperty methods to the PropertyValueCollection
  • Added GetSecurity() methods to the Item and Folder class, which provides access to the security descriptor of that item
  • Added classes for manipulation of the security descriptors of an item

Version 0.91 - 05-17-06

  • Properties are now strongly typed. Single-Valued properties are of type Property<T> and multi valued properties are of type MultiValuedProperty<T>
  • Removed the IsMultiValued and Type properties from the Property class. On a refresh operation, the HTTP result is now reflected in each property.
  • The Properties collection now contains properties which could not be successfully loaded. Those properties are of type Property and have a Property.Status.StatusCode != HttpStatusCode.Ok.
  • When a property is accessed via the Item.Properties indexer or any other of the GetProperty<T> or GetMultiValuedProperty<T> methods, an exception is thrown, whenever the Property.Status.StatusCode != HttpStatusCode.Ok.

Version 0.9 - 05-15-06

  • Initial release

Downloads

InfiniTec.Exchange_Release.zip (129,280 Bytes)
Version 0.93 release compile including debug symbol, signed with the InfiniTec private key
Documentation.zip (251,173 Bytes)
Documentation as CHM file
InfiniTec.Exchange_Source.zip (91,769 Bytes)
Source code version 0.93
ExampleApplication.zip (66,862 Bytes)
Example application

Technorati:

Posted by Henning Krause on Sunday, May 14, 2006 12:00 AM, last modified on Monday, November 29, 2010 7:16 PM
Permalink | Post RSSRSS comment feed

A search on a calendar folder can not span more than 732 days

More Information

A SEARCH on an Exchange calendar folder with a constraint of a begin and an end date is called an expansion query. This means, that all recurring appointments are expanded, and each instance is returned in the query. (See Searching Calendar Folders with WebDAV on MSDN for more information. The article specifically targets the WebDAV  protocol, but the issue explained here applies to the ExOleDB provider as well).

An example of such a query is displayed below.

If you run this query and use values for the start and end properties where the difference between start and end is greater than 732 days, you get the following error:

Error while processing WHERE clause in the SQL statement.

Example

An example of such an expansion query might look like this:

    1 SEARCH /exchange/~username/calendar/ HTTP/1.1

    2 Host: www.example.com

    3 Content-Type: text/xml

    4 

    5 <?xmlversion="1.0"?>

    6 <g:searchrequestxmlns:g="DAV:">

    7   <g:sql>

    8     Select "urn:schemas:calendar:location", "urn:schemas:httpmail:subject",

    9     "urn:schemas:calendar:dtstart", "urn:schemas:calendar:dtend",

   10     "urn:schemas:calendar:busystatus", "urn:schemas:calendar:instancetype"

   11     FROM Scope('SHALLOW TRAVERSAL OF ""') WHERE

   12     "DAV:contentclass" = 'urn:content-classes:appointment'

   13     AND "urn:schemas:calendar:dtstart" &gt; CAST("2005-01-01T00:00:00Z" AS 'dateTime.tz')

   14     AND "urn:schemas:calendar:dtend" &lt; CAST("2006-01-01T00:00:00Z" AS 'dateTime.tz')

   15     ORDER BY "urn:schemas:calendar:dtstart" ASC

   16   </g:sql>

   17 </g:searchrequest>

Workaround

To work around this problem, do your search in multiple steps, with a time span of two years or less.

Status

This is a known limitation in the Exchange Server: Expansion queries are limited to a time span of two leap years.

Posted by Henning Krause on Thursday, April 13, 2006 12:00 AM, last modified on Thursday, April 13, 2006 12:00 PM
Permalink | Post RSSRSS comment feed

Workaround: A status of 409 - conflict is received when adding an attachment to a mail via WebDAV

More Information

The WebDAV protocol can be used to send emails. This can be done by creating the email in a mailbox folder (i.e. the Drafts folder) and moving this item to the ##DavMailSubmissionURI## folder. This is described in this MSDN article: Sending a Message (WebDAV).

Attachments can be either added to this mail using the Outlook Web Access method, or by issuing a PUT, using the address of the mail as folder: If the address of the item is

http://myserver/exchange/jdoe/drafts/new_mail.eml

the url for the PUT command would be something like http://myserver/exchange/jdoe/drafts/new_mail.eml/attachment.zip.

If you created the mail item via the PUT method, instead of a PROPPATCH, you might get a 409  - conflict error when adding attachments to the item.

Solution

A workaround seems to be to issue a PROPPATCH on the mail item itself. The PROPPATCH does not necessarily have to change the item. It's more like a touch command. After this PROPPATCH command, the PUT command for the attachment will be successful.

Status

The status of this problem is unknown.

Posted by Henning Krause on Thursday, April 13, 2006 12:00 AM, last modified on Thursday, April 13, 2006 12:00 PM
Permalink | Post RSSRSS comment feed