Overriding the location for App_Themes

Friday, January 20, 2006 3:54:42 PM
Rate this Content 2 Votes

With a little help from Scott Guthrie, David Ebbo and David Neal I finally got a working implementation to override the location of the App_Themes folder in ASP.NET 2.0!

They each helped point me on the right track and with a little experimentation success was achieved at last.

What I found was themes really want to come from a folder beneath  the App_Themes folder and you can't tell it to look for them in a different folder so you have to fool it into thinking it is getting the theme files from a folder in the App_Themes folder but really get them from your custom location.

I am transitioning my mojoPortal web site framework project from the 1.1 to the 2.0 ASP.NET runtime and trying to adapt the ASP.NET theme to be just one part of a mojoPortal "skin", which also includes a MasterPage, a CSS stylesheet, and any supporting images. mojoPortal supports hosting multiple sites on one web installation and one database so I want to keep "skins" independent between sites and store them in a site specific location like ~/Data/Sites[siteid]/skins/skinname/
Its no problem to store MasterPages, stylesheets, and images anywhere you like but Themes are a bit stubborn to relocate.

At first I emailed Scott Guthrie asking him about how to tackle this problem and he pulled in David Ebbo who pointed out his blog post about implementing a VirtualPathProvider

Here is the process I went through and how I finally fooled it into working.

After looking into inheriting from VirtualPathProvider I got the idea that I could override the directory for the App_Themes folder by implementing
public override VirtualDirectory GetDirectory(string virtualDir) using code like this:

if (virtualDir.Contains("App_Themes"))
{
      SiteSettings siteSettings = SiteSettings.GetCurrent();
      if (siteSettings != null)
      {
          return base.GetDirectory(
              virtualDir.Replace("App_Themes", "Data/Sites/"
              + siteSettings.SiteID.ToString()
              + "/skins/"));
      }
}
return base.GetDirectory(virtualDir);

That would have been much too easy of course but it is checking that the directory we are feeding it is the App_Themes directory and it knows if we re-route to a different directory by overriding this method and it won't let us do that.

So I was off the tracks until I got to work the next day and told David Neal about my difficulties. He said the implementations he has seen of VirtualPathProvider override at the point where it returns a file stream. This got me looking at the VirtualPathProvider public static Stream OpenFile(string virtualPath), but it did not support an override so I tried implementing it by hiding the base class method but setting a breakpoint, my code was never called so that didn't work either.

Then I got to looking at the VirtualPathProvider public override VirtualFile GetFile(string virtualPath) method. I found that by implementing a custom VirtualFile and returning that from the VirtualPathProvider I could make it load the theme.skin file from my special folder.

As it turns out, you have to create a folder under the App_Themes folder and put a default theme in there. We will not actually use this theme but it has to be there because we are trying to fool it into thinking it is using this theme and it is not so easily fooled.  It checks that the Theme we are fooling it to believe it is using exists. We have to fake it out at the last minute and feed it different files not folders. I created a folder named default and put a file in there named theme.skin and a file named theme.css

Here is the code for this method:
public override VirtualFile GetFile(string virtualPath)
{
     if (virtualPath.Contains("App_Themes/default"))
     {
         return new mojoThemeVirtualFile(virtualPath);
     }
     return base.GetFile(virtualPath);
}

mojoThemeVirtualFile.cs looks like this:

using System;
using System.Collections.Generic;
using System.Text;
using System.Web;
using System.Web.Util;
using System.Web.Hosting;
using System.IO;
using mojoPortal.Business;

namespace mojoPortal.Web
{
  
    public class mojoThemeVirtualFile : VirtualFile
    {
        private String pathToFile;
        public mojoThemeVirtualFile(String virtualPath)
            : base(virtualPath)
        {
            pathToFile = virtualPath;
         
        }

        public override Stream Open()
        {
            SiteSettings siteSettings = SiteSettings.GetCurrent();
            if (siteSettings != null)
            {

                pathToFile = pathToFile.Replace("App_Themes/default", "Data/Sites/"
                   + siteSettings.SiteID.ToString()
                   + "/skins/" + siteSettings.Skin);

            }
            SiteUtils.ResetThemeCache();
            String filePath = HttpContext.Current.Server.MapPath(pathToFile);
            return File.Open(filePath, FileMode.Open);
          
        }

    }

}

