How to store each SharePoint Site Collection in its own Database – Part 3

As requested in Part 2, here is the feature code to add eventreceivers to all existing and new site collections. In my experience, I like to create a Web scoped feature that does the work for one web, and then write another feature scoped at the site collection or web application level that activates my web scoped feature for existing and new webs.

So first off, here is the code that does the work for one web:

using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.SharePoint;
using Microsoft.SharePoint.WebPartPages;
using System.Web.UI.WebControls.WebParts;
using System.Web;
using Microsoft.SharePoint.Administration;

namespace Features.SiteCollectionDeletedEventReceiverWeb
{
    class FeatureReceiver : SPFeatureReceiver
    {
        public override void FeatureInstalled(SPFeatureReceiverProperties properties) { }
        public override void FeatureUninstalling(SPFeatureReceiverProperties properties) { }
        public override void FeatureActivated(SPFeatureReceiverProperties properties)
        {
            using (SPWeb web = (SPWeb)properties.Feature.Parent)
            {
                if (web == null) throw new ApplicationException("Web could not be found");

                web.EventReceivers.Add(SPEventReceiverType.SiteDeleted,
                    "EventHandlers, Version=1.0.0.0, Culture=neutral, PublicKeyToken=XXXXXXXXXXXXXXXX",
                    "EventHandlers.DeleteDatabaseOnSiteDeleted");
                web.EventReceivers.Add(SPEventReceiverType.SiteDeleting,
                    "EventHandlers, Version=1.0.0.0, Culture=neutral, PublicKeyToken=XXXXXXXXXXXXXXXX",
                    "EventHandlers.DeleteDatabaseOnSiteDeleted");
                web.Update();
            }
        }
        public override void FeatureDeactivating(SPFeatureReceiverProperties properties)
        {
            using (SPWeb web = (SPWeb)properties.Feature.Parent)
            {
                if (web == null) throw new ApplicationException("Web could not be found");

    List<SPEventReceiverDefinition> eventReceiversToDelete = new List<SPEventReceiverDefinition>();

    foreach (SPEventReceiverDefinition eventReceiver in web.EventReceivers)
    {
     if (eventReceiver.Class == "EventHandlers.DeleteDatabaseOnSiteDeleted")
     {
      eventReceiversToDelete.Add(eventReceiver);
     }
    }

    foreach (SPEventReceiverDefinition eventReceiver in eventReceiversToDelete)
    {
     eventReceiver.Delete();
    }

    web.Update();
            }
        }
    }
}

And here is the code that activates the previous feature on all existing webs for the whole farm:

using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.SharePoint;
using Microsoft.SharePoint.Administration;

namespace Features.SiteCollectionDeletedEventReceiverWebApplication
{
    class FeatureReceiver : SPFeatureReceiver
    {
        public Guid FeatureId()
        {
            return new Guid("{0C522324-C774-4424-B73E-1BCDEC147D89}");
        }

  public override void FeatureActivated(SPFeatureReceiverProperties properties)
        {
            SPWebApplication webApplication = properties.Feature.Parent as SPWebApplication;
            if (webApplication == null) throw new ApplicationException("WebApplication could not be found");

            foreach (SPSite site in webApplication.Sites)
            {
                foreach (SPWeb web in site.AllWebs)
                {
                    if (false == IsFeatureActive(FeatureId(), web.Features) &&
                        ShouldActivateFeature(web))
                    {
                        web.Features.Add(FeatureId(), true);
                    }
                    web.Dispose();
                }
                site.Dispose();
            }
        }

        public override void FeatureDeactivating(SPFeatureReceiverProperties properties)
        {
            SPWebApplication webApplication = properties.Feature.Parent as SPWebApplication;
            if (webApplication == null) throw new ApplicationException("WebApplication could not be found");

            foreach (SPSite site in webApplication.Sites)
            {
                foreach (SPWeb web in site.AllWebs)
                {
                    if (IsFeatureActive(FeatureId(), web.Features))
                    {
                        web.Features.Remove(FeatureId(), true);
                    }
                    web.Dispose();
                }
                site.Dispose();
            }
        }

  public override void FeatureInstalled(SPFeatureReceiverProperties properties)
        {

        }

        public override void FeatureUninstalling(SPFeatureReceiverProperties properties)
        {

        }

