2020-01-31

Building strong named assemblies in Azure Pipelines (or solving the MSB3325 error)

If you, like me, are using a certificate to sign your assemblies, making them strong named, you will get in trouble when trying to build the project on another machine (or as in this case, using an azure pipeline). MSBuild will throw an error (MSB3325) that looks like below.

Error MSB3325: Cannot import the following key file: mycertificate.pfx. 
The key file may be password protected. 
To correct this, try to import the certificate again 
or manually install the certificate to the Strong Name CSP 
with the following key container name: VS_KEY_F287A1878DD02205

Problem background

Certificates (in pfx containers) are password protected and for MSBuild to be able to use them, they must be installed in a key container. (MSBuild uses the "Microsoft Strong Cryptographic Provider", MSC)

Normally, when selecting a certificate in Visual Studio, you are prompted for the password and Visual studio creates the container for you. When the container is created, you do not need the password as long as the container exists. If you would like to build the project on another machine you will get the error above since the container is not present.

Manual solutions

If you go to another machine, one simple way to create the container is to deselect and select the certificate again (in visual studio, in the properties/signing window) which will prompt you for the password.
Another way is to use sn.exe and pass the certificate and key container name as parameters

Sn.exe -i mycertificate.pfx VS_KEY_F287A1878DD02205

This command will then prompt you for the password and create the container
https://docs.microsoft.com/en-us/dotnet/framework/tools/sn-exe-strong-name-tool

Running on Azure pipelines

To be able to build this on Azure Pipelines you need to create the container beforehand.
One way is to create a self-hosted agent that you install the container on.
https://docs.microsoft.com/en-us/azure/devops/pipelines/agents/v2-windows?view=azure-devops

If you would like to use a Microsoft-hosted agent, then you must include the creation of the container in the pipeline itself. Using sn.exe seems like a promising way.

Automated solutions

If we would like to automate the process we have two problems that need solving
1. We need to calculate the name of the container
2. We need to pass the password to the sn.exe tool somehow.

The container name is determined by a class in Microsoft.Build.Tasks.v4.0.dll named ResolveKeySource. It will base the name upon the contents of the certificate, plus the user's domain and username that are running the MSBuild command. kdrapel posted an answer regarding this on StackOverflow

The other issue, passing the password to the sn.exe tool seemed a bit harder. There were some examples where piping it to the exe or writing a wrapper around stdin but none of them worked for me.

SnInstallPfx

Thanks to honzajscz, who also had this issue, the GitHub project SnInstallPfx solved both issues in a nice way. Both calculating the container name and allowing us to enter the password ended us up with a simple command line

SnInstallPfx.exe mycertificate.pfx mypassword

Final solution in Azure Pipelines

To include it in the pipeline I used the library function
https://docs.microsoft.com/en-us/azure/devops/pipelines/library/?view=azure-devops

In the library, I uploaded both the certificate and the sninstallpfx.exe tool.
The password I added in a variable group.

In the YML I first added the variable group

variables:
- group: 'My password'

Then the files needed to be copied to the target host using DownloadSecureFile task

- task: DownloadSecureFile@1
  displayName: Download Pfx
  name: myCertificatePfx
  inputs:
    secureFile: MyCertificate.pfx

- task: DownloadSecureFile@1
  displayName: Download sni
  name: snInstallPfx
  inputs:
    secureFile: SnInstallPfx.exe

Then added a PowerShell task where I imported the file paths and password as an environment variable and just invoked the exe

- task: PowerShell@2
  env:
    SN_INSTALL_PFX: $(snInstallPfx.secureFilePath)
    MYCERTIFICATE_PFX: $(myCertificatePfx.secureFilePath)
    MYCERTIFICATE_PFX_PASSWORD: $(myCertificatePfxPassword)
  inputs:
    targetType: 'inline'
    script: '&"$($ENV:SN_INSTALL_PFX)" "$($ENV:MYCERTIFICATE_PFX)" "$($ENV:MYCERTIFICATE_PFX_PASSWORD)"'

After a long research, I was finally able to build the project on a Microsoft hosted pipeline.

2019-12-02

Uri.ToString() automatically decodes url encoded characters

Uri.ToString() has been discussed quite a lot and its output has not always been consequent.
It can decode URL encoded characters in the URL, especially in the query part where it will be troublesome.

See the article from Anders Abel that discusses the differences between framework 4.0 and framework 4.5 https://coding.abel.nu/2014/10/beware-of-uri-tostring/

As shown in this article, more changes have been introduced int the newer frameworks.

I have done some testing by setting different targetFramework in the httpRuntime element of web.config.

Tests have been performed by creating a URI with a parameter and iterating all ASCII characters (256) and then calling ToString().

for(var i=0;i<16*16;i++) {
  var enc = $"%{i:x2}".ToUpper();
  var uri = new Uri($"https://some.site/?key={enc}");
  uri.ToString();
}

Tests show that different encodings are being decoded when other encodings are kept and the following table shows the differences between each framework where x marks the characters being decoded and a blank space shows that the encoding is kept even after ToString().



targetFramework decodedControl
00-1F

20
!
21
"
22
#
23
$
24
%
25
&
26
'()*+,
27-2C
-
2D
.
2E
/
2F
0-9
30-39
:
3A
;
3B
<
3C
=
3D
>
3E
?
3F
@
40
A-Z
41-5A
[
5B
\
5C
]
5D
^_`a-z{\|}~
5E-7E
DEL
7F
extended
80-FF
4.0 126 x x x x x x x x x x x x x x x x x x x x x x x
4.5 - 4.7.1 83 x x x x x x x x x x x x x x x
4.7.2 - 4.8 75 x x x x x x x x x x
The decoded column lists the number of characters that is decoded (of the 256 possible ASCII characters available).

Summary

ToString always decodes
%20,%22,%2D,%2E,%2F,%30-%39,%3C,%3E,%41-%5A,%5E-%7E 
 "-./0-9<>A-Z^_`a-z{\|}~

I'm not sure that I would like the %20 (whitespace) to be decoded but that's perhaps a personal preference. Of course, it's readability is improving but for programming use, I'm not a fan.

4.5-4.7.1 also decodes
%21,%27-%2C,%3A,%5B,%5D 
!'()*+,:[]

4.0 also decodes
-%1F,%24,%26,%3B,%3D,%3F,%40,%5C 
Control characters $&;=?@\

4.0 is not good since an embedded query string will be decoded since an encoded & and = (%26,%3D) in a value will be decoded.

Example

https://some.site/?key=key%3Dvalue
will be decoded to
https://some.site/?key=key=value
which will mess up the whole meaning

Workaround