As with MasterPages, the Theme for a page can only be set in the OnPreInit event, so my page overrides that method like this:

override protected void OnPreInit(EventArgs e)
{

    base.OnPreInit(e);
    if (!this.DesignMode)
    {
        if (HttpContext.Current != null)
        {
             siteSettings = (SiteSettings)HttpContext.Current.Items["SiteSettings"];
             SiteUtils.SetMasterPage(this, siteSettings);
             this.Theme = "default";
    
         }
    }

}

The SetMasterPage function is setting it to a layout.Master file stored in ~/Data/Sites[siteid]/skins/skinname folder
The Theme is always being set to default because we are trying to fool it into thinking it is using that Theme, and that Theme does exists to help the masquerade, but our VirtualFile is actually opening it from the location of our choosing based on SiteSettings and that was the goal we are trying to achieve.

Now a mojoPortal skin can contain all its files in a single site specific folder and consists of:
layout.Master
theme.skin
style.css
and any supporting image or resource files.

this is just what I was trying to achieve!

Another point worth mentioning is that once it loads the theme it is cached, so I needed a way to invalidate the cache if the the Theme setting stored in my SiteSettings object was changed. In the page where I save SiteSettings I added a few lines after saving to reset the cache like this:

String oldSkin = ViewState["skin"].ToString();
if (oldSkin != siteSettings.Skin)
{
SiteUtils.ResetThemeCache();
}

When the page fist loads I store the current skin name in ViewState then on save I check if it is different than before and if so reset the cache by updating a text file in the file system that I set as the CacheDependency in the VirtualPathProvider:

public override System.Web.Caching.CacheDependency GetCacheDependency(string virtualPath, System.Collections.IEnumerable virtualPathDependencies, DateTime utcStart)
{
    if (virtualPath.Contains("App_Themes/default"))
    {
    String pathToDependencyFile = SiteUtils.GetPathToThemeCacheDependencyFile();
    if(pathToDependencyFile != null)
    {
        return new CacheDependency(pathToDependencyFile);
    }
    }
    return base.GetCacheDependency(virtualPath, virtualPathDependencies, utcStart);
}

All my SiteUtils.ResetThemeCache(); function does is set the modified time of the file to the current time which invalidates the cache.

public static void ResetThemeCache()
{
    String pathToCacheDependencyFile = GetPathToThemeCacheDependencyFile();
    if (pathToCacheDependencyFile != null)
    {
    if (File.Exists(pathToCacheDependencyFile))
    {
        File.SetLastWriteTimeUtc(pathToCacheDependencyFile, DateTime.Now);
    }
    else
    {
         StreamWriter streamWriter = File.CreateText(pathToCacheDependencyFile);
         streamWriter.Close();
    }

    }

}

public static String GetPathToThemeCacheDependencyFile()
{
    string pathToCacheDependencyFile = null;

    if (HttpContext.Current != null)
    {
    SiteSettings siteSettings = SiteSettings.GetCurrent();
    pathToCacheDependencyFile = HttpContext.Current.Server.MapPath(
        "~/Data/Sites/" + siteSettings.SiteID.ToString() + "/themecachedependecy.config");

    }

    return pathToCacheDependencyFile;
}

If you ever have a need to get your Themes from an alternate location, hope this post helps.

Copyright 2003-2010 Joe Audette
Share This Using Popular Bookmarking Services

re: Overriding the location for App_Themes

Friday, January 20, 2006 6:05:08 PM David
Awesome!  Glad to hear you got it working.

re: Overriding the location for App_Themes

Friday, January 20, 2006 6:26:51 PM Joe
Thank you for making me re-think my strategy after my initial defeat!

re: Overriding the location for App_Themes

Sunday, January 22, 2006 6:11:44 PM Keith J. Farmer

I encountered this problem during beta-testing, and posted a feedback about it.  I'd hoped they would back such a fix in, but it didn't make the cut.  Perhaps, I hope, in Orcas.

There are various reasons you'd want to do this -- one is for multiple sites (for example, dealing with corporate branding).  Also, resources cannot be set per-theme, and so this technique may be useful there as well.

