Thursday, December 13, 2012

Interact Intranet: Automate the Extraction of Binary Profile Pictures for use in Active Directory

Active Directory can be used as a central repository for the storage of profile pictures.  This source can then feed email, CRM, intranets, messaging and other enterprise software.

Unfortunately, there isn't an out of the box way for users to add these photos directly to Active Directory (as far as I'm aware!) so some trickery needs to happen to get this all to work.

This blog post will explain how we extract user profile pictures from our Interact Intranet environment and then upload them to Active Directory.


Step 1: SQL query to identify users and their pictures

Microsoft recommends square profile pictures with dimensions of 96x96.  Interact conveniently already thumbnails pictures into preset sizes.  After testing, we found that their 4th size, 75px width, has sufficient clarity when used in Active Directory, which allows us to export pictures without the need for programatic image manipulation.  Note: If you do want to investigate image manipulation you might start with a library like ImageMagick.
select PERSON.NTUsername, ASSET_INSTANCE.BinaryData
from PERSON
INNER JOIN ASSET_INSTANCE
on PERSON.AssetID=ASSET_INSTANCE.AssetID
Where PERSON.NTUsername != 'ARCHIVED'
and PERSON.AssetID is not NULL
and AssetSize='4' 

Step 2: Export pictures to a file path

Below is the code that is triggered though a nightly job to extract the binary data from the database and convert it into files at the designed path.
Credit for this code, and pulling this project together, comes from coding ninja extraordinaire Matt Chiste from Integryst who was able to complete the development in less time than it took me to write the requirements!
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Data.SqlClient;
using System.Data;
using System.IO;
namespace PicExporter
{
    class Program
    {
        static string connString = "Data Source=<redacted>;Initial Catalog=<redacted>;Persist Security Info=True;User ID=<redacted>;Password=<redacted>";
        static string cmdString = "select PERSON.NTUsername, ASSET_INSTANCE.BinaryData from PERSON INNER JOIN ASSET_INSTANCE on PERSON.AssetID=ASSET_INSTANCE.AssetID Where PERSON.NTUsername != 'ARCHIVED' and PERSON.AssetID is not NULL and AssetSize='4'";
        static string folder = "pics/";
        static string FILENAME_EXTENSION = ".jpg";
        static int FILENAME_INDEX = 0;
        static int BINARY_INDEX = 1;
        static string PROP_FILE = "picexporter.properties";
        static void Main(string[] args)
        {
            Console.Out.WriteLine("START: " + DateTime.Now.ToString());
            // vars
            string filename;
            loadVars();
            // set up the connection
            SqlConnection conn = new SqlConnection(connString);
            SqlCommand cmd = new SqlCommand();
            SqlDataReader rdr=null;
            try
            {
                // run the command
                conn.Open();
                cmd.Connection = conn;
                cmd.Parameters.Clear();
                cmd.CommandType = CommandType.Text;
                cmd.CommandText = cmdString;
                rdr = cmd.ExecuteReader();
                // iterate the users
                while (rdr.Read())
                {
                    // write to the log
                    Console.Out.Write("Extracting user: " + rdr[FILENAME_INDEX].ToString());
                    // get the byte array for the image
                    Byte[] b = new Byte[(rdr.GetBytes(BINARY_INDEX, 0, null, 0, int.MaxValue))];
                    rdr.GetBytes(BINARY_INDEX, 0, b, 0, b.Length);
                    // write rest of line to the log
                    Console.Out.Write(" (" + b.Length + "bytes)");
                    // check existing file
                    filename = folder + rdr[FILENAME_INDEX].ToString() + FILENAME_EXTENSION;
                    if (File.Exists(filename))
                        Console.Out.Write(" [UPDATE]" + Environment.NewLine);
                    else
                        Console.Out.Write(" [NEW]" + Environment.NewLine);
                    // user names prefixed with the domain will have a \ in them.  Need to make sure the full folder path is created
                    if (!Directory.Exists(Path.GetDirectoryName(filename)))
                    {
                        Console.WriteLine("Creating Folder: " + Path.GetDirectoryName(filename));
                        Directory.CreateDirectory(Path.GetDirectoryName(filename));
                    }
                    // write the file
                    FileStream fs = new FileStream(filename, FileMode.Create, FileAccess.Write);
                    fs.Write(b, 0, b.Length);
                    fs.Close();
                }
            }
            catch (Exception ex)
            {
                Console.Out.WriteLine("EXCEPTION: " + ex.Message);
                Console.Error.WriteLine("EXCEPTION: " + ex.Message);
            }
            finally {
                // close the connections
                if (rdr != null)
                    rdr.Close();
                if (conn != null)
                    conn.Close();
            }
            Console.Out.WriteLine("FINISH: " + DateTime.Now.ToString());
        }
        static void loadVars()
        {
            string propFile = Directory.GetCurrentDirectory() + "\\" + PROP_FILE;
            string line, name, value;
            StreamReader file = null;
            // Read the file line by line
            try
            {
                file = new System.IO.StreamReader(propFile);
                while ((line = file.ReadLine()) != null)
                {
                    try
                    {
                        name = (line.Substring(0, line.IndexOf('=')));
                        value = (line.Substring(line.IndexOf('=')+1));
                        if (name == "connString")
                            connString = value;
                        else if (name == "cmdString")
                            cmdString = value;
                        else if (name == "folder")
                        {
                            folder = value;
                            if (!Directory.Exists(folder))
                            {
                                Console.WriteLine("Creating Folder: " + folder);
                                Directory.CreateDirectory(folder);
                            }
                        }
//                        else
//                            Console.WriteLine("ignoring property: name: " + name + ", val: " + value);
                    }
                    catch (Exception)
                    {
                        // ignore line without "="
//                        Console.WriteLine("ignoring line: " + line);
                    }
                }
            }
            catch (Exception ex)
            {
                Console.Out.WriteLine("ERROR READING " + propFile + ": " + ex.Message);
                Console.Error.WriteLine("ERROR READING " + propFile + ": " + ex.Message);
            }
            finally
            {
                if (file != null)
                    file.Close();
            }
        }
    }
}
Reference: http://support.microsoft.com/kb/317016