If you would like to avoid decoding any URL:s you can use the following extension method.
This will use the AbsoluteUri when we are dealing with an absolute URI and the OriginalString for relative

public static string ToUrl(this Uri uri)
{
    if (uri == null) return null;
    if (uri.IsAbsoluteUri) return uri.AbsoluteUri;
    return uri.OriginalString;
}

Conclusion


  1. Don't use target framework 4.0, at least switch to 4.5.
  2. If you want to avoid encoding, don't use Uri.ToString(). It will always decode some encoded characters (even if newer frameworks are better att avoiding characters that might get you in trouble).

2018-05-02

Maintaining config files in your project

Have a problem with config files (web.config, app.config) and individual settings in a team or for your building server? Look no further. Check the video below and see if ConfigBuilder can be the solution to your problems. Available as a NuGet package (Meriworks.PowerShell.ConfigBuilder) to install in your project.

2018-04-18

Create admin user in episerver after creating new site

If you ever create a new Episerver site with a default membership provider and cannot login since you don't have any current user account in the membership database, use the SQL script below to create a user (tempadmin/tempadmin) that belongs to the WebEditors and WebAdmin groups.

Disclaimer:
The created user will have it's password in clear text in the database so after creating this user, be sure to create a real admin user and delete this one.


/**
  Will create an application user (epiadmin/epiadmin) and group (webeditors) for an asp.net identity database

**/
DECLARE @aid uniqueidentifier
DECLARE @applicationName nvarchar(235)
DECLARE @uid uniqueidentifier
DECLARE @rolename nvarchar(256)

set @applicationName=N'/'
DECLARE @username nvarchar(50)
DECLARE @password nvarchar(50)
SET @username=N'tempadmin'
SET @password=N'tempadmin'

--create application
IF( NOT EXISTS (SELECT * FROM applications where ApplicationName=@applicationName))
BEGIN
 SET @aid=NEWID()
 insert into applications values(@aid,@applicationName,null)
END

SELECT @aid=ApplicationId FROM applications where ApplicationName=@applicationName
select * from applications

--create user
IF(NOT EXISTS (SELECT * FROM users where ApplicationId=@aid and UserName = @username))
BEGIN
set @uid=NEWID()
 INSERT INTO Users VALUES(@uid,@aid,@username,0,GETDATE())
END
SELECT @uid=UserId FROM Users where ApplicationId=@aid and UserName=@username
select * from users

--create membership data
IF(NOT EXISTS (SELECT * FROM memberships where ApplicationId=@aid and UserId= @uid))
BEGIN
 INSERT INTO Memberships(UserId,ApplicationId,Password,PasswordFormat,PasswordSalt,Email,PasswordQuestion,PasswordAnswer,IsApproved,IsLockedOut,CreateDate,LastLoginDate,LastPasswordChangedDate,LastLockoutDate,FailedPasswordAttemptCount,FailedPasswordAttemptWindowStart,FailedPasswordAnswerAttemptCount,FailedPasswordAnswerAttemptWindowsStart,Comment) 
                  VALUES(@uid,@aid,@password,0,'Tc7EIFB+4dw33bRNOVJTGQ==','tempadmin@example.com',NULL,NULL,1,0,GETDATE(),GETDATE(),GETDATE(),'1754-01-01 00:00:00.000',0,'1754-01-01 00:00:00.000',0,'1754-01-01 00:00:00.000',NULL)
END
select * from memberships

SET @rolename='WebEditors'
DECLARE @roleid uniqueidentifier

IF(NOT EXISTS (SELECT * from roles where applicationId=@aid and RoleName=@rolename))
BEGIN
 SET @roleid=NEWID()
 INSERT INTO Roles(roleid,applicationid,rolename) 
 values(@roleid,@aid,@rolename)
END
SELECT @roleid=RoleId FROM Roles where ApplicationId=@aid and RoleName=@rolename
select * from roles

IF(NOT EXISTS(SELECT * FROM usersinroles where roleid=@roleid AND userid=@uid))
BEGIN
 INSERT INTO usersinroles(userid,roleid) values(@uid,@roleid)
END
select * from usersinroles

SET @rolename='WebAdmins'
IF(NOT EXISTS (SELECT * from roles where applicationId=@aid and RoleName=@rolename))
BEGIN
 SET @roleid=NEWID()
 INSERT INTO Roles(roleid,applicationid,rolename) 
 values(@roleid,@aid,@rolename)
END
SELECT @roleid=RoleId FROM Roles where ApplicationId=@aid and RoleName=@rolename
select * from roles

IF(NOT EXISTS(SELECT * FROM usersinroles where roleid=@roleid AND userid=@uid))
BEGIN
 INSERT INTO usersinroles(userid,roleid) values(@uid,@roleid)
END
select * from usersinroles

2017-11-06

Link to resource in Azure portal gives you Access denied?

Ever needed to create a link to a resource in the Azure portal and when you try it or send it to a colleague, they are faced with an "Access denied" message?

This can happen if the referred resource belongs to a subscription that not belongs to your currently selected directory.

A resource URL looks like the one below where the path to the resource is following the hash sign

https://portal.azure.com/#resource/subscriptions/aaaa.../resourceGroups/my/providers/Microsoft.Sql/servers/myserver/databases/mydb/overview


A resource belongs to a subscription (subscription id is part of the resource path) and a subscription is connected to a tenant (not part of the resource path).

When you navigate to the link, the currently active directory will be used to access the resource and if the subscription cannot be found in that directory, the "Access denied" message will be shown.

To fix this, first, check the name of the directory/tenant that the resource belongs to. (An easy way is to check what directory to use is checking the drop-down menu in the Azure portal.)

You can then create a complete URL on the following path

https://portal.azure.com/signin/index/@[directoryName]#[resourcePath] 


If the resource belongs to the d1.onmicrosoft.com directory, modify the link to go to

https://portal.azure.com/signin/index/@d1.onmicrosoft.com#resource/subscriptions/aaaa.../resourceGroups/my/providers/Microsoft.Sql/servers/myserver/databases/mydb/overview 

There you have a fully portable link that will work regardless where you currently are browsing the portal (provided that your account has access to the resource of course).

2014-12-28

Diskmaskin fixad (Whirlpool, IKEA)

Hade problem med vår diskmaskin, en inbyggd Whirlpool maskin inköpt från IKEA. När man skulle starta den så började den som vanligt, men efter någon minut så började den pipa och när man öppnade den så blinkade startlampan.

Kontrollpanelen för Whirpool DWH B00