Perhaps, now that I've completed my first week at Microsoft (working on DLinq), I can try pushing internally.  Believe me -- I want some of these things as much as you. ;)

 

re: Overriding the location for App_Themes

Wednesday, March 01, 2006 3:52:12 PM jonathan minond
I am going to test this out tommorow... if it works... you just solved a huge task I was trying to accomplish :-)  Thanks.

re: Overriding the location for App_Themes

Thursday, March 02, 2006 2:36:51 PM Joe Audette
Great, hope it helps! Let me know how it goes.

Cheers,

Joe

re: Overriding the location for App_Themes

Friday, March 03, 2006 11:26:00 AM Jonathan Minond

The restriction here is that your ""~/Data/Sites/" has to be part of the app, it cant be a seperate app, called, "data", correct?

 

re: Overriding the location for App_Themes

Friday, March 03, 2006 11:40:14 AM Joe Audette
I'm not sure about crossing app boundaries. That wasn't one of my needs. For me I want the theme.skin file to be within my app in a writable folder accessible to site admins because eventually I want to create a browser interface so users can create skins in the browser and save them on the server. I plan to create an editor for creting master pages stlyesheets and theme files in the browser and/or uploading them as a .zip and having it automatically unzip into the correct location.

The idea for me is not to create a repository of skins available to multiple sites but keep them private to sites even when multiple sites are hosted from the same web folder installation of mojoportal. If I'm hosting multiple sites out of one install I will have a core set of skins that get installed by default into each site but when a site user creates their own custom skin I don't want it available to the other sites.

That said, as long as you can get a file stream I think you could get it to work form any location from which you can get a stream.

if you get the mojoportal source code of the 2.1 branch from svn and look in web\components\mojoThemeVirtualFile.cs that is where it returns a file stream for the theme files. And I I think it could get the stream form other places thats just where I am keeping them. Its the VirtualFile not the VirtualPathProvider that does the magic by returning the file stream object. The virtualPathProvider just passes the request for the file stream to the VirtualFile depending on the path of the requested file.

I think it could work but I've been wrong before ;)

Joe

re: Overriding the location for App_Themes

Tuesday, March 28, 2006 8:50:06 AM Jonathan Minond

Themes work from anywhere, the restriction is specifically for master pages, you NEED a master file somewhere in your "applcaition" space at design and runtime.

This is where junctions come to save you.

In anycase, I agree it would not be needed for waht you are doing where its all in one app. ( We will do this for Rainbow too ;-) ).

