Click here to Skip to main content
15,887,347 members
Articles / Programming Languages / C# 5.0
Tip/Trick

Creating or Editing the Web.config dynamically for Page Level Access (Asp.net web forms)

Rate me:
Please Sign up or sign in to vote.
4.83/5 (8 votes)
27 Oct 2014CPOL3 min read 23.4K   11   9
This tip/trick shows you how to edit the web.config file dynamically .

Introduction

The point of this tip is to show how to create/edit the web.config file dynamically for role based access to pages.

Background

Even if it’s not recommended to create/edit the web.config file dynamically, for different reasons you might want to do so . In my case our clients needed to give access to individual pages on their own using a user interface .

Here we won't be editing the main web.config file that contains the settings for the whole project .We will be creating/editing a web.config which is found on the folders where your .aspx pages reside.

For user management purposes I used SqlMembership provider and there are a lot of materials on the internet that shows how to use it and there is no need to repeat it here.

After you created users and roles using SqlMembership provider then the need comes for you to give access to pages either by role or to individual users. This tip shows you how to achieve that . 

To get started with I used a tutorial by Dan Clem which is found at http://www.4guysfromrolla.com/articles/053007-1.aspx  . The tutorial shows you how to give folder level access to roles or users .

But incase if you needed to provide access to your individual pages that are inside your folders and also create your web.config on the fly you can use this tip.

The Steps are organized as follows

1.Create the user interface

2.Populate the tree

