In my last post - Bulk Importing Content into a SharePoint Publishing Site - I described a technique that we used to programmatically import content into a SharePoint publishing site.
There are some great things about this approach, most importantly:
- Your content becomes part of your source control
- You don't have to create the site content by hand
- You can import the content as many times as you need to
The site we are working on relies heavily on SharePoint Reusable Content; content that is stored and authored in one location, but displayed in several. We decided to extend our bulk import process to accommodate Reusable Content.
Storing Reusable Content in Xml Files
Just as we did with normal publishing content, we store the site's Reusable Content in XML files. Since Reusable Content is simply just another list in a SharePoint publishing site, we can provide values for the various columns in the list, e.g. Title and Reusable HTML.
<?xml version="1.0" encoding="utf-8" ?>
<Items>
<ReusableContent>
<Columns>
<Column Name="Title">Sample Reusable Content</Column>
<Column Name="ID">1</Column>
<Column Name="ContentCategory">General</Column>
<Column Name="AutomaticUpdate">1</Column>
<Column Name="Reusable HTML">
<![CDATA[
<p>reusable content HTML</p>
]]>
</Column>
<Column Name="Comments">
<![CDATA[
]]>
</Column>
</Columns>
</ReusableContent>
</Items>
Note that one not-so-elegant step we had to take was to provide IDs to the List items ourselves. The only negative of this approach is that before importing Reusable Content again, we would have to clear out the existing items from the list.
Importing the Reusable Content
var query = from xElem in xmlFile.Descendants("ReusableContent") select new Reusable
{ Columns = (from column in xElem.Descendants("Column") select new Column
{ Name = column.Attribute("Name").Value, Value = column.Value
}).ToList()
};
SPSite siteCollection = null;
SPWeb site = null;
string siteURL = ConfigurationSettings.AppSettings[CONFIG_SITEURL];
using (siteCollection = new SPSite(siteURL))
{ using (site = siteCollection.RootWeb)
{
foreach (var reusable in query)
{
try
{ Console.WriteLine("Adding new reusable content to list..."); SPListItem listItem = null;
listItem = site.Lists["Reusable Content"].Items.Add();
Console.WriteLine("Processing reusable content columns...\n"); foreach (Column siteColumn in reusable.Columns)
listItem[siteColumn.Name] = siteColumn.Value;
Console.WriteLine("Completing item workflow..."); //update item to complete process
listItem.Update();
}
catch (Exception ex)
{ throw (ex);
}
}
}
Same idea here ... We query an XML file containing our Reusable Content using some simple LINQ. We then iterate through the result set and add the ListItems one by one to the Reusable Content list.
Referencing the Reusable Content in our Import Files
The rest of the site content still lives in our XML import files, and some of that content now needs to be updated to reference a specific item of Reusable Content.
Again, one thing to point out here is that we're sort of hardcoding the ID of the item. Good enough for our quick and dirty console application that we use to populate the content in our site.
Check out the previous post for more information on these import files, but we now directly reference the Reusable Content item in the columns that will use it. (Guids trimmed for readability)
<Page
Name="Sample.aspx"
Default="false"
ContentTypeId="some really long content type id ..."
PageLayout="SampleLayout.aspx">
<Columns>
<Column Name="Title" Id="">Some Title</Column>
<Column Name="ColumnUsingReusableContent" Id="">
<![CDATA[
<div id="__publishingReusableFragmentIdSection">
<a href="/ReusableContent/1_.000">a</a>
</div>
<span id="__publishingReusableFragment"></span>
]]>
</Column>
</Columns>
</Page>
Now go reuse some content!
A little while ago, I posted a question on StackOverflow looking for suggestions on how best to migrate content into a SharePoint publishing site. I got a quite a few good suggestions, but none of them felt like a great fit for our needs.
As you may have realized from my last few posts, I'm working on a project to migrate an existing web site to SharePoint. A very important thing for me was to have all the site content in source control, allowing us to be able to repeatably deploy it into SharePoint during development and testing.
Additionally, we were working with a topology where all authoring would be happening in an Authoring environment behind the firewall, with scheduled Content Deployment jobs responsible for migrating the content to the Production environment. We had to be able to initially populate the content into the Authoring environment so that the content authors (or us) didn't have to do it manually.
We came up with what I think is a pretty original approach to this. The general idea is that since this is a SharePoint Publishing site, all of our content obviously corresponds to a certain content type (inheriting from the Page content type), and is displayed using a specific Page Layout.
Content Type Import Template
What this enables us to do is to define our site content in Xml - making it part of our source code. We can use a simple application to deploy it to a specific site collection.
<?xml version="1.0" encoding="utf-8" ?>
<Pages SiteUrl="/">
<Page
Name="AboutUs.aspx"
Default = "false"
ContentTypeId=""
PageLayout="GeneralContent.aspx">
<Columns>
<Column Name="Title" Id="{fa564e0f-0c70-4ab9-b863-0177e6ddd247}">About Us</Column> <Column Name="Page Content" Id="{f55c4d88-1f2e-4ad9-aaa8-819af4ee7ee8}"> <![CDATA[
<p>Some Html</p>
]]>
</Column>
<Column Name="Meta_Keyword" Id="{74dcba16-0809-4bca-87d5-b0fc67d2086f}"></Column> <Column Name="Meta_Description" Id="{6b1216bb-3023-462d-af6f-31878e9808bb}"></Column> </Columns>
</Page>
</Pages>
Page Element
What's happening here is actually pretty simple; our Xml file contains a Page element for every Publishing page that we need to import into the SPSite at the url specified by the SiteUrl property.
There are several attributes of the Page element:
- Name - the name of the page when it's created in the Pages document library
- Default - whether or not this is the Welcome Page of the specific site
- ContentTypeId - the id of the content type that describes this content (I omitted it from the snippet above because content type ids are so damn long)
- PageLayout - the page layout to use when creating this page
Columns
Each Page also contains a collection of columns corresponding to the columns in the content type. As I mentioned, all of our content types inherited from the Page content type, allowing us to use its columns such as Title and Page Content.
For each column, we provide the Name and the Id. We went with this approach for two reasons, the first being readability of our import files. The second being that when creating these pages programmatically, we felt that it was safer to reference these columns by their Id.
Dealing with HTML Content
The Page Content column contains Publishing HTML, we can specify this in our Xml import files by wrapping the HTML content in a <![CDATA[ ]]> block. This is great because we can paste our HTML in here without worrying about the Xml validation.
Importing the Content
Before we look at the code to import this content, let's define a couple of classes that will help us:
public class Page
{ public string Name { get; set; } public string ContentTypeId { get; set; } public string PageLayout { get; set; } public List<Column> Columns { get; set; } public bool Default { get; set; }}
public class Column
{ public string Name { get; set; } public Guid Id { get; set; } public string Value { get; set; }}
After specifying a particular Xml file to import content from, we use some LINQ to get the set of Pages:
var query = from xElem in xmlFile.Descendants("Page") select new Page
{ Name = xElem.Attribute("Name").Value, ContentTypeId = xElem.Attribute("ContentTypeId").Value, PageLayout = xElem.Attribute("PageLayout").Value, Default = Boolean.Parse(xElem.Attribute("Default").Value), Columns = (from column in xElem.Descendants("Column") select new Column
{ Name = column.Attribute("Name").Value, Id = new Guid(column.Attribute("Id").Value), Value = column.Value
}).ToList()
};
This now becomes a matter of iterating through each Page element and programmatically creating and publishing the Page:
SPSite siteCollection = null;
SPWeb site = null;
PublishingSite publishingSite = null;
PublishingWeb publishingWeb = null;
foreach (var page in query)
{
using (siteCollection = new SPSite(siteURL))
{ using (site = siteCollection.AllWebs[siteName])
{ publishingSite = new PublishingSite(siteCollection);
publishingWeb = PublishingWeb.GetPublishingWeb(site);
List<PublishingPage> pages =
publishingWeb.GetPublishingPages().ToList();
PublishingPage thePage = pages.Find(
delegate(PublishingPage pp)
{ return pp.Name == page.Name; });
if (thePage != null)
{ // Page exists
thePage.CheckOut();
}
//get ContentTypeID
SPContentTypeId contentTypeId =
new SPContentTypeId(page.ContentTypeId);
//get PageLayout and Page Name
List<PageLayout> layouts =
publishingWeb.GetAvailablePageLayouts(contentTypeId).ToList();
PageLayout layout = layouts.Find(
delegate(PageLayout pl)
{ return pl.Name == page.PageLayout; }); string pageName = page.Name;
PublishingPage pubPage = null;
if (thePage != null)
pubPage = thePage;
else
pubPage = publishingWeb.GetPublishingPages().Add(pageName, layout);
foreach (Column siteColumn in page.Columns)
pubPage.ListItem[siteColumn.Id] = siteColumn.Value;
pubPage.Update();
//publish and approve
SPListItem newPage = pubPage.ListItem;
newPage.File.CheckIn("Checked in on creation"); newPage.File.Publish("Published on creation"); }
}
}
Conclusion
There are a bunch of things we can improve on here, but we just needed a quick and dirty tool that provided us with a repeatable way of creating our site content. We can now fully populate our Authoring environment and let the Content Deployment job take care of the rest.
UPDATE (03/08/2009): See updated post meant to handle an issue where entire page content wasn't being transmitted over SSL.
I'm currently working on a .com SharePoint site where access to several sub-sites is restricted to logged in users. The team also implemented custom login and registration pages for these sites as layouts pages.
We have a requirement that these sub-sites and the layouts pages both use SSL.
I came across two very helpful blog posts that addressed each scenario individually:
With very little tweaking, each of these handles the particular situation it was designed for very well. However, I basically needed a combination of these two approaches.
If I tried enabling both HttpModules on my SharePoint web application, one scenario would work, but not the other. So I decided to combine both approaches into a single HttpModule:
1: public class SSLHttpModule : IHttpModule
2: { 3: private HttpApplication _application;
4:
5: private String _baseURLNoSSL;
6: private String _baseURLWithSSL;
7: private String _sslPagesList;
8:
9: private List<String> _sslPages = new List<string>();
10:
11: const String BASEURL_NOSSL = "BaseUrlNoSSL";
12: const String BASEURL_SSL = "BaseUrlSSL";
13: const String SITE_REQUIRES_SSL_PROPERTY = "requiressl";
14: const String SSL_LAYOUTS_PAGES = "SSLLayoutsPages";
1:
2: public void Init(HttpApplication application)
3: { 4: if (ConfigurationManager.AppSettings[BASEURL_NOSSL] != null)
5: _baseURLNoSSL = ConfigurationManager.AppSettings[BASEURL_NOSSL];
6: else
7: return;
8: if (ConfigurationManager.AppSettings[BASEURL_SSL] != null)
9: _baseURLWithSSL = ConfigurationManager.AppSettings[BASEURL_SSL];
10: else
11: return;
12: if (ConfigurationManager.AppSettings[SSL_LAYOUTS_PAGES] != null)
13: _sslPagesList = ConfigurationManager.AppSettings[SSL_LAYOUTS_PAGES];
14: else
15: return;
16:
17: // Create a List<String> of Layouts pages requiring SSL
18: //
19: char[] separator; separator = new char[] { ',' }; 20: string[] pages; pages = _sslPagesList.Split(separator);
21: _sslPages.AddRange(pages);
22:
23: application.PreRequestHandlerExecute += new EventHandler(PreRequest);
24: _application = application;
25: }
There are three application settings (that live in web.config) that make the whole thing work:
- BaseUrlNoSSL - the url of your site when you're not using SSL
- BaseUrlSSL - the url of your site when you're using SSL
- I use an Alternate Access Mapping to do this
- SSLLayoutsPages - a comma-separated list of layouts pages which should be secured by SSL
- Nothing's stopping you from modifying this code to make it work with any page, not just layouts pages.
The following listing shows the PreRequest event of the HttpModule. What I'm doing differently here is handling the case for layouts pages first, and then checking if the SPWeb needs SSL.
1: public void PreRequest(object sender, EventArgs e)
2: { 3: HttpContext ctx = null;
4: SPContext spContext = null;
5:
6: try
7: { 8: ctx = HttpContext.Current;
9: spContext = SPContext.Current;
10: if (spContext != null)
11: { 12: if (spContext.Web != null)
13: { 14: string pathAndQuery = ctx.Request.Url.PathAndQuery;
15:
16: if (pathAndQuery.ToLower().Contains("_layouts")) 17: { 18: bool pageRequiresSSL = false;
19:
20: foreach (var page in _sslPages)
21: { 22: if (pathAndQuery.ToLower().Contains(page.ToLower()))
23: { 24: pageRequiresSSL = true;
25: break;
26: }
27: }
28:
29: if (pageRequiresSSL)
30: { 31: if (HttpContext.Current.Request.Url.Scheme.ToString() == "http")
32: { 33: string url = HttpContext.Current.Request.Url.ToString();
34: url = url.Replace("http://", "https://"); 35: HttpContext.Current.Response.Redirect(url);
36: }
37: }
38: else
39: { 40: if (HttpContext.Current.Request.Url.Scheme.ToString() == "https")
41: { 42: string url = HttpContext.Current.Request.Url.ToString();
43: url = url.Replace("https://", "http://"); 44: HttpContext.Current.Response.Redirect(url);
45: }
46: }
47: }
48: else
49: { 50: bool siteRequiresSSL = false;
51: if (spContext.Web.Properties.ContainsKey(SITE_REQUIRES_SSL_PROPERTY))
52: { 53: siteRequiresSSL = (spContext.Web.Properties
54: [SITE_REQUIRES_SSL_PROPERTY].ToString()).ToLower()
55: == Boolean.TrueString.ToLower();
56: }
57:
58: if (siteRequiresSSL & !ctx.Request.IsSecureConnection)
59: { 60: if (_baseURLWithSSL != null)
61: ctx.Response.Redirect(_baseURLWithSSL + pathAndQuery);
62: else
63: ctx.Response.Redirect(String.Concat("https://", 64: ctx.Request.Url.Host, pathAndQuery));
65: return;
66: }
67: if (!siteRequiresSSL & ctx.Request.IsSecureConnection)
68: { 69: if (_baseURLNoSSL != null)
70: ctx.Response.Redirect(_baseURLNoSSL + pathAndQuery);
71: else
72: ctx.Response.Redirect(String.Concat("http://", 73: ctx.Request.Url.Host, pathAndQuery));
74: return;
75: }
76: }
77: }
78: }
79:
80: }
81: catch (Exception) { } 82: }
Some notes about this:
- I provisioned the RequireSSL property of each SPWeb as part of our provisioning process in a custom site definition.
- We provisioned all the required web.config modifications in a feature receiver, definitely a much more elegant way than manually modifying your web.config file