Efter lite sökande på nätet så visade sig att man kan, utifrån antal blinkningar, utläsa en felkod och i mitt fall var det 11 blinkningar vilket enligt servicemanualen indikerade på fel "FA WI failure". Detta fel kan uppstå om vattenindikatorn inte slår på när vattnet spolas in i maskinen eller om det läcker ut vatten i maskinen och flottören slår ifrån. Efter att ha öppnat maskinen så fanns det dock inte något vatten i botten på maskinen så troligtvis så är det vattenindikatorn som inte ger rätt signal.
Vattenindikatorn sitter i botten på maskinen, under filterplåten och ser ut som ett svart litet galler. Enligt några drabbade på internet kunde man lösa detta genom att med en borste eller svamp, tvätta indikatorn från smuts el dyl. I mitt fall hjälpte inte detta något. Felet kvarstod när jag försökte köra maskinen igen.
För att verifiera att det var indikatorn som var problemet så kortslöt jag anslutningen så att den alltid gav en tillslagen signal (kretschemat i servicemanualen är till god hjälp för att förstå). (Spänning i mitten, tillslagen till höger och frånslagen till vänster)

Vattenindikatorn är markerad med WI i kretsschemat, LS6 är flottörens krets. PA6 A är kontakten till vattenindikatorn där 1,2,3 i kretsschemat motsvarar kontaktens anslutningar från v->h 2,1,3

Detta lät maskinens program komma vidare ända till fördisken, men när fördiskens vatten pumpats ut så kommer felet upp igen, då troligtvis för att indikatorn skall slå ifrån.
Dock så räckte fördiskens varma vatten för att lösa upp resterna av smutsen på indikatorn så efter jag kopplat tillbaka anslutningen till indikatorn så fungerade maskinen igen. Nu kanske den lever ett par månader till. En citronsyradisk är nog behövligt snart :)

Obs! Om du vill mecka med din egna maskin, se till att koppla bort strömmen från maskinen innan du öppnar den.

2014-02-20

Duplicate config sections in web.config and internal server errors...

There is a duplicate 'myApp' section defined, Internal Server Error

Had a breakdown moment today when two sites didn't behave. Each site had a virtual application with its own web.config.
Both sites where configured almost the same but one of the virtual applications worked and not the other.
The following error was displayed.

HTTP Error 500.19 - Internal Server Error

The requested page cannot be accessed because the related configuration data for the page is invalid.

Detailed Error Information:

Module           IIS Web Core
Notification     BeginRequest
Handler          Not yet determined
Error Code       0x800700b7
Config Error     There is a duplicate 'myapp' section defined
Config File      \\?\c:\MyApp\vapp\web.config
Requested URL    http://myapp:80/vapp/
Physical Path    c:\MyApp\
Logon Method     Not yet determined
Logon User       Not yet determined

Config Source:
    7:   <configsections>
    8:     <section name="myApp" type="MyApp.MyConfigSection, MyApp"/>
    9:   </configsections>

This configuration section was present in my site as well, like below.

<section name="myApp" type="MyApp.MyConfigSection,MyApp"/>

Removing the section from the vapp did the trick, but why did it work on the other site?

Finally after some testing and borrowing a second pair of eyes (thx robert), a small difference in the setup was detected. One of the sections where declared with the type "MyApp.MyConfigSection, MyApp" and the other with "MyApp.MyConfigSection,MyApp". Did you see it?! A small whitespace between the type and assembly names where missing.
After adjusting the typo to either keeping the whitespace or removing it on both, it started to work...

2013-10-14

Kernel mode caching and HttpHandlers

Ever tried to get a IHttpHandler delivered data to use the IIS (7) kernel mode cache?
Ever tried to get fancy and make the handler auto configured (ie managing it programmatically)?
Well, I tried a lot but gave up.

Basic setup to use IHttpHandler and kernel mode cache