        private bool ShouldActivateFeature(SPWeb web)
        {
   //Implement any custom logic whether to activate the feature on the web
            return true;
        }

  private bool IsFeatureActive(Guid featureId, SPFeatureCollection features)
        {
            try
            {
                SPFeature feature = features[featureId];
                if (feature != null) return true;
            }
            catch { }
            return false;
        }
    }
}

Also, this is feature stapling code in the feature_elements.xml file that activates the feature on any new webs:

<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
 <FeatureSiteTemplateAssociation Id="0C522324-C774-4424-B73E-1BCDEC147D89" TemplateName="GLOBAL" />
 <FeatureSiteTemplateAssociation Id="0C522324-C774-4424-B73E-1BCDEC147D89" TemplateName="STS#1" />
</Elements>

The global means it will be activated no matter what template is selected. I found that in some cases, a template (such as the blank template, STS#1) will not allow global based feature stapling. That’s why I added the extra line for STS#1, which is the blank template.

The full source can be downloaded here.

If you are going to copy my source, please remember a few things:

  1. Make sure you sign your assemblies
  2. EventReceivers are REQUIRED to be in the GAC
  3. Make sure you replace the ReceiverAssembly and ReceiverClass with your assembly and class names including the publickeytoken in the Feature.xml files
  4. Do the same thing for the EventReceivers in the SiteCollectionDeletedEventReceiverWeb FeatureReceiver

How to store each SharePoint Site Collection in its own Database – Part 2

As I mentioned in Part 1, I decided to store each site collection in its own database for better managability on both of our SharePoint farms. This post is Part 2 and hopes to answer the question: “What happens when someone deletes a site collection?” In a normal sharepoint environment that only has a few content databases, this is not a big deal. But in our environment it’s a much bigger deal because each site collection has its own database and that database name includes the site collection url. Our web service from Part 1 helps make sure we don’t put a site into a database that is left over from a deleted site collection, but it’s our event receivers that do the real clean up.

We have two event receivers for this. One for SiteDeleting (Before the site collection is deleted) and the other for SiteDeleted (After the site collection is deleted). SiteDeleting will check if the site collection being deleted is in a database which will be empty after the site collection is deleted. If this is the case, then we set the database status in sharepoint to offline (disabled). SiteDeleted will get a list of all content databases that are empty and remove them from SharePoint. It also sets the database to single user and then drops it.

Below is the code or click this link to download:

using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.SharePoint;
using Microsoft.SharePoint.Administration;
using System.Data;
using System.Data.SqlClient;
using Microsoft.SharePoint.Utilities;
using System.Web;

public class DeleteDatabaseOnSiteDeleted : SPWebEventReceiver
{
    public override void SiteDeleting(SPWebEventProperties properties)
    {
        base.SiteDeleting(properties);
        using (SPSite site = properties.Web.Site)
        {
            //Before the site collection is deleted
            //set status to disabled if the currently being deleted site collection
            //is the last one in this database
            if (site.ContentDatabase.CurrentSiteCount == 1)
            {
                site.ContentDatabase.Status = SPObjectStatus.Disabled;
                site.ContentDatabase.Update();
            }
        }
    }

    public override void SiteDeleted(SPWebEventProperties properties)
    {
        base.SiteDeleted(properties);

        Uri url = new Uri(properties.FullUrl);
        string currUrl = properties.FullUrl;
        string basepath = currUrl.Substring(0, currUrl.IndexOf(url.Host) + url.Host.Length);

        SPWebApplication webApp = SPWebApplication.Lookup(new Uri(basepath));
        List<SPContentDatabase> emtpyDbs = new List<SPContentDatabase>();

        //After the site collection is deleted find all databases that 
        //don't have any sites in them
        foreach (SPContentDatabase database in webApp.ContentDatabases)
        {
            if (database.CurrentSiteCount <= 0)
            {
                emtpyDbs.Add(database);
            }
        }

        foreach (SPContentDatabase database in emtpyDbs)
        {
            RemoveAndDeleteDb(database);
        }
    }

    private void RemoveAndDeleteDb(SPContentDatabase db)
    {
        string dbName = db.Name;

        if (db.CurrentSiteCount != 0) return;

        //Remove database from SharePoint
        db.WebApplication.ContentDatabases.Delete(db.Id);

        //Get database server name
        SPWebService service = SPFarm.Local.Services.GetValue<SPWebService>();
        SPDatabaseServiceInstance defaultDatabaseInstance = service.DefaultDatabaseInstance;
        string databaseServer = SPHttpUtility.NoEncode(defaultDatabaseInstance.NormalizedDataSource);

        //Build connection string
        string connectionString = "Data Source=" + databaseServer + ";Initial Catalog=Master;User ID=uid;Password=pwd;";

        SqlConnection connection = new SqlConnection();
        connection.ConnectionString = connectionString;
        string sql;

        //Force database to close other connections
        sql = "ALTER DATABASE [" + dbName + "] SET SINGLE_USER WITH ROLLBACK IMMEDIATE";
        SqlCommand command1 = new SqlCommand(sql, connection);
        command1.CommandType = CommandType.Text;

        //Remove database from sql server
        sql = "DROP DATABASE [" + dbName + "]";
        SqlCommand command2 = new SqlCommand(sql, connection);
        command2.CommandType = CommandType.Text;

        using (connection)
        {
            connection.Open();
            command1.ExecuteScalar();
            command2.ExecuteScalar();
        }
    }
}

As with Part 1, the main issue with this implementation is concurrency. You probably shouldn’t be creating and deleting a site collection at the same time. Since the chances in our environment for this are very slim, we’ve never had any issues. Also, since the database is dropped, any changes made since the last backup are lost. It might be a better solution to call a stored procedure during SiteDeleted which backs up the database first, and then sets it to single user and drops it.

Of course for this solution to work, you’ll need to write a feature that adds the eventreceivers to all existing site collections and any new site collections. I haven’t included that code here because it’s pretty straightforward, but if anyone is interested, leave a comment and I’ll see if I can get it posted.

————————————
Update 11/06/2009: I’ve created a Part 3 which includes the feature code.

How to store each SharePoint Site Collection in its own Database – Part 1

We’ve been experiencing mediocre performance with our SharePoint implementation at work. I think it’s just grown too large for our current infrastructure. I felt like I had done everything I could with code optimization and since we had some support hours left over for the year we decided to ask a Microsoft consultant to come by for a few days. Daniel Painter was the consultant that helped us out. While going through our environment and looking at our custom code I had to explain what it did and why it was there. One of the pieces really intrigued him and he asked me to blog about it so here it is.
When I inherited our intranet SPS 2003 and WSS V2 environment in 2005 it had one content database that was 130 GB in size. Backups would take a really long time and we were concerned not only with disaster recovery but with accidental deletion (there wasn’t a recycle bin in SP2003). Microsoft recommended not having any content databases over 50 GB in size. So my strategy was to have each site collection in its own database. This would really help out the backups, we would be within recommendations for database size, and recovery of deleted data would be easier.
I decided that the best way to do this is to have a SharePoint Site Request Form which is an ASP.NET web application that would call custom built web services in SharePoint. The web service would do the following:
  1. Create a database name which is valid and has the url of the site collection it contains as part of the name
  2. Call a stored procedure in the master db on the sql server which does the following
    • Creates a database with data and log files in the correct locations
    • Sets appropriate file growth and other parameters
    • Performs a backup so that the log backup’s won’t fail at night
  3. Set all databases in SharePoint that have available space in them to offline (disabled). A database which is offline (disabled) means that it won’t accept any new site collections in it but SharePoint will still serve any content that is in that database.
  4. Add the newly created database to the web application in SharePoint
    • We set the warning level to 0 and maximum to 1
  5. Create a new site collection. Because our newly created db is the only db that is online and has space in it (1 space) then our newly created site collection will be put in our newly created db.
  6. Set all databases in SharePoint that have available space in them to online
Doing this resolved our issues for any new site collections (migrating the existing site collections into their own databases took some time but was a pretty straightforward, backup, delete, then restore process) as well as had a few more benefits, including now we can easily tell which site collection is in which database because of the database naming. We can also make sure the data and log files are in their correct location (different drives).
When we first implemented this, we had issues when a new site collection was created our backups would fail at night. This was because it was trying to do a log backup first and then the full backup, but since a full backup didn’t exist yet, the backup would fail that night but succeed the next night assuming no new site collections were created. We fixed the backups failing issue by adding to our stored procedure to backup the db and log right after the db was created.
Below is the code or click these links to download: StoredProcedure and WebService
Stored Procedure:
USE [master]
GO

SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO

CREATE PROCEDURE [dbo].[usp_create_sharepoint_database2] (
@databaseName varchar(1000)
)
AS

declare @dataPath varchar(1000)
declare @logPath varchar(1000)
declare @name1 varchar(1000)
declare @name2 varchar(1000)
declare @sql varchar(8000)

set @dataPath = 'd:mssql90data'
set @logPath = 'f:mssql90log'

set @sql = 'create DATABASE ' + @databaseName + '
ON ( NAME = ' + @databaseName + ',
	FILENAME = ''' + @dataPath + @databaseName + '.mdf'',
	SIZE = 3MB, MAXSIZE = UNLIMITED, FILEGROWTH = 250MB )
LOG ON
( NAME = ' + @databaseName + '_log,
   FILENAME = ''' + @logPath + @databaseName + '.ldf'',
   SIZE = 1MB,
   MAXSIZE = UNLIMITED,
   FILEGROWTH = 100MB )
COLLATE Latin1_General_CI_AS_KS_WS'
EXEC (@sql)

select @name1='e:mssql90backup' + @databaseName +  '.BAK', @name2 = @databaseName + '_C'
    BACKUP DATABASE @databaseName TO DISK = @name1 WITH INIT, NOUNLOAD, NAME = @name2, NOSKIP, STATS = 10, NOFORMAT
    select @name1='E:mssql90backup' + @databaseName + '_' + 'LOG' + '.BAK', @name2 = @databaseName + '_L'
    BACKUP LOG @databaseName TO DISK = @name1 WITH INIT, NOUNLOAD, NAME = @name2, NOSKIP, STATS = 10, NOFORMAT
Web Service:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.SharePoint;
using System.Text.RegularExpressions;
using Microsoft.SharePoint.Administration;
using Microsoft.SharePoint.Utilities;
using System.Web;
using System.Data.SqlClient;
using System.Data;
using System.Web.Services;

[WebService(Namespace = "http://tempuri.org/")]
[WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
[System.ComponentModel.ToolboxItem(false)]
// To allow this Web Service to be called from script, using ASP.NET AJAX, uncomment the following line. 
// [System.Web.Script.Services.ScriptService]
public class SiteCreationWebService : WebService
{
    [WebMethod]
    public void CreatePortalSiteCollectionAndDatabase(string basepath, string serverRelativeUrl, string userName, string displayName, string emailAddress, string template, string quota, string title, string description, string portalUrl, string portalName)
    {
        //Make sure web doesn't already exist
        bool webExists = false;
        try
        {
            using (SPSite site = new SPSite(basepath + serverRelativeUrl))
            {
                using (SPWeb web = site.OpenWeb())
                {
                    webExists = web.Exists;
                }
            }
        }
        catch { }
        if (webExists)
        {
            throw new ApplicationException("This Site already Exists");
        }

        //Construct database name to include the url and only use allowed characters
        //Also append some random numbers at the end to pretty much guarantee a unique name
        string WebURL = serverRelativeUrl.Replace("/", "_");
        string databaseName = "wss__" + WebURL + "__" + System.Guid.NewGuid().ToString().Substring(0, 8);
        databaseName = Regex.Replace(databaseName, "[^A-Za-z0-9_]", string.Empty);

        //Make sure the site creation doesn't fail if the template is not specified
        if (string.IsNullOrEmpty(template))
        {
            template = null;
        }

        SPWebApplication webApp = SPWebApplication.Lookup(new Uri(basepath));

        //Take all databases offline if they have space to ensure the created site is put in
        //the database we create
        foreach (SPContentDatabase database in webApp.ContentDatabases)
        {
            if (database.CurrentSiteCount < database.MaximumSiteCount)
            {
                database.Status = SPObjectStatus.Disabled;
                database.Update();
            }
        }

        //Get the default database server's name
        SPWebService service = SPFarm.Local.Services.GetValue();
        SPDatabaseServiceInstance defaultDatabaseInstance = service.DefaultDatabaseInstance;
        string databaseServer = SPHttpUtility.NoEncode(defaultDatabaseInstance.NormalizedDataSource);

        //Build the connection string
        string connectionString = "Data Source=" + databaseServer + ";Initial Catalog=Master;User ID=uid;Password=pwd";

        //Build the sql statement
        SqlConnection connection = new SqlConnection();
        connection.ConnectionString = connectionString;

        SqlCommand command = new SqlCommand("usp_create_sharepoint_database", connection);
        command.CommandType = CommandType.StoredProcedure;
        command.Parameters.AddWithValue("@databaseName", databaseName);

        //Create the database
        using (connection)
        {
            connection.Open();
            command.ExecuteScalar();
        }

        //Add our newly created database to SharePoint
        webApp.ContentDatabases.Add(databaseServer,
                                    databaseName,
                                    webApp.WebService.DefaultDatabaseUsername,
                                    webApp.WebService.DefaultDatabasePassword,
                                    0,
                                    1,
                                    0);

        //HACK:  Needed when creating a site collection from a web service
        HttpContext.Current.Items["FormDigestValidated"] = true;

        //Create site collection
        using (SPSite site = webApp.Sites.Add(basepath + serverRelativeUrl,
                                              title,
                                              description,
                                              1033,
                                              template,
                                              userName,
                                              displayName,
                                              emailAddress))
        {
            //Add portal connection if available
            if (false == string.IsNullOrEmpty(portalName) &&
                false == string.IsNullOrEmpty(portalUrl))
            {
                site.PortalUrl = portalUrl;
                site.PortalName = portalName;
            }

            //Set quota if available
            if (false == string.IsNullOrEmpty(quota))
            {
                SPQuotaTemplateCollection quotas = webApp.WebService.QuotaTemplates;
                site.Quota = quotas[quota];
            }

            //Take databases back online
            foreach (SPContentDatabase database in webApp.ContentDatabases)
            {
                if (database.Status != SPObjectStatus.Online &&
                    database.CurrentSiteCount != 0)
                {
                    database.Status = SPObjectStatus.Online;
                    database.Update();
                }
            }
        }
    }
}

There are some issues with this implementation. The first one is concurrency. You can’t really create two site collections at the exact same time with this method. If you do, then you might have issues where you get a site collection in a wrong database or one site collection creation fails, etc. The other issue is what happens when someone deletes their site collection? That means there will be a database with 1 space available for a site collection but can’t really be used because it’s name is for the deleted site collection. Step #3 and #6 of the web service partly takes care of this. The full solution is in Part 2.

Using your SharePoint site Master Page on all application pages

So I found this article, http://weblogs.asp.net/soever/archive/2006/11/14/SharePoint-2007_3A00_-using-the-masterpage-from-your-site-in-custom-_5F00_layouts-pages.aspx, which explains how to use your SharePoint site’s master page when you create a custom application page in the _layouts virtual directory. At this point in time I don’t really have any custom application pages yet but I liked the idea of when I theme a site that all pages, including application pages in the _layouts virtual directory, use the same master page. By default all application pages use the application.master master page. After finding this article, http://www.odetocode.com/Articles/450.aspx, and a lot of playing around I believe I have found a solution. I created a HttpModule that on a page’s preinit, it checks to see if the page is in the _layouts virtual directory, and if so, it will assign it the master page url from the current sharepoint site. Here’s my code:

using System;
using System.Collections.Generic;
using System.Text;
using System.Web;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Configuration;
using System.Data.SqlClient;
using System.Data;
using Microsoft.SharePoint;

public class ApplicationMasterPage : IHttpModule
{
	public void Init(HttpApplication context)
	{
		context.PreRequestHandlerExecute += new EventHandler(context_PreRequestHandlerExecute);
	}

	void context_PreRequestHandlerExecute(object sender, EventArgs e)
	{
		Page page = HttpContext.Current.CurrentHandler as Page;
		if (page != null)
		{
			page.PreInit += new EventHandler(page_PreInit);
		}
	}

	void page_PreInit(object sender, EventArgs e)
	{
		Page page = sender as Page;
		if (page == null) return;
		if (page.MasterPageFile == null) return;
		HttpContext context = HttpContext.Current;
		string currUrl = context.Request.Url.ToString();
		string basepath = currUrl.Substring(0, currUrl.IndexOf(context.Request.Url.Host) + context.Request.Url.Host.Length);
		//Use RawUrl, otherwise it will always use the root web.
		currUrl = context.Request.RawUrl;
		if (currUrl.ToLower().Contains("/_layouts/") &&
			page.MasterPageFile.ToLower().EndsWith("application.master"))
		{
			currUrl = currUrl.Substring(0, currUrl.ToLower().IndexOf("/_layouts/"));
			SPSite site = null;
			SPWeb web = null;
            try
            {
				site = new SPSite(basepath + currUrl);
				web = site.OpenWeb(currUrl);

				if (String.IsNullOrEmpty(web.MasterUrl) == false)
	            {
					page.MasterPageFile = web.MasterUrl; 
					page.Load += new EventHandler(page_Load);
				}
			}
            finally
            {
                if (web != null)
                {
                    web.Dispose();
                }
                if (site != null)
                {
                    site.Dispose();
                }
            }
		}
	}

	public void Dispose()
	{
	}

	void page_Load(object sender, EventArgs e)
	{
		Page page = sender as Page;
		if (page == null) return;

		try
		{
			ContentPlaceHolder placeHolderTitleBreadcrumb = page.Master.FindControl("PlaceHolderTitleBreadcrumb") as ContentPlaceHolder;
			SiteMapPath contentMap = placeHolderTitleBreadcrumb.FindControl("ContentMap") as SiteMapPath;
			contentMap.CssClass = "ms-hidden";
		}
		catch { }

		try
		{
			ContentPlaceHolder placeHolderSearchArea = page.Master.FindControl("PlaceHolderSearchArea") as ContentPlaceHolder;
			placeHolderSearchArea.Visible = false;
		}
		catch { }
	}

}

Be sure to register the HttpModule in the web.config of your sharepoint site.

This did require me to add extra contentplaceholders into my default.master. Here are the two extra placeholders I had to add:

PlaceHolderPageDescriptionRowAttr
PlaceHolderPageDescriptionRowAttr2

To add these placeholders into my default.master I replaced the following code:

<PlaceHolder id="MSO_ContentDiv" runat="server">
<table id="MSO_ContentTable" width=100% height="100%" border="0" cellspacing="0" cellpadding="0" class="ms-propertysheet">
<tr>
<td class='ms-bodyareaframe' valign="top" height="100%">
<A name="mainContent"></A>
<asp:ContentPlaceHolder id="PlaceHolderPageDescription" runat="server"/>
<asp:ContentPlaceHolder id="PlaceHolderMain" runat="server">
</asp:ContentPlaceHolder>
</td>
</tr>
</table>
</PlaceHolder>

with

<PlaceHolder id="MSO_ContentDiv" runat="server">
<table id="MSO_ContentTable" width=100% height="100%" border="0" cellspacing="0" cellpadding="0" class="ms-propertysheet">
<TR valign="top" <asp:ContentPlaceHolder id="PlaceHolderPageDescriptionRowAttr" runat="server"/> >
<TD class="ms-descriptiontext" width="100%">
<asp:ContentPlaceHolder id="PlaceHolderPageDescription" runat="server"/>
</TD>
</TR>
<TR <asp:ContentPlaceHolder id="PlaceHolderPageDescriptionRowAttr2" runat="server"/>>
<TD ID=onetidMainBodyPadding height="8px"><IMG SRC="/_layouts/images/blank.gif" width=1 height=8 alt=""></TD>
</TR>
<tr>
<td class='ms-bodyareaframe' valign="top" height="100%">
<A name="mainContent"></A>
<asp:ContentPlaceHolder id="PlaceHolderMain" runat="server">
</asp:ContentPlaceHolder>
</td>
</tr>
</table>
</PlaceHolder>

I also had to change my default breadcrumbs code in the default.master to match the application.master. I replaced the following code:

<asp:contentplaceholder id="PlaceHolderTitleBreadcrumb" runat="server">
	<asp:sitemappath id="ContentMap" runat="server" sitemapprovider="SPContentMapProvider" skiplinktext="" nodestyle-cssclass="ms-sitemapdirectional"></asp:sitemappath> &nbsp;
</asp:contentplaceholder>

with

<asp:contentplaceholder id="PlaceHolderTitleBreadcrumb" runat="server">
	<asp:SiteMapPath SiteMapProvider="SPXmlContentMapProvider" SkipLinkText="" NodeStyle-CssClass="ms-sitemapdirectional" runat="server"/>

	<asp:SiteMapPath SiteMapProvider="SPContentMapProvider" id="ContentMap" SkipLinkText="" NodeStyle-CssClass="ms-sitemapdirectional" runat="server"/>
</asp:contentplaceholder>

————————————————————————————————–

Update (02/23/08): I’ve changed my default.master and my httpmodule around a lot since posting this. If you don’t compile a dll you will need to place the ApplicationMasterPage.cs file in a App_Code directory of your root site and in a App_Code directory at C:Program FilesCommon FilesMicrosoft SharedWeb Server Extensions12TEMPLATEIMAGES. You can download my latest files below:

default.master

ApplicationMasterPage.cs

————————————————————————————————–

All of the code and changes ended up with a site settings page that looks like this.

Using Features to enable Drop-Down Menus in Team Sites

I recently was trying to enable the top nav bar to be a drop down menu of all of the sub sites. I found this article http://sharingpoint.blogspot.com/2007/02/wss-v3-drop-down-menus-in-team-sites.html which told me everything I needed to know. Instead of modifying the default.master and the TopNavBar.ascx (for the application.master) to change the SPNavigationProvider to SPSiteMapProvider, I decided I wanted each teamsite to have the option and I’m reluctant from changing SharePoint system files (see http://www.graphicalwonder.com/?p=532), so I put this part into a feature. Here’s my code below:

The first file is Feature.xml

<Feature  Id="22b94164-5348-11dc-8314-0800200c9a66"
          Title="All Sites in Site Collection Navigation"
          Description="Enable all sites in the site collection top navigation bar."
          Version="12.0.0.0"
          Scope="Web"
          Hidden="false"
    DefaultResourceFile="core"
    xmlns="http://schemas.microsoft.com/sharepoint/">
    <ElementManifests>
        <ElementManifest Location="NavigationSiteSettings.xml"/>
    </ElementManifests>
</Feature>

The second file is NavigationSiteSettings.xml

<Elements
    xmlns="http://schemas.microsoft.com/sharepoint/">
    <!-- Top Nav -->
 <Control 
        Sequence="25"
        Id="TopNavigationDataSource"
  ControlClass="System.Web.UI.WebControls.SiteMapDataSource"
  ControlAssembly="System.Web, version=2.0.3600.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
        <Property Name="ID">topSiteMap</Property>        
        <Property Name="SiteMapProvider">SPSiteMapProvider</Property>
        <Property Name="ShowStartingNode">true</Property>
    </Control>
    <HideCustomAction
        Id="TopNav"
        HideActionId="TopNav"
        GroupId="Customization"
        Location="Microsoft.SharePoint.SiteSettings" />
</Elements>

I still needed to modify the default.master and TopNavBar.ascx (you don’t need to edit the TopNavBar.ascx if you are implementing my Using your SharePoint site Master Page on all application pages) so that more than one level is shown. I haven’t figured out a way around that part yet. Does anyone else know a way to override the SharePoint.AspMenu control?

————————————————————————————————————
Update (09/21/07): I have since found out that apparently the SPSiteMapProvider relies on the user having permissions to view the top-level site in the site collection. If the user does not have access to this top-level site, then they will be prompted for authentication and won’t be able to access the site. Using Reflector I was able to find the code where they are finding the top-level site without checking for permissions first.

I ended up having to write my own SiteMapProvider that did the correct security checking and trimming while still allowing access to the site.

On a side note, while I was looking into this issue, I also noticed that the Global Breadcrumbs (which also uses the SPSiteMapProvider) doesn’t do security trimming. So I wrote a SiteMapProvider for the Global Breadcrumbs as well that did security trimming. There doesn’t seem to be a delegate control for the Global Breadcrumbs like there is for the top nav so I couldn’t write a feature to override the default. So I decided to just change my default.master and in combination with Using your SharePoint site Master Page on all application pages will use my custom SiteMapProvider for the Global Breadcrumbs.

————————————————————————————————————
Update (05/19/08): Some people have asked for the source code of my custom site map providers. You can download my latest files below:

AllSitesInSiteCollectionNavTrimmed.cs

AllSitesBreadcrumbsTrimmed.cs

FYI, this may not be the most efficient code and I have noticed some caching issues with it sometimes. Other than that it seems to work great.