Step 3: Import pictures to Active Directory

The following powershell script runs through a scheduled job.  The job searches the folder we have extracted photos to for newly modified photos and then calls the script, which matches the filename of the photo to the network User Name and then uses native Active Directory APIs to add the image to the user’s profile.
param($Identity,$Path);
# Import a .jpg into Active Directory for use as the Exchange/Outlook GAL Photo
# For best resultsL <10kb files, 96x96 dimensions
Add-PSSnapin Microsoft.Exchange.Management.PowerShell.Admin
if (!$Identity)
{
    throw "Identity Missing";
}
if (!$Path)
{
    throw "Path Missing";
}
if (!(Get-Command Get-User))
{
    throw "Exchange Management Shell not loaded";
}
$User = Get-User $Identity -ErrorAction SilentlyContinue
if (!$User)
{
    throw "User $($Identity) not found";
}
if (!(Test-Path -Path $Path))
{
    throw "File $($Path) not found";
}
$FileData = [Byte[]]$(Get-Content -Path $Path -Encoding Byte -ReadCount 0);
if($FileData.Count -gt 10240)
{
    throw "File size must be less than 10K";
}
$adsiUser = [ADSI]"LDAP://$($User.OriginatingServer)/$($User.DistinguishedName)";
$adsiUser.Put("thumbnailPhoto",$FileData);
$adsiUser.SetInfo()

Tuesday, December 4, 2012

Hide Content Based Upon Iframe Parent

Some web sites use iframes to display information from other web servers.  This is a practice that is generally frowned upon yet is a necessary evil sometimes.

I work on an employee intranet where we need to display content from another internal server.  The problem is that sometimes our employees access the site from off of our domain where they don't have access to the internal server.  To get around this issue I've created an extremely simple javascript snippet which can be added to the page to hide the content and display a message to the user explaining why they can't see the content.

I'm posting it here as a reference for myself and in case someone out there needs something like this!


<div id="whatever">Your content</div>

<STYLE type=text/css>
   .hidden { display: none; }
</STYLE>

<script type="text/javascript">

   function referrerCheck()
   {
      var mapReferrer = document.referrer;
      var mapDiv = document.getElementById('content');

      if(mapReferrer=="http://this is the URL calling the iframe")
      {
         mapDiv.className='unhidden';
      }else{
         mapDiv.className='hidden';
      }
   }

   referrerCheck();

</script>

How it works:
  1. All of this code goes onto the page which calls the iframe.
  2. The div at the top is where your iframe goes - what you want to hide if folks are off the domain.
  3. The style section sets the stage for what you want to happen if folks are off the domain.  In this case we want to not display the entire div.
  4. The javascript runs the referrerCheck() function which grabs the document.referrer which is the page that calls the iframe.
  5. The getElementById functions gets the div object so that its class can be manipulated.
  6. The If condition then determines whether or not the referrer is on or off domain and sets the class accordingly.

Wednesday, November 28, 2012

Interact Intranet: Alerts

Interact Intranet uses a system of alerts to notify users of certain activities.
Some activities additionally trigger emails (covered in my previous post: Interact Intranet: System emails).