3.Create the web.config file dyanamically (i.e if it doesn't exist)

4.Create a rule

5.Update a rule

6.Delete a rule

7.Display the Access rules on Grid

1.Creating the user Interface

 Use the below code to create the user interface and don't forget to change the images with your own  .

 I am also using telerik RadGrid you might also want to change that to Microsoft Gridview control   .                      
                                                                                          

ASP.NET
 <div>
        <table>
            <tr>
                <th>
                    <asp:Localize ID="Localize1" runat="server"
                        Text="Access Rule Management" />
                </th>
            </tr>
            <tr>
                <td class="details" valign="top">
                    <p>
                        <i>
                            <asp:Localize ID="Localize2" runat="server"
                                Text="Use this page to manage access rules for the system users. Rules are applied to pages." />
                        </i>
                    </p>
                    <asp:Label ID="lblError" runat="server" CssClass="Important" Visible="False" 
                     style="font-weight: 700"></asp:Label>
                    <table style="width: 100%">
                        <tr>
                            <td valign="top" style="padding-right: 30px;">
                                <div class="treeview">
                                    <asp:TreeView runat="server" ID="FolderTree"
                                        OnSelectedNodeChanged="FolderTree_SelectedNodeChanged" ExpandDepth="1" OnTreeNodeExpanded="FolderTree_TreeNodeExpanded">
                                        <RootNodeStyle ImageUrl="~/Content/Images/folder.gif" />
                                        <ParentNodeStyle ImageUrl="~/Content/Images/folder.gif" />
                                        <LeafNodeStyle ImageUrl="~/Content/Images/folder.gif" />
                                        <SelectedNodeStyle Font-Underline="True" ForeColor="#A21818" />
                                    </asp:TreeView>
                                </div>
                            </td>
                            <td valign="top" style="padding-left: 30px; border-left: 1px solid #999; width: 80%">
                                <asp:Panel runat="server" ID="SecurityInfoSection" Visible="False">
                                    <h4 runat="server" id="TitleOne" class="alert"></h4>
                                    <p align="center">
                                        <asp:Label ID="ActionStatus" runat="server" CssClass="Important" Visible="False"></asp:Label>
                                    </p>                                  
                                    <telerik:RadGrid ID="RadGridRuleDisplay" runat="server"
                                         GridLines="None" CellSpacing="0" Skin="Vista" AutoGenerateColumns="False">
                                        <ClientSettings>
                                            <Selecting AllowRowSelect="True" />
                                        </ClientSettings>
                                        <AlternatingItemStyle BackColor="#F0F8FF" />
                                        <MasterTableView>
                                            <Columns>                                               
                                                <telerik:GridBoundColumn DataField="Roles" HeaderText="Allowed Roles">
                                                    <ColumnValidationSettings>
                                                        <ModelErrorMessage Text=""></ModelErrorMessage>
                                                    </ColumnValidationSettings>
                                                </telerik:GridBoundColumn>                                                                              
                                               
                                                <telerik:GridTemplateColumn HeaderText="Delete Rule" UniqueName="DeleteRule">
                                                    <ItemTemplate>
                                                        <asp:ImageButton ID="Button1" ToolTip="Delete Rule" AlternateText="Delete Rule"
    runat="server"
                                                            ImageUrl='<%# RadAjaxLoadingPanel.GetWebResourceUrl(Page, "Telerik.Web.UI.Skins.Default.Grid.Delete.gif") %>'
                                                            
                                                            OnClick="DeleteRule"
                                                            OnClientClick="return confirm('Click OK to delete this rule.')" />
                                                    </ItemTemplate>
                                                </telerik:GridTemplateColumn>                                              
                                            </Columns>
                                        </MasterTableView>
                                    </telerik:RadGrid>
                                   <br />
                                    <hr />
                                    <h4 runat="server" id="TitleTwo" class="alert"></h4>
                                    <b>
                                        <asp:Literal ID="Literal1" runat="server" Text="Action:" /></b>
                                    <asp:RadioButton runat="server" ID="ActionDeny" GroupName="action"
                                        Text="Deny" Checked="True" />
                                    <asp:RadioButton runat="server" ID="ActionAllow" GroupName="action"
                                        Text="Allow" />
                                    <br />
                                    <br />
                                    <b>
                                        <asp:Literal ID="Literal2" runat="server" Text="Rule applies to:"
                                            meta:resourcekey="Literal2Resource1" /></b>
                                    <br />

                                    <asp:RadioButton runat="server" ID="ApplyRole" GroupName="applyto"
                                        Text="This Role:" Checked="True" meta:resourcekey="ApplyRoleResource1" />
                                    <telerik:RadComboBox ID="UserRoles" runat="server" AppendDataBoundItems="True"
                                        Filter="Contains">
                                        <Items>
                                            <telerik:RadComboBoxItem Text="Select Role" Value="Select Role" runat="server"
                                                Owner="" />
                                        </Items>
                                    </telerik:RadComboBox>
                                    <br />
                                    <asp:Button ID="Button4" runat="server" Text="Create Rule" OnClick="CreateRule"
                                        OnClientClick="return confirm('Click OK to create this rule.');" />
                                    <asp:Literal runat="server" ID="RuleCreationError"></asp:Literal>
                                </asp:Panel>
                            </td>
                        </tr>
                    </table>
                </td>
            </tr>
        </table>
    </div>

On my machine the final UI looks like the image shown below

Image 1

2.Populating the tree

Call the method populate tree on Page_Load to populate the tree

C#
        protected void Page_Load(object sender, EventArgs e)
        {
            if (!IsPostBack)
            {
                PopulateTree();
            }
        }
        private const string VirtualImageRoot = "~/";
        string selectedFolderName;
        protected void Page_Init()
        {
            UserRoles.DataSource = Roles.GetAllRoles();
            UserRoles.DataBind();
            if (IsPostBack)
            {
                selectedFolderName = "";
            }
            else
            {
                selectedFolderName = Request.QueryString["selectedFolderName"];
            }
        }        
        protected void PopulateTree()
        {
            // Populate the tree based on the subfolders of the specified VirtualImageRoot
            DirectoryInfo rootFolder = new DirectoryInfo(Server.MapPath(VirtualImageRoot));
            TreeNode root = AddNodeAndDescendents(rootFolder, null);
            FolderTree.Nodes.Add(root);
            try
            {
                FolderTree.SelectedNode.ImageUrl = "~/Content/Images/folder.gif";
            }
            catch
            { 

            }
        }

Here is the Method that addes the Nodes and it Descendants

C#
        /// <summary>
        /// This method is used to populate the tree.It populates the folders
        /// by excluding those folders which are not necessary for user mangement purposes
        /// </summary>
        /// <param name="folder"></param>
        /// <param name="parentNode"></param>
        /// <returns></returns>
        protected TreeNode AddNodeAndDescendents(DirectoryInfo folder, TreeNode parentNode)
        {
            string virtualFolderPath;
            if (parentNode == null)
            {
                virtualFolderPath = VirtualImageRoot;
            }
            else
            {
                virtualFolderPath = parentNode.Value + folder.Name + "/";
            }
            TreeNode node = new TreeNode(folder.Name, virtualFolderPath);
            node.Selected = (folder.Name == selectedFolderName);
            // Recurse through this folder's subfolders
            DirectoryInfo[] subFolders = folder.GetDirectories();
            foreach (DirectoryInfo subFolder in subFolders)
            {
                if (subFolder.Name != "_controls" && subFolder.Name != "App_Data" && subFolder.Name != "App_Code" 
                    &&  subFolder.Name != "bin" && subFolder.Name != "fonts" && subFolder.Name != "Scripts" 
                    && subFolder.Name != "Styles" && subFolder.Name != "Temp" && subFolder.Name != "UserControls"
                    && subFolder.Name != "obj" && subFolder.Name != "Service References" && subFolder.Name != "App_Start"
                    && subFolder.Name != "Account" && subFolder.Name != "Properties" && subFolder.Name != "Models" 
                    && subFolder.Name != "Content" && subFolder.Name != "Images")
                {
                    TreeNode child = AddNodeAndDescendents(subFolder, node);
                    node.ChildNodes.Add(child);
                    foreach (FileInfo File in subFolder.GetFiles())
                    {
                        //create node and add the pages to the tree
                        if (File.Extension == ".aspx")
                        {
                            TreeNode childnode = new TreeNode(File.Name, File.FullName);
                            childnode.ImageUrl = "~/Content/Images/icon-new.png";
                            child.ChildNodes.Add(childnode);
                        }
                    }
                }
            }
            return node; // Return the new TreeNode
        }

3.Create the Web.config Dynamically

On the TreeNodeExpanded event check whether there is a web.config file on the selected folder and create it dynamically if it doesn't exist

C#
        protected void FolderTree_TreeNodeExpanded(object sender, TreeNodeEventArgs e)
        {            
                if (!System.IO.File.Exists(e.Node.Value))
                {
                    CreateWebConfigFile(e.Node.Value);
                }         
        }
        /// <summary>
        /// this method is used to create a web.config file dyamically if it doesn't exist
        /// on a folder
        /// </summary>
        /// <param name="folderPath"></param>
        void CreateWebConfigFile(string folderPath)
        {
            string pathString = System.IO.Path.Combine(Server.MapPath(folderPath), "Web.config");
            if (!System.IO.File.Exists(pathString))
            {
                FileInfo f = new FileInfo(pathString);
                using (StreamWriter fs = f.CreateText())
                {
                    // Use the FileStream object...
                    string xmlFile = "<?xml version='1.0'?><configuration></configuration>";
                    fs.Write(xmlFile);
                    fs.Close();
                }
            }
        }   

4.Create rule

On the button click event of createRule call the AddRoleRule method

C#
        protected void CreateRule(object sender, EventArgs e)
        {
            AuthorizationRule newRule;
            if (ActionAllow.Checked)
                newRule = new AuthorizationRule(AuthorizationRuleAction.Allow);
            else
                newRule = new AuthorizationRule(AuthorizationRuleAction.Deny);

            if (ApplyRole.Checked && UserRoles.SelectedIndex > 0)
            {               
                AddRoleRule(newRule, UserRoles.Text);
            }  
        
        }

The method that is responsible for creating the rule is shown below     

How it works ,

It starts by loading the web.config file as an Xml document .Before creating the new authorization rule it checks whether the authorization rule is already updated . If the rule is not updated it means it's a new rule it and it creates it as an xml element including it’s attributes and child elements.Finally after it has created the elements it appends them to the end of the Xml document (i.e the loaded web.config file) .      

C#
        /// <summary>
        /// this method is used to add an access rule to a web.config if the rule
        /// doesn't already exists
        /// </summary>
        /// <param name="newRule"></param>
        /// <param name="selectedrole"></param>
        protected void AddRoleRule(AuthorizationRule newRule, string selectedrole)
        {
            bool updated = false;
            string virtualFolderPath = FolderTree.SelectedNode.Parent.Value;
            Configuration config = WebConfigurationManager.OpenWebConfiguration(virtualFolderPath);
            XmlDocument xDoc = new XmlDocument();
            xDoc.Load(config.FilePath);
            FileInfo myFile = new FileInfo(config.FilePath);
            myFile.IsReadOnly = false;
            // if the rule exists update the rule
            updated = getOldAssignedRolesAndUpdate(config, newRule, Path.GetFileName(FolderTree.SelectedValue), selectedrole, xDoc);
           
            if (!updated)
            {
                //create location element and the path attribute with it's value set to the 
                //selected page 
                XmlElement newLocationelement = xDoc.CreateElement("location");
                XmlAttribute newLocationAttrib = xDoc.CreateAttribute("path");
                newLocationAttrib.Value = Path.GetFileName(FolderTree.SelectedValue);
                newLocationelement.Attributes.Append(newLocationAttrib);    
                XmlElement newSystemWebelement = xDoc.CreateElement("system.web");
                XmlElement newAuthorizationelement = xDoc.CreateElement("authorization");
                //create the allow element
                XmlElement newAllowelement = xDoc.CreateElement("allow");
                XmlAttribute newAllowAttrib = xDoc.CreateAttribute("roles");
                newRule.Roles.Add(selectedrole).ToString();
                string listofRoles = "";
                foreach (var item in newRule.Roles)
                {
                    listofRoles = item.ToString() ;
                }
                newAllowAttrib.Value = listofRoles;
                newAllowelement.Attributes.Append(newAllowAttrib);   
                //create the deny element
                XmlElement newDenyelement = xDoc.CreateElement("deny");
                XmlAttribute newUsersAttrib = xDoc.CreateAttribute("users");
                newUsersAttrib.Value = "*";
                newDenyelement.Attributes.Append(newUsersAttrib);            
                newAuthorizationelement.AppendChild(newAllowelement);
                newAuthorizationelement.AppendChild(newDenyelement);
                newLocationelement.AppendChild(newSystemWebelement);
                newSystemWebelement.AppendChild(newAuthorizationelement);
                xDoc.DocumentElement.AppendChild(newLocationelement);
                xDoc.PreserveWhitespace = true;
                //write to web.config file using xml writer
                XmlTextWriter xwriter = new XmlTextWriter(config.FilePath, null);               
                try
                {
                    xDoc.WriteTo(xwriter);
                    xwriter.Close();
                    PopulateRolesGrid(Path.GetFileName(FolderTree.SelectedValue), config);
                    ActionStatus.Visible = true;
                    ActionStatus.ForeColor = Color.Green;
                    ActionStatus.Text = "Role Added Successfuly!";
                    RuleCreationError.Visible = false;
                }

                catch (Exception ex)
                {
                    RuleCreationError.Visible = true;
                    RuleCreationError.Text = "An error occurred and the rule was not added.";
                }
            }
        }



       

5.Update rule

Use the below method to update a specific rule if exist for the selected page

How it works ,

It starts by loading the web.config file as an XDocument . First it gets the "location" element which has it's "path" attibute value of the selected page, then it gets the "authorization" element and checks whether it has "allow" element inside it if it has no "allow" element it creates it . If there already an "allow" element it updates it by concatenating the new role to the older one.

C#
    
        /// <summary>
        /// This method is used to update a rule if it already exist in the web.config file
        /// </summary>
        /// <param name="config"></param>
        /// <param name="newRule"></param>
        /// <param name="selectedPage"></param>
        /// <param name="selectedRole"></param>
        /// <param name="xDoc"></param>
        /// <returns></returns>
        bool getOldAssignedRolesAndUpdate(Configuration config, AuthorizationRule newRule, 
                                        string selectedPage, string selectedRole, XmlDocument xDoc)
        {
            bool updated = false;

            XDocument xmlFile = XDocument.Load(config.FilePath);
            IEnumerable<XElement> location = from el in xmlFile.Elements("configuration").
                                          Elements("location")
                                          where (string)el.Attribute("path") == selectedPage
                                          select el;
            var authorizationElement = from authorization in location.Elements("system.web").
                                        Elements("authorization")
                                        select authorization;  
            var allowRoleRuleElements = from category in location.Elements("system.web").
                                        Elements("authorization").Elements("allow"where (string)category.Attribute("roles") != null   
                                        select category;
            if (authorizationElement.Count() > 0)    
            {
                if (allowRoleRuleElements.Count() == 0)
                {
                    XmlDocument xDocNew = new XmlDocument();
                    xDocNew.Load(config.FilePath);
                    XmlNode xNode = xDocNew.CreateNode(XmlNodeType.Element, "allow", "");
                    XmlAttribute xKey = xDocNew.CreateAttribute("roles");
                    xKey.Value = selectedRole;
                    xNode.Attributes.Append(xKey);
                    authorizationElement.Single().AddFirst(xNode);
                    xDocNew.Save(config.FilePath);
                    ActionStatus.Visible = true;
                    ActionStatus.ForeColor = Color.Green;
                    ActionStatus.Text = "Role Added Successfuly!";
                    return true;
                }

               else
               {
                    string oldrole = allowRoleRuleElements.Single().Attribute("roles").Value.ToString();
                    if (newRule.Action == AuthorizationRuleAction.Deny)
                    {
                       int index = oldrole.IndexOf(selectedRole);
                       string newRole = (index < 0) ? oldrole : oldrole.Remove(index, selectedRole.TrimStart(',').Length);
                       allowRoleRuleElements.Single().Attribute("roles").Value = newRole.TrimEnd(',').TrimStart(',');
                    }

                    else
                    {
                      string newRole = (oldrole == String.Empty) ? selectedRole.Trim() : (oldrole + "," + selectedRole);
                      allowRoleRuleElements.Single().Attribute("roles").Value = newRole;
                    }
                    try
                    {
                      xmlFile.Save(config.FilePath);
                      PopulateRolesGrid(Path.GetFileName(FolderTree.SelectedValue), config);
                      RuleCreationError.Visible = false;
                    }
                    catch (Exception ex)
                    {
                        RuleCreationError.Visible = true;
                        RuleCreationError.Text = "An error occurred and the rule was not added.";
                    }
             }
                updated = true;
            }
            return updated;


       }

6.Delete rule

How it works,

It loads the the web.config file as XDocument and gets the node which has it's path attibute of the selected page and remove/delete all that are associated with it. 

C#
        protected void DeleteRule(object sender, EventArgs e)
        {
            ImageButton button = (ImageButton)sender;

            GridDataItem item = (GridDataItem)button.Parent.Parent;
            string virtualFolderPath = FolderTree.SelectedNode.Parent.Value;
            Configuration config = WebConfigurationManager.OpenWebConfiguration(virtualFolderPath);
            //change the attribute of the web.config so that we can manipulate it
            FileInfo myFile = new FileInfo(config.FilePath);
            myFile.IsReadOnly = false;
            ConfigurationLocationCollection loc = (ConfigurationLocationCollection)config.Locations;
            SystemWebSectionGroup systemWeb = (SystemWebSectionGroup)config.GetSectionGroup("system.web");
            AuthorizationSection section = (AuthorizationSection)systemWeb.Sections["authorization"];
            XDocument doc = XDocument.Load(config.FilePath);
            doc.Declaration = new XDeclaration("1.0", null, null);

            var ruleTobeDeleted = from node in doc.Descendants("configuration").Elements("location")
                    where (string)node.Attribute("path") == Path.GetFileName(FolderTree.SelectedValue)
                    select node;

            ruleTobeDeleted.ToList().ForEach(x => x.Remove());
            doc.Save(config.FilePath, SaveOptions.OmitDuplicateNamespaces);
            ActionStatus.ForeColor = Color.Green;
            ActionStatus.Visible = true;
            ActionStatus.Text = "Role Deleted Successfuly!";
            PopulateRolesGrid(Path.GetFileName(FolderTree.SelectedValue), config);

        }

7.Display Access rules on grid

For data binding purpose to the grid create a class which contains the necessary fields 

C#
       //class that is used for the data binding purpose      
       public class AuthorizationRoleCollection
       {
           public string Roles { get; set; }
           public string Users { get; set; }
           public string Action { get; set; }
       }
              

Display the access rules on Grid

C#
        protected void FolderTree_SelectedNodeChanged(object sender, EventArgs e)
        {
           if (FolderTree.SelectedNode.Value.Contains(".aspx"))
            {
                ActionDeny.Checked = true;
                ActionAllow.Checked = false;
                ApplyRole.Checked = true;
                ActionStatus.Visible = false;
                UserRoles.SelectedIndex = 0;
                RuleCreationError.Visible = false;
                lblError.Visible = false;           
                FolderTree.SelectedNode.ImageUrl = "~/Content/Images/icon-new.png"; 
                string folderPath = FolderTree.SelectedNode.Parent.Value;
                DisplayAccessRules(folderPath, Path.GetFileName(FolderTree.SelectedValue));
            }
            else
            {
                lblError.Visible = true;
                lblError.Text = "Please select a Page.";
                SecurityInfoSection.Visible = false;
            }

       }

 

Display access rules method

C#
        /// <summary>
        /// this method is used to display the access rules given to a selected page
        /// </summary>
        /// <param name="virtualFolderPath"></param>
        /// <param name="page"></param>
        protected void DisplayAccessRules(string virtualFolderPath, string page)
        {
            try
            {
                SecurityInfoSection.Visible = true;
                lblError.Visible = false;
                Configuration config = WebConfigurationManager.OpenWebConfiguration(virtualFolderPath);
                PopulateRolesGrid(page, config);
                TitleOne.InnerText = "Rules applied to " + page;
                TitleTwo.InnerText = "Create new rule for " + page;
            }
            catch (Exception)
            {
                throw;
            } 
     
        }

Use the below method to populate the grid with roles that have access to pages

C#
        /// <summary>
        /// this method is used to populate the grid thats shows the access that are
        /// given to a selected page on a folder
        /// </summary>
        /// <param name="page"></param>
        /// <param name="config"></param>
        private void PopulateRolesGrid(string page, Configuration config)
        {
            XDocument xmlFile = XDocument.Load(config.FilePath);
            IEnumerable<XElement> roles = from el in xmlFile.Elements("configuration").
                                          Elements("location")
                                          where (string)el.Attribute("path") == page
                                          select el;            

            var authorizationElement = from authorization in roles.Elements("system.web").
                                       Elements("authorization")
                                       select authorization;
           
            var allowRoleRuleElements = from category in roles.Elements("system.web").
                                        Elements("authorization").Elements("allow")
                                        where (string)category.Attribute("roles") != null
                                        select category;

              
            var denyUserRuleElements = from category in roles.Elements("system.web").
                                       Elements("authorization").Elements("deny")
                                       where (string)category.Attribute("users") != null
                                       select category;

            List<AuthorizationRoleCollection> arList = new List<AuthorizationRoleCollection>();
            foreach (var item in authorizationElement)
            {
                AuthorizationRoleCollection ar = new AuthorizationRoleCollection();
                ar.Roles = allowRoleRuleElements.First().FirstAttribute.Value.ToString();
                ar.Users = denyUserRuleElements.First().FirstAttribute.Value.ToString();
                ar.Action = "";
                arList.Add(ar);
            }
            RadGridRuleDisplay.DataSource = arList;
            RadGridRuleDisplay.DataBind();
        }

 

History

First version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior)
Ethiopia Ethiopia
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
Praisevery useful Pin
Dave Stanley 202230-Apr-22 0:13
Dave Stanley 202230-Apr-22 0:13 
Questionhow to create delete location path using parameter textbox ? Pin
Member 111656072-Feb-15 14:36
Member 111656072-Feb-15 14:36 
AnswerRe: how to create delete location path using parameter textbox ? Pin
Israel Gebreselassie6-Feb-15 19:17
Israel Gebreselassie6-Feb-15 19:17 
QuestionWill it allow to write web.config on server? Pin
Mehul M Thakkar24-Nov-14 22:32
Mehul M Thakkar24-Nov-14 22:32 
AnswerRe: Will it allow to write web.config on server? Pin
Israel Gebreselassie27-Nov-14 18:45
Israel Gebreselassie27-Nov-14 18:45 
GeneralAwesomer Pin
Johannes Jo11-Nov-14 9:34
Johannes Jo11-Nov-14 9:34 
GeneralRe: Awesomer Pin
Israel Gebreselassie11-Nov-14 20:06
Israel Gebreselassie11-Nov-14 20:06 
QuestionThanks Pin
Member 1068700828-Oct-14 22:44
Member 1068700828-Oct-14 22:44 
AnswerRe: Thanks Pin
Tadiwous Wondimou4-Nov-14 6:39
Tadiwous Wondimou4-Nov-14 6:39 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.