1. To get a IHttpHandler deliver data (like images) and make it use the IIS kernel-mode cache, there are a few points that you must not miss. (http://support.microsoft.com/kb/817445)
2. You need to register the handler in the system.webServer/handlers section (http://msdn.microsoft.com/en-us/library/46c5ddfy(v=vs.100).aspx)
3. If you run a routed site (like a MVC site) you must Ignore the route of the handler (else it will not be added to the kernel cache).

Trying to get fancy and using any method below to minimize configuration (avoid step 2 above).
a. Adding the IHttpHandler using a IRouteHandler (http://www.mikesdotnetting.com/Article/126/ASP.NET-MVC-Prevent-Image-Leeching-with-a-Custom-RouteHandler)
b. Changing handler after the handler has been calculated (http://stackoverflow.com/questions/1888016/any-way-to-add-httphandler-programatically-in-net)
c. Using a IHttpHandlerFactory (same registration as point 2 but if you have several handlers you can configure one factory and redirect to each handler)
d. Modifying system.webServer/handlers programmatically doesn't even work. No API, no hacks, no nothing?! Didn't even find a way to read which handlers were registered in web.config without parsing it manually and backtracking inherited configuration files...

None of the fancy methods works (1-3 works but not with kernel cache). Problem is that kernel cache is disabled IF the handler is changed along the way. (http://blogs.msdn.com/b/tmarq/archive/2010/04/01/asp-net-4-0-enables-routing-of-extensionless-urls-without-impacting-static-requests.aspx)

So I'm back with a standard IHttpHandler, manually registering it in web.config and ignoring routes...

2013-06-11

Using a custom format in ImageVault to specify compression quality in jpg images

If you want to request an converted image in ImageVault and also specify the compression quality for a jpeg image you cannot use the LINQ syntax in the C# API of ImageVault. The LINQ engine only wraps some functions in the API and compression quality is not yet added to the mix.
To do this we need to use another approach and access the service methods directly (like the LINQ parser does internally).

First we need a client.
var client = ClientFactory.GetSdkClient();
Then we need to create a format that has the correct parameters.
//Compression quality can be set on ImageFormats where 100 is maximum quality
var format = new ImageFormat {
  CompressionQuality = 80,
  MediaFormatOutputType = MediaFormatOutputTypes.Jpeg;
};
format.Effects.Add(new ResizeEffect(200, 200));
Here we created an ImageFormat and specifies the Compression quality to 80 and specify that we want to have a jpeg output image. We can then add other effects to it, like in this case, a Resize effect.

Next step is to create a query that will be used to find the media we are looking for.
var q = new MediaItemQuery {
  //we filter out the items we want to retrieve (in this case media with id 485)
  Filter = { Id = new List<int> { 485 } },
  //and supply the format that we want to populate
  Populate = {
    MediaFormats = { format },
    PublishIdentifier = client.PublishIdentifier
  }
};
This query will filter out image with id 485 and populate the found MediaItem with the requested formats (in this case the one that we defined above).
Note that we have not created the format, this will be done automatically (or if a matching format is found, it will be reused). These format are called system formats and is not visible in the UI.
We also specify a publish identifier that will generate the urls to the converted media as public urls (no authentication).

This query is then passed to the service method.
//first create the media service channel
var mediaService = client.CreateChannel<IMediaService>();
//we then pass the query object to the find method
var mediaItem = mediaService.Find(q).Single();
//and since we only requested one Format, the converted image url will reside 
//in the first MediaConversion of the item.
var url = mediaItem.MediaConversions[0].Url;
When all is done we have the url to the converted (and published) jpeg image.
Note: the client.PublishIdentifier will be set if you are in an EPiServer site or if you configure it in the imagevault.client.config. You can also set this manually.

2013-05-16

Using the REST API of ImageVault

In ImageVault 4 we introduced a REST API for the ImageVault Core service. This will show a small example on how to use that.

Javascript

This example uses javascript and the javascript classes that are included in the ImageVault installation (in the UI). I will use the scripts that are located on the beta installation but you can use the ones that are on your installation as well.
We need three scripts, jquery, json2 and ImageVault.Client. ImageVault.Client handles authentication and CORS logic for the app.

<script type="text/javascript" src="https://demo2.imagevault.se/ImageVault/Scripts/lib/jquery-1.6.1.js" ></script>
<script type="text/javascript" src="https://demo2.imagevault.se/ImageVault/Scripts/lib/json2.js"></script>
<script type="text/javascript" src="https://demo2.imagevault.se/ImageVault/Scripts/ImageVault.Client.js"></script>

Client

To connect to core we need the javascript client.
var core = new ImageVault.Client({
    core: "https://demo2.imagevault.se",
    username: "sdkUser",
    password: "sdkKey",
    resourceOwnerUsername: "username",
    resourceOwnerPassword: "password",
    grant_type:"password"
});
There are several methods to authenticate to core, this shows the resource owner password credential grant method. See the authentication documentation for more information.
The core client is created using the following parameters
core
This is the url to the Core server
username/password
the username and password of the sdkuser.
resourceOwnerUsername/resourceOwnerPassword
the username and password of the user.
grant_type
The authentication mechanism to use. For Resource Owner password credential grant method, the value "password" is used.

Authentication

We use federated authentication and to be able to call the services you need to have an authentication ticket. To get the ticket you need to authenticate vs the IdentityProvider (Idp). This is handled by the ImageVault.Client script. Also reissuing of tickets when they exipire are handled by the client script.

API calls to Core

To call the REST API we use the json method of the client that performs an authenticated call to the core.

core.json("MediaService/Find", {
  Filter: { SearchString: searchString },
  Populate: {
    MediaFormats: [
      //Thumbnail format
      {  
        $type: "ImageVault.Common.Data.ThumbnailFormat,ImageVault.Common", 
        Effects:[
          {$type:"ImageVault.Common.Data.Effects.ResizeEffect, ImageVault.Common",Width:200,Height:200,ResizeMode:'ScaleToFill'}
        ]
      }
    ],
    PublishIdentitifier:"http://myserver.com/"
  }
}, function (d) {
  alert(JSON.stringify(d));
});
The json call uses the following parameters
path
The service/method to call. Name of the service is the same as the interface (omit the leading I)
arguments
the arguments to the service method as a javascript object. If the method takes multiple arguments, wrap them in a single object (.e {arg1:'test',arg2:'test2'} )
success callback
The function to call when the service call is done. The argument to the function is the return value of the service method.
The services are documented at the ImageVault API reference documentation. The full ImageVault developer documentation can be found at http://imagevault.se/doc

You can test a fully fledged demo below that uses the ImageVault demo site scripts and content that performs a search and displays the thumbnail media

Demo


2013-02-26

Keeping configuration transforms after applying EPiServer 7 Patch

When installing the patch 1 for EPiServer CMS 7 the installation instructions stated that you needed to turn of the msbuild build tasks that created the configuration files from different xml fragments using xml transform.
The patch also noted that you needed to manually replace the assembly redirect assembly versions directly in the web.config file.

Since I have grown quite fond of using the xml fragments to apply configuration changes for our entire development team I found a way to apply the patch changes by a simple xml transform.
Just edit the [Configuration]/Common/Web.Common.config and add the redirects that you need to fix.

<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:xdt="http://schemas.microsoft.com/XML-Document-Transform" xmlns:asm="urn:schemas-microsoft-com:asm.v1">
  <runtime>
    <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
      <dependentAssembly xdt:Transform="Replace" xdt:Locator="Condition(asm:assemblyIdentity/@name='EPiServer.Framework')">
        <assemblyIdentity name="EPiServer.Framework" publicKeyToken="8fe83dea738b45b7" culture="neutral" />
        <bindingRedirect oldVersion="1.0.0.0-7.0.859.4" newVersion="7.0.859.4" />
      </dependentAssembly>
    </assemblyBinding>
  </runtime>
</configuration>

The key here is that we replace the original assembly redirect with the new binding (with the new version number) and use a Locator attribute to find the correct node in the original web.config file to replace.

The tricky part in this case is that the assemblyBinding changes the default namespace. To get a correct xpath we need to use this namespace to locate the child node. To use a namespace in an xpath expression we first need to register it with a prefix (here using asm) in the root node (or somewhere in scope). We can then build a correct xpath expression (asm:assemblyIdentity/@name) to locate the node.

I will follow up with a final post stating all adjustments needed to get this to work.

Update/follow up
I completed the adjustments for both the Framework and CMS packages and collected the needed transforms in the following file.
https://dl.dropbox.com/u/10119568/EPiServerCms7Patch1/web.common.config
The installation instructions also stated that other assemblies could be present (in my case there wasn't) but if that's so in your case, just add those transformations to the list.)

Note: I needed to remove the old dll:s from the [Libraries] folder in the Alloy template or my VisualStudio didn't get the hint path correctly.

2011-06-24

Supporting Cross Origin Resource Sharing (CORS) requests in a WCF Rest service - Part 4

For background information, see part 1
For message inspection code, see part 2
For operation dispatcher code, see part 3

To include the message inspection in our wcf service for our service we need to bind it to our service. This is done using behaviors. To bind the IDispatchMessageInspector that works on the ServiceEndpoint level we need to implement an IEndpointBehavior and assign it to our endpoint.

To bind the IOperationInvoker and IDispatchMessageFormatter we need to implement the IOperationBehavior that works on the operation level.

This class also works as an attribute so you can decorate your contract and operations with it if need be. It also contains some properties if you want to control the header values.

https://github.com/dhvik/Wcf-cors-behavior/blob/master/CorsBehaviorAttribute.cs

Assigning can be done in many ways. By declarations on the contract (attribute), configuration in the applications configuration file or direct using code. I'm using code since I host the services in a windows service.
var host = new WebServiceHost2(serviceType, false,
new Uri(serviceBaseUrl + serviceName));
var endpoint = host.AddServiceEndpoint(contractType,
new WebHttpBinding(WebHttpSecurityMode.None), "");
//add support for cors (both for the endpoint to detect and create reply)
endpoint.Behaviors.Add(new CorsBehaviorAttribute());

foreach (var operation in endpoint.Contract.Operations) {
//add support for cors (and for operation to be able to not
//invoke the operation if we have a preflight cors request)
operation.Behaviors.Add(new CorsBehaviorAttribute());
}
When we now have all pieces in place we don't need to bother about cross domain requests. Our endpoint and operation behaviors handles this and we don't need to litter our service code with things that don't belong there anyway.

Supporting Cross Origin Resource Sharing (CORS) requests in a WCF Rest service - Part 3

For background information, see part 1
For message inspection code, see part 2

When a cors request is identified we now have the CorsState object available on our operation level and can act upon this. To be able to skip calling the service method in case of a preflight request we need to implement two extension points in the operation level. Message formatting and operation invocation. Since the preflight request won't have the correct requests message format, we skip the deserializing process and the message using the normal deserializator can results in errors. So in that case we skip both serialization and deserialization.

https://github.com/dhvik/Wcf-cors-behavior/blob/master/CorsFormatter.cs

When it comes to the IOperationInvoker we just skip invoking the operation if we have a preflight request. All other requests uses the original invoker.

https://github.com/dhvik/Wcf-cors-behavior/blob/master/CorsInvoker.cs


In the part 4 we will see how we bind all these extension points together for our service.

2011-06-23

Supporting Cross Origin Resource Sharing (CORS) requests in a WCF Rest service - Part 2

For a background in the subject please see part 1.

To handle Cors requests we can take advantage of the extensibility mechanism that is embedded in WCF. There are many extension points that we can use and the cors problem can be issued in more than one way.
There is a great article, written by Aaron Skonnard, published in the Dec 2007 issue of MSDN magazine, that covers many parts of this mechanism that I can recommend for further reading.

Our main problem is that we need to analyse the incoming request and determine if it is a cors preflight request, cors normal request or a normal request. Preflight request should not reach the service method at all since we don't want to invoke the service method. The other requests should invoke the method but for all cors responses we should add extra headers.

I found a reply by Carlos Figueira in a msdn thread that solves the "analyse message and skip invoking the service method"-part that I used as a start. (as a side note Richard Blewett stated (in the same thread) that we could intercept the message before it reaches the dispatcher by writing a protocol channel. I will leave that excercise to the reader or for another day.)



So to analyse the incoming message we use the first avaliable extension point in the dispatcher (server) that would be the Message inspection stage.
To use this extension point we implement an IDispatchMessageInspector. In this stage we are at the service endpoint and haven't decided wich method of the service that we should call. What we do here is that we look for a cors request (checking if the Origin header is present). If so we create a Cors state object and adds a property to the message so we can use this later on in other extension points.
If it is a preflight request we also create a response message that we should use.
When the message inspector receives the reply we add the headers (and if it was a preflight request, we replace the whole response.

In part 3 we will continue to the operation level where we check the property and omits the call to the service method in case of a preflight request.

View code on github https://github.com/dhvik/Wcf-cors-behavior/blob/master/CorsDispatchMessageInspector.cs

In part 3 we will see how we handle things on the operation level.

Supporting Cross Origin Resource Sharing (CORS) requests in a WCF Rest service - Part I

If you use try to consume a rest service using JavaScript (jquery) and are using chrome or Firefox (newer versions) you might run into cross domain issues that involves the w3c standard for accessing resources on other domains (or ports).

In short, if you try to request a resource on a page using javascript(call a rest service) that is located on another domain/port then the XMLHttpRequest object that is implemented in the browser will first try to discover if the cross-origin resource is prepared/allows to accept requests from the origin. If this preflight request succeeds then the real request will be fired.
All this is handled by the browsers XMLHttpRequest object so from the JavaScript side this don't affect the code.

The server side code must support cors though.

An example: we take a simple service method that only returns a string.

public string Hello() {
return "Hello!";
}

If we want to allow cors requests, then we must detect the preflight request and reply accordingly and if it is the real request we should perform the actual method.

public string Hello(){
//for all cors requests
WebOperationContext.Current.OutgoingResponse.Headers
.Add("Access-Control-Allow-Origin","*");
//identify preflight request and add extra headers
if (WebOperationContext.Current.IncomingRequest.Method == "OPTIONS") {
WebOperationContext.Current.OutgoingResponse.Headers
.Add("Access-Control-Allow-Methods", "POST, OPTIONS, GET");
WebOperationContext.Current.OutgoingResponse.Headers
.Add("Access-Control-Allow-Headers",
"Content-Type, Accept, Authorization, x-requested-with");
return null;
}
return "Hello!";
}

Basically we first add the Access-Control-Allow-Origin header telling that we allow any origins (we can also specify an origin that matches the origin that the request comes from). Then we check if the request is a preflight request (method is OPTIONS). If it is we add extra headers to declare which methods and headers that we allow the real request to contain. There are a few more access-control headers that we can add if we need and these are described in the w3c spec.

To add this code in every method is not a great solution but in part 2 we will see how we can use WCF extensibility to do this in a more elegant way.

2011-01-02

Alpha version of IOU tracker

Have been experimenting with .net4, MVC2 and EntityFramework. The result is a IOU tracker that is available at iou.nu.
Service is free of charge and feel free to try it out.

I know there are several other iou trackers out there, but since the most lack some features and I needed to dig deeper into net4/mvc2/EF anyway so why not give it a try!?

/Dan

2010-06-09

TF53010: Errors in the metadata manager when rebuilding OLAP cube

I was adding new fields to our TFS workitems the other day and wanted them to be part of the olap cube. But when I added them (marked them with the reportable attribute) and processed the cube, i couldn't see them in the cube.
I went reading the Event log on the Data tier of the TFS and found log entries like below

TF53010: The following error has occurred in a Team Foundation component or extension:
Date (UTC): 2010-06-09 09:31:26
Machine: MyTfsServer
Application Domain: /LM/W3SVC/830720315/Root/Warehouse-3-129205184075330719
Assembly: Microsoft.TeamFoundation.Warehouse, Version=9.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a; v2.0.50727
Process Details:
Process Name: w3wp
Process Id: 9316
Thread Id: 8484
Account name: DOMAIN\admin

Detailed Message: The pending configuration changes were not successfully added to
the cube because of the following error: Microsoft.AnalysisServices.OperationException:
XML parsing failed at line 1, column 0: A document must contain exactly one root element.
.
Errors in the metadata manager. An error occurred when instantiating a metadata
object from the file, '\\?\C:\Program Files\Microsoft SQL Server\MSSQL.3\OLAP\Data
\TfsWarehouse.0.db\Team System.63837.cub.xml'.

at Microsoft.AnalysisServices.AnalysisServicesClient.SendExecuteAndReadResponse(ImpactDetailCollection impacts, Boolean expectEmptyResults, Boolean throwIfError)
at Microsoft.AnalysisServices.AnalysisServicesClient.Alter(IMajorObject obj, ObjectExpansion expansion, ImpactDetailCollection impact, Boolean allowCreate)
at Microsoft.AnalysisServices.Server.Update(IMajorObject obj, UpdateOptions options, UpdateMode mode, XmlaWarningCollection warnings, ImpactDetailCollection impactResult)
at Microsoft.AnalysisServices.Server.SendUpdate(IMajorObject obj, UpdateOptions options, UpdateMode mode, XmlaWarningCollection warnings, ImpactDetailCollection impactResult)
at Microsoft.AnalysisServices.MajorObject.Update(UpdateOptions options, UpdateMode mode, XmlaWarningCollection warnings)
at Microsoft.AnalysisServices.MajorObject.Update(UpdateOptions options)
at Microsoft.TeamFoundation.Warehouse.OlapCreator.CreateOlap(WarehouseConfig whConf, String accessUser, String[] dataReaderAccounts, Boolean dropDB, Boolean processCube)
at Microsoft.TeamFoundation.Warehouse.AdapterScheduler.EnsureCubeIsUpToDate()

For more information, see Help and Support Center at http://go.microsoft.com/fwlink/events.asp.


I searched the path in the error message above (on the database server, little bit confusing since the path seems to be a local one, but since the error originates from the database server it makes sense).

I the folder i found several files named Team System.xxxx.cub.xml (where xxxx is digits) and some where 0 bytes long and some contained error xml (seemed to be cut in the middle of writing).
I removed these files and processed the cube again. (I used the Warehousecontroller webservice on the tfs server, https://mytfsserver:8143/Warehouse/v1.0/WarehouseController.asmx?op=Run)

This time the processing went through without any problems and the fields apperared in my cube.

I wonder how these error xml files where created, perhaps if the cube was instructed to reprocess while the iis was restarted or for some other reason.
I'll better check the folder once and a while....

2010-05-21

Writing a custom trigger for CruiseControl.Net

I’ve had some problems regarding the triggering of our nightly builds and has not been able to solve this with the standard triggers that comes with ccnet (using v1.4.4). So finally I took some time (night time, kids are sleeping and there is peace and quiet in the house :) and wrote a trigger that fixed the issue at hand. I will come to the trigger in just a moment but I’ll take some time to explain our ccnet setup.

Interval builds

All our projects are built using CruiseControl.Net and for each project we setup two builds. One interval build that triggers when the source has been changed. This build uses the intervalTrigger that checks the source repository at regular intervals. The interval build compiles the project and performs unit testing and provides feedback to the programmer. This is quite standard setup I guess so there’s no rocket science here…

Nightly builds

The other build is a nightly build that (in addition to the tasks for the interval build) performs analysis, generates documentation and installation packages ready to deploy to a target system. It also creates deliverable folder in a specific ”drop zone” for the project. This folder is named using the label of the nightly build.

Labels

All projects uses the project name and build no as label (like MyProject – 234). We reuse the build number of the interval build for the nightly build so if the last interval build was ”MyProject – 234”, the next time the nightly build is triggered the label for the nightly build is ”MyProject-Nightly – 234”. This creates a side effect that if the nightly build is triggered twice and the interval build hasn’t been triggered, the label is the same. If you remember the drop zone folder it uses the label of the nightly build as a name so if it is triggered twice using the same label, it tries to overwrite the drop zone folder.

This is a big no-no and to solve this I added a nant task that fires in the beginning of the nightly build that fails if the drop zone folder exists.

Triggering the nightly build

So to avoid failing the nightly build, it should only be triggered if an interval build has successfully been completed and the nightly build for that interval build hasn’t been performed.

Normally nightly triggers are defined to run at a specific time every night using a ScheduleTrigger that fires at that time if any changes to the source has been made during the day. In combination with a projectTrigger that only fires if the interval build has been successful we can accomplish the trigger to fire only if the interval is successful.

Problem with projectTrigger

There is a limitation to the projectTrigger that affects our setup severely. It doesn’t remember the project status after the ccnet server has restarted. This causes the nightly projects to be rebuilt after a server restart. Since we have lots of projects on the server, we need to restart it atleast once a week and this causes the nightly builds to fail.

New trigger?

To solve this we need another way to trigger the build. The first solution that comes to mind is if there was a trigger that could check if a specific folder (the drop zone folder in this case) was missing, we could replace the projectTrigger with a ”Missing folder trigger”.

Perhaps could look like this.

<missingFolderTrigger path="Z:\PublishedBuilds\MyProject-Nightly\1.0.0.234"/>
Problem here is that we don’t know the last folder name. The ”1.0.0.234”comes from the file version for our compiled assemblies.
The first part of the version number can be any numbers and the 234 refers to the build number of the project. This changes for each build so we need a way to look this up.

Final design

I speak highly of the KISS principle, but I’m also great at creating generic things and sometimes this ”generic gene” gets some overhand (and sometimes also get way out of hand, but I think I found a good level this time :)
So the final design was a trigger syntax like below:
<fileExistsTrigger
triggerOnMissing="true"
seconds="30"
path="Z:\PublishedBuilds\MyProject-Nightly\"
match="\d+\.\d+\.\d+\.[Regex.Match([Projects(MyProject).LastSuccessfulBuildLabel],\d+$)]"/>
  • This trigger is now a more ”generic” one allowing us to trigger on both files and directories.
  • The ”triggerOnMissing” allows us to trigger on both exists and not exists.
  • It’s based on an interval trigger and the ”seconds” parameter allows us to set on how often a file/directory should be checked.
  • The ”path” allows us to set the path that should be checked. If we only need to check a file/directory that we know the name of, this is enough. End with a \ to check a directory.

The match attribute

The ”match” allows us to perform a regular expression search that searches for a match for a directory/file in the supplied path.
  • The \d+ will match one or more digits
  • The \. will match a dot.
  • Repeating this match three times \d+\.\d+\.\d+\. will match the ”1.0.0.” part of the file (or any other valid version major.minor.build. combo)
Now the fun part begins :). I added some syntax to perform string operations and replacements. Any valid command is surrounded by [] and are replaced before the file/directory regex matching begins.
In this example we have two commands, one property and one method call. Properties are evaluated first so [Projects(MyProject).LastSuccessfulBuildLabel] allows us to instruct the trigger to lookup the project named ”MyProject” and return the LastSuccessfulBuildLabel. In this case this would be ”MyProject – 234”.
The method call would look like this after the property replacement. [Regex.Match(MyProject - 234,\d+$)]. This will call the Regex.Match method and return the matched pattern. In this case \d+$ will match the last digits in the ”MyProject – 234” and would return ”234”.
So the final match attribute will be \d+\.\d+\.\d+\.234 and would in this case find a folder named 1.0.0.234. If the folder is missing, the build will be triggered, but if it is there, no trigger is fired. If the interval build fires and is successful, the LastSuccessfulBuildLabel increases and is 235. When the nightly build is triggered again, the folder is missing and the trigger will fire.

Writing the trigger

The first part to think about when writing extensions/plugins to ccnet is that the assembly must be named ccnet.*.plugins.dll otherwise ccnet wont find it. Secondly, the trigger class needs to be decorated with the ReflectionTypeAttribute to tell the class what the xml element name for the class looks like (like [ReflectorType("fileExistsTrigger")]). Every field/property that should be read into the trigger should also be decorated with the ReflectorPropertyAttribute (like [ReflectorProperty("path", Required = true)]). I mainly looked at the ProjectTrigger that comes with ccnet and added stuff along the way.
Beware that the code don't fix all special cases, like if your project name contains , ) or other characters that messes up the expressions, but this is what I needed (remember TDD).
You can use the code below as it is or modify it to your needs. If you want the whole solution (including unit tests), just drop me a mail.
Good luck :)
using System;
using System.Collections.Generic;
using System.Linq;
using ThoughtWorks.CruiseControl.Remote;
using ThoughtWorks.CruiseControl.Core.Triggers;
using ThoughtWorks.CruiseControl.Core.Util;
using Exortech.NetReflector;
using System.Text.RegularExpressions;
using System.IO;

namespace Meridium.CruiseControl.Net.Triggers {
[ReflectorType("fileExistsTrigger")]
public class FileExistsTrigger : IntervalTrigger {
/// <summary>
/// If the trigger should be active if the file is missing, default is false
/// </summary>
[ReflectorProperty("triggerOnMissing", Required = false)]
public bool TriggerOnMissing;
/// <summary>
/// The url to the ccnet server, defaults to a local ccnet installation tcp://localhost:21234/CruiseManager.rem
/// </summary>
[ReflectorProperty("serverUri", Required = false)]
public string ServerUri;
/// <summary>
/// The file/directory path to see if it exists or not
/// </summary>
[ReflectorProperty("path", Required = true)]
public string Path;
/// <summary>
/// Optional parameter to use if a regular expression should be used to match a file or directory in the path. Default is an empty string.
/// </summary>
[ReflectorProperty("match", Required = false)]
public string Match;

private readonly ICruiseManagerFactory _managerFactory;

public IDictionary<string, ProjectStatus> ProjectStatus {
get {
lock (ProjectStatusLock) {
//if cache has timed out, clear cache.
if (_projectStatus != null && DateTime.Now > _cacheValidUntil) {
_projectStatus = null;
Log.Debug("Cache was deleted, was valid until " + _cacheValidUntil.ToString("yyyy-MM-dd HH:mm:ss,fff"));
}
if (_projectStatus == null) {
Log.Debug("Updating ProjectStatus cache from server: " + ServerUri);
_projectStatus = new Dictionary<string, ProjectStatus>();
foreach (ProjectStatus status in _managerFactory.GetCruiseManager(ServerUri).GetProjectStatus()) {
_projectStatus.Add(status.Name, status);
}
_cacheValidUntil = DateTime.Now + CacheTime;
Log.Debug("Cache valid until " + _cacheValidUntil.ToString("yyyy-MM-dd HH:mm:ss,fff"));
}
return _projectStatus;
}
}
}
private static DateTime _cacheValidUntil = DateTime.MinValue;
private static readonly TimeSpan CacheTime = new TimeSpan(0, 10, 0);
private static Dictionary<string, ProjectStatus> _projectStatus;
private static readonly object ProjectStatusLock = new object();

public FileExistsTrigger()
: this(new DateTimeProvider(), new RemoteCruiseManagerFactory()) {
}

public FileExistsTrigger(DateTimeProvider dtp, ICruiseManagerFactory managerFactory)
: base(dtp) {
ServerUri = "tcp://localhost:21234/CruiseManager.rem";
_managerFactory = managerFactory;
}
public override IntegrationRequest Fire() {
//only check on intervals
if (base.Fire() != null) {
try {
Log.Debug(string.Format("More than {0} seconds since last integration, checking url.", IntervalSeconds));
if (FileExists() != TriggerOnMissing) {
Log.Debug("Trigger matched, fire IntegrationRequest");
return new IntegrationRequest(BuildCondition, Name);
}
} catch (Exception ex) {
Log.Error(ex);
} finally {
IncrementNextBuildTime();
}
}
return null;
}
private string HandleProjectPropertyMatches(Match m) {
string projectName = m.Groups["projectName"].Value;
string property = m.Groups["property"].Value;
var ps = GetCurrentProjectStatus(projectName);
switch (property.ToLower()) {
case "lastsuccessfulbuildlabel":
return ps.LastSuccessfulBuildLabel;
case "name":
return ps.Name;
case "buildstatus":
return ps.BuildStatus.ToString();
default:
throw new NotImplementedException(string.Format("Support for property {0} is not implemented yet!", property));
}
}
private static string HandleRegexMatchMatches(Match m) {
string input = m.Groups["input"].Value;
string pattern = m.Groups["pattern"].Value;
Match match = Regex.Match(input, pattern);
return match.Success ? match.Value : string.Empty;
}
private bool FileExists() {
string fp = TranslateValue(Path);
string match = TranslateValue(Match);
if (!string.IsNullOrEmpty(match)) {
if (!fp.EndsWith(@"\"))//"
fp += @"\";//"
var dir = new DirectoryInfo(fp);
if (!dir.Exists) {
Log.Debug(string.Format("Matching path {0} failed. Directory does not exist.", fp));
return false;
}
foreach (var fs in dir.GetFileSystemInfos().Where(fs => Regex.IsMatch(fs.Name, match))) {
Log.Debug(string.Format("Match successful with fileSystemInfo {0}", fs.FullName));
return true;
}
Log.Debug(string.Format("No match for {0}", match));
return false;

}
bool isDirectory = fp.EndsWith(@"\");//"
bool exists = isDirectory ? Directory.Exists(fp) : File.Exists(fp);
Log.Debug(string.Format("Checking if {0} {1} exists: {2}", (isDirectory ? "directory" : "file"), fp, exists));
return exists;
}
/// <summary>
/// Translates the supplied value and expands all methods and variables
/// </summary>
/// <param name="val">The value to translate</param>
/// <returns>The translated value</returns>
private string TranslateValue(string val) {
//handle replacements...
//like [Projects(MyProject).LastSuccessfulBuildLabel]
val = Regex.Replace(val, @"\[Projects\((?<projectName>[^\)]+)\)\.(?<property>[^\]]+)\]", HandleProjectPropertyMatches, RegexOptions.IgnoreCase);
//handle Regexp
//like [Regex.Match(string,pattern)]
val = Regex.Replace(val, @"\[Regex\.Match\((?<input>[^,]+),(?<pattern>[^\)]+)\)\]", HandleRegexMatchMatches, RegexOptions.IgnoreCase);

return val;
}
private ProjectStatus GetCurrentProjectStatus(string project) {
if (!ProjectStatus.ContainsKey(project)) {
throw new NoSuchProjectException(project);
}
return ProjectStatus[project];
}
}
}

2010-04-13

Adding buttons to the TinyMce editor in EPiServer 6 programmatically

While developing ImageVault we needed to add buttons to insert images in the TinyMce editor that ships with EPiServer CMS 6. The buttons are decorated with a TinyMCEPluginButtonAttribute and they can be added manually by entering admin mode and modifying the settings for the XHTML property (see Oskars post "Add functionality to the rich text editor in EPiServer cms 6").
To do this by code this was rather simple since EPiServer has a great Api (even that it could contain more examples)...

The settings are stored in the EPiServer database and is accessed using the PropertySettingsRepository class.
To get the current settings for the TinyMce editor, just create a repository and use the GetDefault method

PropertySettingsRepository p = new PropertySettingsRepository();
PropertySettingsWrapper defaultSettings = p.GetDefault(typeof(TinyMCESettings));

The PropertySettingsWrapper contains metadata for the settings, like DisplayName, Id etc.
It also contains the specific setting for the TinyMce editor in the property PropertySettings.

To create a new settings for the TinyMce editor with some buttons added we can use the code below.

PropertySettingsRepository p = new PropertySettingsRepository();
PropertySettingsWrapper defaultSettings = p.GetDefault(typeof(TinyMCESettings));

//create a copy of the default settings
PropertySettingsWrapper copy = defaultSettings.Copy();
copy.DisplayName+="(My copy)";
TinyMCESettings settings = copy.PropertySettings as TinyMCESettings;

//create a new toolbar row for my buttons
ToolbarRow row = new ToolbarRow();
settings.Toolbars.Add(row);

//the buttons are added using the name defined in the
//ButtonName property of the TinyMCEPluginButtonAttribute.
row.Buttons.Add("mybutton1");
row.Buttons.Add("separator");
row.Buttons.Add("mybutton2");

//Save the copy as a global setting
p.SaveGlobal(copy);

//Set it as default
p.SetDefault(copy.Id);

Quite easy. The separator button is included in EPiServer 6 the others need to be added using classes decorated with the TinyMCEPluginButtonAttribute.

Note!
To actually use this code in EPiServer you need to insert it somewhere :) The easiest way is to create a InitializationModule class (implement IInitializaionModule and mark it with the InitializationModuleAttribute). Then in the Initialize method, add your code to the InitializationEngine.InitComplete event.

public void Initialize(InitializationEngine context) {
context.InitComplete+=(sender,args)=> MyInstallButtonsMethod();
}

More about EPiServer Initialization on episerver world.

2009-10-16

Converting word doc to pdf in CruiseControl.Net

Today I needed to automate the process where we create PDF files out of our client documentation in Microsoft Word format. Since up to now we have done it manually by opening the document and printing to a installed PDF printer. Quite tedious work.
After some searching I start to realize that there are many PDF creators around; and most of them uses Ms word and prints using a pdf printer (just as we use to do, but by automation).
The problem is that I don't want to install printers or the Ms Office package on our build server (if I can avoid it). Another wish is that the solution should be free and open source.
So finally I stumbled upon this article by Graham J. Williams that uses OpenOffice.org to do the conversion and a custom macro to allow the conversion to be called from the commandline.
REM  *****  BASIC  *****

Sub ConvertWordToPDF(cFile)
cURL = ConvertToURL(cFile)

' Open the document.
' Just blindly assume that the document is of a type that OOo will
' correctly recognize and open -- without specifying an import filter.
oDoc = StarDesktop.loadComponentFromURL(cURL, "_blank", 0, Array(MakePropertyValue("Hidden", True), ))

cFile = Left(cFile, LastIndexOf(cFile,".")) + "pdf"
cURL = ConvertToURL(cFile)

' Save the document using a filter.
oDoc.storeToURL(cURL, Array(MakePropertyValue("FilterName", "writer_pdf_Export"), ))

oDoc.close(True)

End Sub

Function LastIndexOf ( cText as String, cMatch as String) As Integer
lastIndex = 0
pos=0
Do
pos = InStr(pos+1,cText,cMatch)'
If pos >0 Then
lastIndex = pos
End If
Loop While pos >0
LastIndexOf()=lastIndex
End Function

Function MakePropertyValue( Optional cName As String, Optional uValue ) As com.sun.star.beans.PropertyValue
Dim oPropertyValue As New com.sun.star.beans.PropertyValue
If Not IsMissing( cName ) Then
oPropertyValue.Name = cName
EndIf
If Not IsMissing( uValue ) Then
oPropertyValue.Value = uValue
EndIf
MakePropertyValue() = oPropertyValue
End Function
I modified it a bit to allow correct handing of extensions.
To call it from a windows command prompt just enter
c:\program files\OpenOffice.Org 3\Program\swriter.exe -invisible "macro:///Standard.Module1.ConvertWordToPDF(c:\temp\My word document.doc)"
I incorporated it in our cruisecontrol.net nant scripts so all solutions that needs pdf conversions in the build chain can have it.
Great!

2010-05-11 Update: Fixed a bug with filenames that contained more than one period (.), added the LastIndexOf function