The purpose of this post is my own documentation on what I've learned about the alert system on version 5.1.5.

Manage Alerts

From time to time someone might trigger an alert that administrators would like to remove.
We've removed system emails several times and the process is very straight forward.

Within Site Admin: Control Panel: Manage Alerts you can find and delete alerts.

Be aware that alerts in this area are a combination of future and past alerts.  Meaning some have been sent and others are in the queue to send at a later date.  It would be helpful if there were an indicator for this!  I haven't been able to determine the sort order but they don't appear to display chronologically nor alphabetically.

Advanced Alert Management

Alerts (future and past) are stored in the ALERT table of the Interact database.
Key fields in the table include:

  • DateAdded - date the alert was created
  • DateFor - when the alert is due to be sent
  • Date Actioned - when the Alert has been read by a user (aka actioned)
  • ActionedBy - which user the alert has been actioned by

Alert SubType IDs

Alert subtype ID's are used to help organize alerts by activity in the database.
After over 10k alerts being sent by our system (1 month of production use) these are the IDs that have triggered alerts for us and their titles:
  • 1 New Blog Post
  • 2 Keyword Suggestion
  • 2 You have received a comment on one of your blog postings
  • 3 Is This Up To Date?
  • 7 Document Version Approved
  • 8 Comment Reported
  • 8 Post Notification
  • 8 Thread Notification
  • 8 Thread/Post Content Reported
  • 9 <Title of document pending approval>
  • 10 Document Review Reminder
  • 12 Document Expiry Warning
  • 13 Document Watch
  • 14 Document Comment Received
  • 17 An update has been scheduled
  • 18 Interest Suggestion
  • 19 Feed Notification
  • 20 <Username> has invite you to join <team name>
  • 22 Share Notification
  • 23 A comment has been made about your image.
  • 200 You have been tagged
A number of alerts have null as their SubTypeID.  Those include:
  • {TEAMNAME} Notification.
  • Accepted Interest Suggestion
  • <Username> has requested to join <team name>.
  • Approval Request Deleted
  • Comment Request Deleted
  • <team Name> Membership (upon being granted access to a team)
  • Rejected Interest Suggestion
  • Team Creation
  • Team Request

Note

AFAIK alerts (and emails) are triggered by actions of others and can't be self-triggered.
This needs more testing on my end to validate this behavior.

Tuesday, November 27, 2012

Interact Intranet: Customizing your login page

Modifying your Interact login page is easy to do, although probably not upgrade proof.  If you are going to make any customizations, be smart and make a backup of your customizations (in addition to the originals)!


The source files for the login page are stored at \Interact\WEB\Interact\Login.  From there you can modify the default.aspx and default.aspx.vb files per your design.  The CSS is quite complex so if you aren't strong at manipulating floats properties in DIVs then you might just want to start from scratch (or delete everything between the BODY tags).

With some graphic help from our creative department I was able to whip this up in about an hour.
Good luck!

Thursday, November 8, 2012

Interact Intranet release version 5.2 with enhanced analytics and more

This morning (US time) Interact Intranet hosted a webinar covering the release of version 5.2 of their intranet software.  The webinar was recorded and is available here.  Below are some comments and screenshots of the features.

Comments Interface

Anyone that has the ability edit a document can now delete comments.
Profile pictures display alongside comments.


Change Author

In bulk - reassign a document to someone else.



Influence Score

Displays profile score and influence score on user profile
Measures how each user causes others to take action on the Intranet








Analytics

Statistics











Warnings











Real Time Benchmarking

If you don't opt in you don't see the scores for other companies.
All benchmarked against entire Interact user population


Email Report

Daily emails.  Content and frequency cannot be customized.


Document Quality

Score out of 10
Based on things like: keywords, title, images, review date, links, summary, content, etc
Updates on creation and edit of a document.












3rd Party Analytics Integration

Using this interface you can apply a 3rd party analytics tool.



How do I get 5.2

You need to request it from support area via the Interact Extranet area.  Raise a new ticket
5.2 is available immediately.

Tuesday, October 16, 2012

Interact Intranet: Administrative Queries


We are getting close to our go-live date with Interact Intranet and it is time for us to do final cleanup to ensure our site is as consistent as possible.  To supplement the out of the box metrics I created a post of document queries, and this post of queries is going to focus on content structure and organization.

Absolute vs Relative Links