In any case, if you ever get to where you need cross app sahring of masters and controsl (themes you have a global folder in the aspnetclient system.web folder. Then NTFS junction will be your solution, in UNIX(mono) you can use something called HardLinks I believe or something liek that.

Here is what I wrote about it: http://community.rainbowportal.net/blogs/jonathans_rainbow_blog/archive/2006/03/15/1518.aspx

 

 

re: Overriding the location for App_Themes

Tuesday, March 28, 2006 8:54:54 AM Joe Audette
Interesting post! Good to know about that technique.

Cheers,

Joe

re: Overriding the location for App_Themes

Wednesday, May 17, 2006 6:08:14 AM Daz
I wondered why you didn't just use the virtualization within iis6 to do this?  Am I missing something?  Did you want to do it on the fly?

re: Overriding the location for App_Themes

Wednesday, May 17, 2006 7:46:07 AM Joe Audette
Using a virtual directory in IIS would allow storing them in a different folder than the deault App_Themes but not in a different folder per site when multiple sites are running from one IIS site.

I can have any number of sites running from a single installation and when a new site is created the default skins go under ~/Data/Sites/[SiteID]/skins and any skins in that folder are now available to the site but custom skins created for other sites are not available. This is my desired outcome, skins and themes are site specific and not shared accross sites.

Also my project is designed to run on IIS or apache/mono so my solution is not to use any IIS feature to accomplish the goal

Cheers,

Joe

re: Overriding the location for App_Themes

Thursday, August 17, 2006 12:50:17 PM Juan Pablo

Joe, i'm trying to use the same VirtualPathProvider and VirtualFile Classes with some modifications for my special case.

My Question & Scenario:

My problem is that i can't catch the Stylesheet.css when i excecuting my WebApp with the IIS 6.0 but when i run with the VS 2005 built-in WebServer all it's OK and i see the styles correctly

Another strange behavior is that with two WebServers (IIS6 and VS) i can catch the SkinFile.skin without problems.

I have seen that in your excelent mojoPortal you have a ASCX control that put the StyleSheet, this is for the same reason of my case?

And sorry for my poor english, your project is Amazing!

Thanks!

re: Overriding the location for App_Themes

Friday, September 01, 2006 12:30:38 AM neo

if (virtualPath.Contains("App_Themes/default"))
     {
         return new mojoThemeVirtualFile(virtualPath);
     }

My code never run into the if block, and I have App_Themes/default folder

Does any one know the reason?

re: Overriding the location for App_Themes

Friday, September 01, 2006 5:20:32 AM Joe Audette
Hi,

One thing to be aware of is that once the Virtual file is requested it is cached by the runtime and not requested again until the cache expires. Its seems to me that this cache even survives re-compiles so you have to moduify the web.config or a dependency file to clear it.

Another thing to be aware of when working with VirtualPaths and VirtualFiles is that if you are testing under the VS web server you will get different behavior than what you get under IIS because in the VS Web Server all file requests are handled byt the asp.net runtime whereas in IIS theme.skin file is handled but not .css and not image files .gif, .png etc

So unless you do some additional IIS configuration you can't use VirtualPathProvider and VirtualFile for anything related to theme except for .skin files. If it requests App_Themse/default/theme.skin you can return the file stream from anywhere using the VirtualFile but it will still put a link to App_Themes/default/theme.css so what you want to do is have an empty css file there and add your true css to the page another way. I use a user control names StyleSheet.ascx in my master page and it knows where the css for the active theme/skin is located. I only use the VPP stuff for theme.skin file. In order to make my code work the same in VS Web Server as in IIS I have added code to explicitly not handle .css and various image files. Below is my updated code, hope it helps.

Joe

using System;
using System.Web;
using System.Web.Caching;
using System.Web.Util;
using System.Web.Hosting;
using System.IO;
using mojoPortal.Business;

namespace mojoPortal.Web
{
    /// <summary>
    /// Author:             Joe Audette
    /// Created:            1/17/2005
    /// Last Modified:    1/21/2005
    ///
    ///
    /// </summary>
    public class mojoVirtualPathProvider : VirtualPathProvider
    {
      
        /// <summary>
        ///  AppInitialize() is not used in mojoportal. It would be called on startup
        ///  if this file was in the App_Code folder but who wants a folder with a generic
        ///  name like App_code and besides we can just as easily register our
        ///  VirtualPathProvider in the Global.asax.cs Application_OnStart as we do
        ///
        /// </summary>
        public static void AppInitialize()
        {
            HostingEnvironment.RegisterVirtualPathProvider(new mojoVirtualPathProvider());
        }

        public override string CombineVirtualPaths(string basePath, string relativePath)
        {
          
            return base.CombineVirtualPaths(basePath, relativePath);
         

        }

        public override VirtualDirectory GetDirectory(string virtualDir)
        {
           
            return base.GetDirectory(virtualDir);
        }

        public override VirtualFile GetFile(string virtualPath)
        {
            // don't handle requests for css, its not needed
            // under IIS requests for .css are not handled by
            // the asp.net runtime anyway but under the VS web server
            // all files are so this is to prevent side effects of that
            // our StyleSheet user control correctly handles css
            // so we don't need to re-map files here
            String loweredPath = virtualPath.ToLower();
            if (
                (virtualPath.Contains("App_Themes/default"))
                && (!loweredPath.EndsWith(".css"))
                && (!loweredPath.EndsWith(".gif"))
                && (!loweredPath.EndsWith(".jpg"))
                && (!loweredPath.EndsWith(".png"))
                && (!loweredPath.EndsWith(".js"))
                && (!loweredPath.EndsWith(".axd"))
                )
            {
                return new mojoThemeVirtualFile(virtualPath);
               
            }
            return base.GetFile(virtualPath);
        }

        public override System.Web.Caching.CacheDependency GetCacheDependency(string virtualPath, System.Collections.IEnumerable virtualPathDependencies, DateTime utcStart)
        {
            if (virtualPath.Contains("App_Themes/default"))
            {
                String pathToDependencyFile = SiteUtils.GetPathToThemeCacheDependencyFile();
                String pathToThemeFile = SiteUtils.GetFullPathToThemeFile();
                if(pathToDependencyFile != null)
                {
                    AggregateCacheDependency dependency = new AggregateCacheDependency();
                    dependency.Add(new CacheDependency(pathToDependencyFile));
                    dependency.Add(new CacheDependency(pathToThemeFile));
                    return dependency;

                }
            }


            return base.GetCacheDependency(virtualPath, virtualPathDependencies, utcStart);
        }

    }
}

using System;
using System.Collections.Generic;
using System.Text;
using System.Web;
using System.Web.Util;
using System.Web.Hosting;
using System.IO;
using mojoPortal.Business;

namespace mojoPortal.Web
{
    /// <summary>
    /// Author:              Joe Audette
    /// Created:            1/19/2006
    /// Last Modified:    4/16/2006
    ///
    /// </summary>
    public class mojoThemeVirtualFile : VirtualFile
    {
        private String pathToFile;
        public mojoThemeVirtualFile(String virtualPath)
            : base(virtualPath)
        {
            pathToFile = virtualPath;
         
        }

        public override Stream Open()
        {
            SiteSettings siteSettings = SiteUtils.GetCurrentSiteSettings();
            if (siteSettings != null)
            {

                pathToFile = pathToFile.Replace("App_Themes/default", "Data/Sites/"
                   + siteSettings.SiteID.ToString()
                   + "/skins/" + siteSettings.Skin);

            }
            SiteUtils.ResetThemeCache();
            String filePath = HttpContext.Current.Server.MapPath(pathToFile);
           
            return File.Open(filePath, FileMode.Open);
          
        }

    }

}

re: Overriding the location for App_Themes

Tuesday, September 12, 2006 3:29:31 PM Ember

Hi Joe,

You mention: "Its no problem to store MasterPages, stylesheets, and images anywhere you like but Themes are a bit stubborn to relocate. "

I'm having some problems using master pages across applications & with precompiling.  I've got it working without compiling in regular 2.0 & WAP using virtual directories.  I want the master pages to be in a central location accessible by any content page.  I'd also like to have reproducible names for source control.

Do you have any tips for this?  If you have a blog post on this, please let me know--I couldn't find one.

Thanks!
Ember

re: Overriding the location for App_Themes

Wednesday, September 13, 2006 4:37:26 AM Joe Audette
Hi Ember,

Sorry, I was referring to "anywhere you like" withing the web, I didn't mean external to the application. So in my case I'm not using VirtualPathProvider for Master Pages, only for themes.

I would think that using VPP would be the way to make external MasterPages available. I'm not sure about the pre-compilation issue. What I would try is having a class library accessible to all the different apps and have the code behind (name.Master.cs and name.Master.designer.cs) for the MasterPage in there. Then only have the .Master files served from the external path using VPP and make them all inherit from the common code behind file.

Not positive that will work but thats what I would try.

Hope it helps,

Joe

re: Overriding the location for App_Themes

Wednesday, November 15, 2006 7:19:34 AM unknown

re: Overriding the location for App_Themes

Thursday, February 15, 2007 5:43:50 AM Mark Walters

Hi,

I'm using a virtualpathprovider to deliver aspx pages from a database. I've set no cache dependency so I cannot expire the cache easily - in fact I don't know what expires the cache at all or where this can be done. I've tried:

1) recycling the app pool that I've place the website under

2) restarting IIS

3) changing the webconfig file

4) recompiling the website

5) setting content expiration to immediate for all content under the website

None of these things seems to have an effect!

Any help appreciated!

Ta,

Mark.

re: Overriding the location for App_Themes

Thursday, March 08, 2007 3:16:22 PM Piyush Shah
Try deleting the Temporary ASP.Net files, I believe thats where it caches it.
Comments are closed on this post.
Donate Money to support the mojoPortal Project. View Joe Audette's profile on LinkedIn View Joe Audette's profile on The Guild of Accessible Web Designers site