If you have a tendency to link to content (documents, teams, areas, categories, etc) within your intranet you might find that some of your links are absolute and others are relative.  This becomes an issue if you use different URLs for intranet, extranet or outside users. Use this query to verify that all of your links are relative:
SELECT SECTION.Title, SECTION2.Title as Parent, SMI.URL from SECTION_PROPERTIES_SIDEMENUITEM as SMI
INNER JOIN SECTION
on SECTION.SectionID=SMI.SectionID
INNER JOIN SECTION as SECTION2
on SECTION.ParentID=SECTION2.SectionID
WHERE SMI.URL like 'http://%'

Links Specify Page Format

If you create links within your intranet to other areas you might find that some of your links were created using a URL which specifies formatting.  All links which aim at Section/Main should use the default.aspx as opposed to using a defined .aspx page such as MainTwoColumnsLeft MainOneColumnLeft, etc.  You can verify that your site is clean by using this query:
SELECT SECTION.Title, SECTION2.Title as Parent, SMI.URL  FROM SECTION_PROPERTIES_SIDEMENUITEM as SMI
INNER JOIN SECTION
on SECTION.SectionID=SMI.SectionID
INNER JOIN SECTION as SECTION2
on SECTION.ParentID=SECTION2.SectionID
WHERE SMI.URL like '/Interact/Pages/Section/Main%'
ORDER BY Parent

Inconsistent Links

If you create links on multiple areas pointing to the same piece of content you might find that some of your links aren't consistent.  You can verify that your links are consistent using this query and swapping out Expense and Award for your commonly used link titles:
SELECT SECTION.Title, SECTION2.Title as Parent, SMI.URL from SECTION
INNER JOIN SECTION_PROPERTIES_SIDEMENUITEM as SMI
on SECTION.SectionID=SMI.SectionID
INNER JOIN SECTION as SECTION2
on SECTION.ParentID=SECTION2.SectionID
WHERE SECTION.TypeID='3' and (SECTION.Title like 'Expense%' OR SECTION.Title like 'Award%')
ORDER BY SECTION.Title

Inconsistent Category Options

Within a category there are a series of options which can be set including things like sort order.  If you have a large intranet and you deviate from the defaults you might want to verify that your categories are somewhat consistent.  You can access the consistency of your categories using this query and swapping out the red text with however you've set your default options:
SELECT SECTION2.Title as Parent, SECTION.Title, SMI.ShowAuthor, SMI.DefaultOrdering, SMI.ShowPerPage, SMI.TemplateID FROM SECTION
INNER JOIN SECTION_PROPERTIES_SIDEMENUITEM as SMI
on SECTION.SectionID = SMI.SectionID
INNER JOIN SECTION as SECTION2
on SECTION.ParentID=SECTION2.SectionID
WHERE SECTION.TypeID='3' and SMI.TypeID='1' and (SMI.ShowAuthor!=1 or SMI.DefaultOrdering!=1 or SMI.ShowPerPage!=1 or SMI.TemplateID!=1)

What Categories Are Empty

Curious what categories on your site don't have any documents?
Sections >1790 (on my site) are admin and modules areas which is why I eliminated them.
SELECT SECTION.sectionID, Section2.Title as Parent, SECTION.Title FROM SECTION
INNER JOIN SECTION as section2
on SECTION.ParentID=section2.SectionID
WHERE SECTION.SectionID>'1790' and SECTION.Title not like ('%Home') and SECTION.TypeID='3'
and NOT EXISTS
(SELECT * FROM CONTENT_SECTION WHERE SECTION.SectionID=CONTENT_SECTION.SectionID)
ORDER BY Parent asc

User Last Login

Curious which users haven't logged into your site in the last few days, weeks months? This query will show the last time all users in your system logged in.
SELECT Surname, Firstname, DateLastAccessed from PERSON
ORDER BY DateLastAccessed desc

I'll continue to add to this list as I generate additional queries.  Feel free to lob out any suggestions for queries and I'll see if I can add them to the list.

Thursday, October 11, 2012

Interact Intranet: Noise Words


To improve processing time for search queries it is common for applications and databases to ignore certain words which have low value.  SQL Server has it's own list of noise words which you can edit and change.
Interact also has a list however I've been told they are hard-coded into the system and not customizable.
Here is the list of Interact's noise words:

1 2 3 4 5 6 7 8 9 0 a b c d e f g h i j k l m n o p q r s t u v w x y z not about after all also an and another any are as at be because been before being between both but by came can come could did do each for from get got has had he have her here him himself his how if in into is it like make many me might more most much must my never now of on only or other our out over said same see should since some still such take than that the their them then there these they this those through to too under up very was way we well were what where which while who with would you your & ? use

Curiously if you look at the Interact database you'll notice there is a table called NoiseWords which has a single, unpopulated, column called WRD.

Reference (login required): http://extranet.interact-intranet.com/Interact/Pages/Content/Document.aspx?id=4609