Posts tagged "cq5"

May 22, 2017

AEM – Circuit Breaker Innovation via a Hystrix Integration

From time to time the Adobe Partner Experience (APx) team has the privilege to check out some truly innovative stuff. Yogesh is a great guy and offered to show us a cool integration pattern that he has been working on. We liked it so much that we decided to let him share it with the world via the Content Management blog. – Darin Kuntze @DarinKuntze

Yogesh Kulkarni is an experienced Adobe AEM/CQ developer/architect specializing in best practices for designing connected digital experience using AEM technology stack.

He is currently working for AKQA (a digital agency) as a Senior Software Engineer (Adobe AEM/CQ).

AEM 6.1 SP1 – Hystrix Integration

We had a requirement where the client wanted to add the Circuit Breaker pattern to an AEM component which calls RESTful endpoints, to support the following use cases:

  • If more than 10% of the calls fail within a minute then the circuit should trip.
  • After the circuit is tripped, the system should periodically check if the external service API is back up and working using a background thread.
  • The component prevents users from experiencing a sub-optimal response time.
  • The component should present a user-friendly message in case of any service failure.

Circuit Breaker Pattern

In our case, the component is responsible for making a call to RESTful endpoints to register/log in the user and provide an option to update related data/content after login.

The Circuit Breaker pattern can handle remote resources and service call failures more gracefully. It can prevent an application from repeatedly trying to execute an operation that’s likely to fail, allowing it to continue without waiting for the fault to be fixed or wasting CPU cycles while it determines that the fault is long lasting. The Circuit Breaker pattern also enables an application to detect whether the fault has been resolved. If the problem appears to have been fixed, the application can try to invoke the operation once again.

Hystrix  (a Netflix library) has a built-in ready-to-use Circuit Breaker. When we apply a Circuit Breaker to a method, Hystrix watches for failing calls to that method, and if failures build up to a pre-defined threshold, Hystrix opens the circuit so that subsequent calls automatically fail. While the circuit is open, Hystrix redirects calls to the method, and they’re passed on to a specified fallback method.

Reference: https://docs.pivotal.io/spring-cloud-services/1-3/common/circuit-breaker/

OSGi Dependencies

In order to get Hystrix running in AEM, you need to install the following dependency bundles in Felix.

Artifact ID(s) Version
1 org.apache.servicemix.bundles.hystrix 1.5.9_1
2 org.apache.servicemix.bundles.hystrix-event-stream 1.5.9_1
3 rxjava 1.2.9
4 org.apache.servicemix.bundles.commons-configuration 1.9_2
5 com.diffplug.osgi.extension.sun.misc 0.0.0
6 HdrHistogram 2.1.9

 

Apply the Circuit Breaker Pattern

Netflix Hystrix looks for any method annotated with the @HystrixCommand annotation and wraps that method in a proxy connected to a Circuit Breaker so that Hystrix can monitor it.

The code to be isolated is wrapped inside the run() method of a HystrixCommand similar to the following:

import com.netflix.hystrix.HystrixCommand;

 

public class HelloServiceGetCommand extends HystrixCommand<HelloResult> {
 


private final HttpGet httpGet;


    public HelloServiceGetCommand(final HttpGet httpGet) {


     super(HystrixCommandGroupKey.Factory.asKey("HelloGroup"));
 
     LOG.debug("Is CC breaker open " + isCircuitBreakerOpen());
      this.httpGet = httpGet;
    }
 
 
    @Override
    protected HelloResult run() throws IOException {

       //your logic goes here
        CloseableHttpClient httpClient = HttpClientBuilder.create().build();


        LOG.debug("Health count : TotalRequests " + metrics.getHealthCounts().getTotalRequests());        

               //call httpClient.execute

 

               //catch any error and populate HelloResult object

               return helloResult;

 

}

Fallback

To handle the failure of external services, Hystrix has built in the following defaults:

  1. Timeout for every request to an external system (default: 1000 ms)
  2. Limit of concurrent requests for external system (default: 10)
  3. The Circuit Breaker to avoid further requests (default: when more than 50% of all requests fail)
  4. Retry of a single request after the Circuit Breaker has triggered (default: every 5 seconds)
  5. Interfaces to retrieve runtime information at the request and aggregate level (there’s even a ready-to-use realtime dashboard for it) * Yet to be defined in OSGi.
    How-To-Use#Fallback

Simple Fallback method using Fallback: Stubbed pattern:

@Override
protected HelloResult getFallback() {
 


         LOG.debug("FALLBACK : is CC breaker open {} isResponseTimedOut() {}             isResponseTimedOut() {}",  isCircuitBreakerOpen(), isResponseTimedOut(),isResponseThreadPoolRejected());

LOG.debug("Health count : TotalRequests {} ErrorPercentage {} ErrorCount {}", metrics.getHealthCounts().getTotalRequests()
            , metrics.getHealthCounts().getErrorPercentage()
,metrics.getHealthCounts().getErrorCount());

 

// returns error object to service to send it to FE


return getHelloResultError();
}

The fallback method returns the error code which is then consumed by a UI component.

How to Run Hystrix Command

There are many ways to run the command. Following simple call is triggered from HelloServiceImpl to invoke Hystrix command.

public class HelloServiceImpl implements HelloService {

 

  private callCommand(){

    new HelloServiceGetCommand(getRequest).execute();

   //other service logic goes here

 }

}

Hystrix Runtime Configuration

Configuring a Hystrix command details can be found here: Hystrix Configuration. It is simple to update the configuration.

For example, the default value for circuitBreaker.requestVolumeThreshold is set to 20. We override the property using HystrixCommandProperties.Setter, as shown below.

public HelloServiceGetCommand(final HttpGet httpGet) {
    super(Setter
            .withGroupKey(HystrixCommandGroupKey.Factory.asKey("HelloGroup "))
            .andCommandPropertiesDefaults(
                    HystrixCommandProperties.Setter()
                       .withCircuitBreakerRequestVolumeThreshold(MyAudiConstants.CB_REQUEST_VOLUME_THRESHOLD)
                            .withCircuitBreakerErrorThresholdPercentage(MyAudiConstants.
                                    CB_REQUEST_ERROR_THRESHOLD)).
                    andThreadPoolPropertiesDefaults(HystrixThreadPoolProperties.Setter().
                            withCoreSize(MyAudiConstants.CB_REQUEST_THREAD_POOL_SIZE)).
                    andCommandKey(HystrixCommandKey.Factory.asKey("HelloGroup ")));

 

…

}

Monitoring

A dashboard for monitoring applications using Hystrix is available in the hystrix-dashboard module. However, hystrix-dashboard has not been deployed to our AEM instance at this time.

1) Circuit Breaker is close at the start

DEBUG [hystrix-HelloGroup2] com.akqa…services.commands.HelloServiceGetCommand CC breaker open false

All HelloCommand requests are going through.

2) Now FAILURE occurs

DEBUG [hystrix-HelloGroup-2] com.akqa…services.commands.HelloServiceGetCommand CC breaker open True Events[SHORT_CIRCUITED]

3) Lastly, CB is closed once host is back online.

DEBUG [hystrix-HelloGroup-2] com….services.commands.HelloServiceGetCommand CC breaker open false

The example above just scratches the surface of how to improve the Service Resilience in a Felix container using Hystrix. The following resources can provide more advanced tricks to help make your application more fault tolerant.

https://github.com/Netflix/Hystrix/wiki/How-To-Use#Fallback

https://github.com/Netflix/Hystrix/wiki/How-To-Use#Common-Patterns

https://github.com/Netflix/Hystrix/wiki/Configuration

https://github.com/Netflix/Hystrix/wiki/Metrics-and-Monitoring

Summary

As demonstrated, it is possible to use the state-of–the-art, industry standard fault-tolerance library Hystrix in AEM to protect your service against cascading failures and to provide fallback behavior for potentially failing calls.

 

All opinions expressed by Yogesh Kulkarni and are his own and not Adobe’s.

 

11:14 AM Permalink
May 5, 2017

Generate Rockstar AEM Logs Metrics with R Programming Utility

Today’s Tips & Tricks guest post comes from Atish Jain, who is a Senior AEM Developer at SapientRazorfish (Publicis.Sapient) with over seven years of experience working with CMS based applications. Atish was a semi-finalist in this year’s AEM Rockstar competition. 

The tool I’m sharing is an R programming based utility to find gaps in renditions versus assets uploaded. This can be helpful in asserting the Bulk Migration, Longevity Tests success, Upload Activities, and Comparative Analysis in your AEM instance.

For those unfamiliar with R programming, it is a free open-source language and environment used for data manipulation, calculations, statistical computing, and graphical techniques useful to statisticians, analysts, data miners, and other researchers.  To learn more about R, visit r-project.org.

Trend analysis for upload vs. workflow completion and systems experience an increase in slowness with time. The stats can be analyzed to find missing assets reports and degradation in AEM server performance under continuous load. It works on the logs that are produced under crx-quickstart folder of AEM. Hence, there is no direct performance impact on the AEM instance. Also, reports can be generated over historical log files to produce and find comparative results, and do an analysis.

The utility helps you:

  • Analyze the exact count of missing Assets Renditions with the upload path that has been missed.
  • Conduct trend analysis for uploaded assets versus renditions generation. The pace of renditions generation can be calibrated for better insights for estimating activity timings and degradation factors.

The AEM logs are powerful and transformable to produce vital statistics. This utility, based on R programming language utilizes this power and generates metrics.

Here is how the utility works:

Step1: parses error.log(s) to subset log lines with date time – A

Step2: parses A to find log lines for upload trigger –B

Step3: parses A to find log lines for last rendition – C

Step4: merges B & C to create reports.

The concept detailed above can be enhanced into a more exhaustive application that can create extensive reports from AEM logs.

For example, the utility can be extended to generate more detailed graphical reports via the graphical plugin API available for R.

If you have any questions, you can contact Atish at ajain216@sapient.com. All opinions expressed by Atish Jain and are his own and not Adobe’s.


 

# R SCRIPT TO FIND ASSETS UPLOADED AND ANALYSE CORRESPONDING THE RENDITIONS GENERATION COUNT
#USER INPUTS
QUICKSTART_LOGS_DIR <- "D:/Atish/aem-rock/output/logs"
OUTPUT_DIR <- "D:/Atish/aem-rock/results/day1"
print_renditions_gap_flag <- TRUE
upload_report_print_flag <- TRUE
UPLOAD_TRIGGER_TXT_PATTERN <- "*EXECUTE_START*"
RENDITION_LOG_TXT_PATTERN<- "jcr:content/renditions/cq5dam.web.1280.1280.jpeg"
 
# DO NOT CHANGE THIS LINE
ERROR_LOGS_FILE_PATTERN <- "error\\.log\\.\\d\\d\\d\\d*"
 
renditions_gap <- 0
 
#LIST ALL ERROR LOG FILES UNDER crx-quickstart
error_log_files_list <- function(QUICKSTART_LOGS_DIR) {
setwd(QUICKSTART_LOGS_DIR)
error_file_list <- list.files(pattern = ERROR_LOGS_FILE_PATTERN)
error_file_list <- unlist(list(error_file_list,list.files(pattern = "error.log$")))
error_file_list
}


upload_report_calculation <- function(logs_dir){
setwd(logs_dir)
dataset_upload <-NULL
dataset_workflowstart <-NULL
error_log_combined <- NULL
 
for (file in error_file_list){
print(paste("Analysing log file : ", file, sep=""))
error_log_full_dataset <- NULL
error_log_subsetdate_dataset <- NULL
dataset_x <- NULL
error_log_full_dataset <- read.table(file, header=FALSE, quote="", fill=TRUE)
colnames(error_log_full_dataset) <- c("date", "time", "level", "type", "class" , "logtext1", "logtext2", "logtext3", "assetPath")
 
#Filter rows which contains date only and assign it to error_log_subsetdate_dataset
error_log_subsetdate_dataset <- subset(error_log_full_dataset, grepl("\\d\\d.\\d\\d.\\d\\d\\d\\d", date))
write.csv(file="dataset.csv", x=error_log_subsetdate_dataset)
 
#Filter rows which contains *EXECUTE_START* and */content/dam/*
#Refine the above dataframe to contain only asset upload trigger log.
upload_trigger <- subset(error_log_subsetdate_dataset, grepl(UPLOAD_TRIGGER_TXT_PATTERN, logtext2))
upload_trigger <- subset(upload_trigger, grepl("*/content/dam/*", class))
 
#Filter rows which contains string:jcr:content/renditions/cq5dam.web.1280.1280.jpeg.
rendition_generation <- subset(error_log_subsetdate_dataset, grepl(RENDITION_LOG_TXT_PATTERN, assetPath))
 
#concatenate the data and time columns of subset data frames
upload_trigger$datetime <- paste(as.Date(upload_trigger$date,format='%d.%m.%Y'), upload_trigger$time, sep=" ")
rendition_generation$datetime <- paste(as.Date(rendition_generation$date,format='%d.%m.%Y'), rendition_generation$time, sep=" ")
 
renditions_gap <- renditions_gap + (nrow(upload_trigger) - nrow(rendition_generation))
upload_trigger_df <- data.frame(sub('.*:','',sub('/jcr.*', '', upload_trigger$class)), upload_trigger$datetime)
colnames(upload_trigger_df) <- c("assetPath","upload_trigger.datetime")
write.csv(file="upload_trigger_df.csv", x=upload_trigger_df)
 
#Prepare renditions generation dataframe
rendition_gen_df <- data.frame(gsub('.{49}$', '', rendition_generation$assetPath), rendition_generation$datetime)
colnames(rendition_gen_df) <- c("assetPath","rendition_generation.datetime")
write.csv(file="rendition_gen_df.csv", x=rendition_gen_df)
dataset_x <- merge(upload_trigger_df,rendition_gen_df,'assetPath',all.x=TRUE)
 
#Create a new data frame with assetPath, upload, rendition generation timings
dataset_x$timeDiff <- as.POSIXlt(dataset_x$rendition_generation.datetime, "%d-%m-%Y %H:%M:%S") - as.POSIXlt(dataset_x$upload_trigger.datetime, "%d-%m-%Y %H:%M:%S")
 
filename <- paste(file, ".csv", sep="")
dataset_upload <- rbind(dataset_upload,dataset_x) 
}
 
return(dataset_upload)
}
 
print_rendtions_gap_report <- function(renditions_gap, print_renditions_gap_flag) {
if(print_renditions_gap_flag){
temp_var <- paste("Renditions gap vs uploaded assets: ", renditions_gap, sep="")
 
print(temp_var)
setwd(OUTPUT_DIR)
write(temp_var,file="Rsummary.txt",append=FALSE)
}
}

upload_report_print <- function(dataset_upload,upload_report_print_flag){
if(upload_report_print_flag){
setwd(OUTPUT_DIR)
row.has.na <- apply(dataset_upload, 1, function(x){any(is.na(x))})
uploadAsset <- dataset_upload[!row.has.na,]
write.csv(file="uploadAsset.csv", x=uploadAsset)
 
missingRenditions <- dataset_upload[row.has.na,]
write.csv(file="missingRenditions.csv", x=missingRenditions)
x11()
barplot(as.matrix(uploadAsset$timeDiff), main="Time-Diff Report", xlab="AssetsUploaded", ylab= "timeLag(sec)", beside=TRUE, col=rainbow(1))
dev.copy2pdf(file = "TimeDiffReport.pdf")
}
}
 
# Functions Execution
setwd(QUICKSTART_LOGS_DIR)
error_file_list <- error_log_files_list(QUICKSTART_LOGS_DIR)
dataset_upload <- upload_report_calculation(QUICKSTART_LOGS_DIR)
print_rendtions_gap_report(renditions_gap, print_renditions_gap_flag)
upload_report_print(dataset_upload,upload_report_print_flag)


 

4:52 PM Permalink
March 22, 2017

Rockstar Tips & Tricks for AEM Forms

 

AEM Rockstar at 2017 Adobe Summit

This year over 12,000 people, including attendees from 1,600 Partners, will be at Adobe Summit, making it the largest Summit ever. Over 200 sessions and labs are held during Summit, including the AEM Rockstar session. This year’s AEM Rockstar session features five winners who submitted their favorite Tips & Tricks for working with Adobe Experience Manager. In upcoming weeks, come back here to see more posts detailing the best tips & tricks from the five AEM Rockstar winners and some of the runners-up.

Todays’s guest post is written by AEM Rockstar runner-up Gary Howell, who is a Sr. Development Manager at iCiDIGITAL, a digital agency in Raleigh, NC that specializes in AEM. Gary has expertise in Adobe CQ5, Java, OSGi and Sling and has extensive frontend development and server-side coding experience. 

Five Tips and Tricks to use AEM Forms More Effectively

Forms are a part of nearly every site on the web. From Fortune 100 companies to the pizza place down the street.

There are many different form solutions you can use on your site, but for many of them you must install a plug-in or go through a complex set-up. However, if you are using an AEM platform, you have the benefits of AEM Forms, allowing users to easily create, manage, and track forms on their site.

There is a lot of good documentation on AEM Forms on the Adobe site, but I figured I’d save you some time by showing you five tips and tricks that my team and I have found to use AEM Forms more effectively.

1)    Form Fragments: Form fragments are most commonly used when you have sections or part of a form you want to reuse on multiple forms. They allow you to easily create part of a form – such as a signature – as a form fragment. You can then reference that specific form fragment on other forms across your site. The power of form fragments is that if you modify a piece of your signature form fragment, for example, the year, you have to change it only once and it will change on all your forms.

2)    Rule Editor: Normally you need to write custom code to execute specific rules on a form such as showing, hiding, validating a field, or disabling a piece of the form. But AEM Forms comes with a rule editor that allows you to easily add this functionality. For example, you can add a rule so that when a user selects a checkbox to have their billing address the same as their home address, it will prefill the billing address with the values they previously entered. Cutting down on the time a user spends on your form means getting one step closer to making a conversion.

 

3)  E-Sign: One of the major pain points with forms on the web – and one that halts conversions – is when the user has to print out a form, sign it and then resend it to you. With AEM forms, you no longer have to do that. AEM Forms comes out of the box with the E-Sign function to enable authors to create forms that can be safely and securely signed by designated users online, with no printing needed.

4)    Form Submission: If you’re managing multiple sites or have a fairly complex site, this feature can be one of the most important. AEM Forms allows developers to easily submit forms in a variety of different ways. For example, you can develop a servlet that submits to a REST endpoint that sends data to your SalesForce instance. Or when a user fills out a form, you can kick off a workflow that notifies a user group that there are new submissions in their inbox, so they can quickly get in touch with their users.

5) Output Service: Outputting forms in various formats such as PDF, PNG, or in a custom format has always been a challenge. Luckily with AEM Forms output service you have an API you can tap into to easily output your forms in a variety of formats. Not only does the output service allow you to generate printable PDFs, but it supports output design features of Adobe LiveCycle Designer ES4 – a key feature for anyone coming from a LiveCycle instance to AEM Forms.

With the release of AEM Forms, you no longer need to create a completely custom solution on your own, thereby reducing the difficulty of upgrading and maintaining your library of forms. AEM Forms frequently comes out with new releases, and each one has new enhancements and features. Keep a look out for what AEM Forms has to offer in the future!

3:42 PM Permalink
October 4, 2013

Custom Component: Manual List Widget

Requirement: The users want the ability to manually choose a list of of items from a source list of items.

Example: A list of all the articles in the system is the source list and the users want to select the most important ones to be listed on the home page of a site.

To achieve this, I created a custom ExtJs widget.

  • The users are able to double click on an item on the left (source) list to move the said item to the right list
  • They can manually order the items on the right list via drag and drop.
  • They can double click the item on the right list to remove it from the list
  • They are also able to pass tags to limit the results (your custom servlet that returns the JSON object to be consumed by the widget will need to handle this use case)

Below are the steps to use this widget in your implementation.

1. Paste the widget code from below into a JS file in your CQ implementation.

//Create a new class based on existing CompositeField
CQ.form.GridtoGrid = CQ.Ext.extend(CQ.CustomContentPanel, {
    secondGrid: null,
    firstGrid: null,
    columnModel: null,
    reader: null,
    layout: 'fit',
    constructor : function(config){

        var dataSrcUrl;

        if( config.dataSrcUrl != null ){
            dataSrcUrl = config.dataSrcUrl;
        }

        var jsonReaderConfig;
        if( config.jsonReaderConfig != null ){
            jsonReaderConfig = config.jsonReaderConfig;
        }

        var sortDirection;
        if( config.sortDirection != null ){
            sortDirection = config.sortDirection;
        }

        var sortField;
        if( config.sortField != null ){
            sortField = config.sortField;
        }

        var columnModelConfig;
        if( config.columnModelConfig != null ){
            columnModelConfig = config.columnModelConfig;
        }

        console.log( dataSrcUrl );
        console.log( sortDirection );
        console.log( sortField );
        console.log( CQ.Ext.util.JSON.decode( jsonReaderConfig ) );
        console.log( CQ.Ext.util.JSON.decode( columnModelConfig ) );

        var proxy = new CQ.Ext.data.HttpProxy( {url:  dataSrcUrl} );

        reader = new CQ.Ext.data.JsonReader( {}, CQ.Ext.util.JSON.decode( jsonReaderConfig ) );

        var sourceStore = new CQ.Ext.data.Store(
            {
                proxy: proxy,
                sortInfo: {
                    field: sortField,
                    direction: sortDirection
                },
                reader: reader
            }
        );
        sourceStore.load();

        columnModel = new CQ.Ext.grid.ColumnModel( CQ.Ext.util.JSON.decode( columnModelConfig ) );

        firstGrid = new CQ.Ext.grid.GridPanel({
            listeners: {
                rowdblclick: function (grid, index){

                    //On DoubleClick, get the record from the sourceStore using the index.
                    var moveRecord = firstGrid.getStore().getAt( index );

                    //Add the record to the destinationStore.
                    secondGrid.getStore().add( moveRecord );
                }
            },
            store            : sourceStore,
            name             : 'sourceGrid',
            colModel          : columnModel,
            stripeRows       : true,
            title            : 'Latest Articles',
            margins          : '0 2 0 0',
            x: 0,
            y:100,
            height: 500

        });

        //destination store
        var destinationStore = new CQ.Ext.data.Store({
            reader: reader
        });

        //destination grid
        secondGrid = new CQ.Ext.grid.GridPanel({
            listeners: {
                rowdblclick: function (grid, index){
                    //On DoubleClick, get the record from the sourceStore using the index.
                    var moveRecord = secondGrid.getStore().getAt( index );

                    //Remove the record from the destinationStore.
                    secondGrid.getStore().remove( moveRecord );
                }
            },
            name: 'destinationGrid',
            store: destinationStore,
            colModel: columnModel,
            stripeRows       : true,
            title            : 'Displayed Articles',
            enableDragDrop: true,
            hideHeaders: true,
            ddGroup: 'ddGroup',
            margins          : '0 0 0 3',
            x: 450,
            y: 100,
            height: 500
        });

        //Set the dropZone for the second grid so that users can manually re-order the list.
        secondGrid.on('render', function() {

            secondGrid.dropZone = new CQ.Ext.dd.DropZone(secondGrid.getView().scroller, {
                ddGroup: 'ddGroup',

                //If the mouse is over a grid row, return that node. This is
                //provided as the "target" parameter in all "onNodeXXXX" node event handling functions
                getTargetFromEvent: function(e) {
                    return e.getTarget(secondGrid.getView().rowSelector);
                },

                //On entry into a target node, highlight that node.
                onNodeEnter : function(target, dd, e, data){
                    CQ.Ext.fly(target).addClass('my-row-highlight-class');
                },

                //On exit from a target node, unhighlight that node.
                onNodeOut : function(target, dd, e, data){
                    CQ.Ext.fly(target).removeClass('my-row-highlight-class');
                },

                //While over a target node, return the default drop allowed class which
                //places a "tick" icon into the drag proxy.
                onNodeOver : function(target, dd, e, data){
                    return CQ.Ext.dd.DropZone.prototype.dropAllowed;
                },

                //On NodeDrop event
                onNodeDrop : function(target, dd, e, data){

                    var targetRowIndex = secondGrid.getView().findRowIndex(target);
                    var sourceRowIndex = data.rowIndex;

                    if( targetRowIndex == false ){
                        return false;
                    }

                    if( targetRowIndex == sourceRowIndex ){
                        return false;
                    }

                    var ds = secondGrid.getStore();

                    var sourceRecord = ds.getAt(sourceRowIndex);

                    ds.insert( targetRowIndex, sourceRecord );

                    //Reconfigure the destination list to new store.
                    secondGrid.reconfigure ( ds, columnModel );

                    return true;
                }
            });
        })

        //Tag Inputbox
        var tagInputField = new CQ.tagging.TagInputField({
            listeners:{
                addtag: function( inputField, tag ){

                    var proxyUrl = firstGrid.getStore().proxy.url;
                    var tagId = tag.tagID;

                    var tagsIndex = proxyUrl.indexOf( '?tags=' );

                    if( tagsIndex > -1 ){
                        proxyUrl = proxyUrl + "," + tagId;
                    } else {
                        proxyUrl = proxyUrl + "?tags=" + tagId;
                    }
                    //alert( "Add Tag: " + proxyUrl );
                    var updatedSourceStore = new CQ.Ext.data.Store({
                        url: proxyUrl,
                        reader: reader
                    });

                    updatedSourceStore.load();
                    firstGrid.reconfigure( updatedSourceStore, columnModel );
                },
                removetag: function( inputField, tag ){
                    var proxyUrl = firstGrid.getStore().proxy.url;
                    var tagId = tag.tagID;

                    var splitUrl = new Array();
                    splitUrl = proxyUrl.split( '?tags=' );

                    //alert( "Remove Tag 1 : " + proxyUrl );

                    if( splitUrl[1].indexOf( "," + tagId ) > -1 ){
                        splitUrl[1] = splitUrl[1].replace( "," + tagId, "" );
                    } else {
                        splitUrl[1] = splitUrl[1].replace( tagId , "" );
                    }

                    if( splitUrl[1].indexOf( ',' ) == 0 ){
                        splitUrl[1] = splitUrl[1].substring(1);
                    }

                    if( splitUrl[1] != null && splitUrl[1].length > 0 ){
                        proxyUrl = splitUrl[0] + '?tags=' + splitUrl[1];
                    } else {
                        proxyUrl = splitUrl[0];
                    }
                    //alert( "Remove Tag 2 : " + proxyUrl );

                    var updatedSourceStore = new CQ.Ext.data.Store({
                        url: proxyUrl,
                        reader: reader
                    });

                    updatedSourceStore.load();
                    firstGrid.reconfigure( updatedSourceStore, columnModel );
                }

            },
            title: 'Tag Properties',
            name: './tags',
            x: 250,
            y: 0,
            width: 400
        });
        CQ.form.GridtoGrid.superclass.constructor.call(this, config);

        this.selectionForm = new CQ.Ext.Panel({
            name         : this.name,
            width        : 'auto',
            height       : 600,
            border       : false,
            layout       : {
                type: 'absolute',
                padding: 0
            },
            items        : [
                tagInputField,
                firstGrid,
                secondGrid
            ]
        });
        this.add(this.selectionForm);

    },
    submitPanel: function() {

        var store = secondGrid.getStore();
        var storeData = store.data;
        var storeFields = store.fields;
        var params = {};

        if( storeData.length > 0 ){
            for(var i = 0; i < storeData.length; i++) {
                for(var j = 0; j < storeFields.length; j++){
                    var name = storeFields.item(j).name;
                    if (!params[name]) {
                        params[name] = new Array();
                    }

                    if( name == 'headline' ){
                        var headline = storeData.get(i).get(name);
                        if( headline.indexOf ( '_' ) == headline.length - 1 ){
                            params[name] = params[name].concat( storeData.get(i).get(name));
                        } else {
                            params[name] = params[name].concat( storeData.get(i).get(name) + "_");
                        }
                    } else {
                        params[name] = params[name].concat( storeData.get(i).get(name));
                    }
                }
            }
        }

        var serverResponse = CQ.utils.HTTP.post(this.urlPost, null, params, this);
        return CQ.utils.HTTP.isOk(serverResponse);
    },
    loadContent: function() {

        loadStore = null;

        if (!this.content) {
            var url = CQ.HTTP.externalize(this.urlGet);
            loadStore = new CQ.data.SlingStore({
                url: url+'.infinity.json'
            });
        } else if (this.content instanceof CQ.Ext.data.Store) {
            loadStore = this.content;
        }

        loadStore.load({
            callback: this.processRecords,
            scope: this
        });

    },
    processRecords: function(){

        var tagString = loadStore.getAt(0).get('tags');
        var sourceProxyUrl = firstGrid.getStore().proxy.url;

        if( tagString != undefined && sourceProxyUrl.indexOf( tagString ) == -1 ){
            sourceProxyUrl = sourceProxyUrl + "?tags=" + tagString;
        }
        //alert( "Process Records: " + sourceProxyUrl );
        var updatedSourceStore = new CQ.Ext.data.Store({
            url: sourceProxyUrl,
            reader: reader
        });

        updatedSourceStore.load();
        firstGrid.reconfigure( updatedSourceStore, columnModel );

        var tempHeadlineData = loadStore.getAt(0).get('headline');
        if( tempHeadlineData != undefined ){
            tempHeadlineData = "" + tempHeadlineData;
            var headlineData = new Array();
            headlineData = tempHeadlineData.split('_,');

            var tempPathData = loadStore.getAt(0).get('path');
            var tempDateData = loadStore.getAt(0).get('date');

            var secondGridData = [];

            for( var i = 0; i < headlineData.length; i++ ){
                var headline = headlineData[i].replace('_', '');
                secondGridData.push({headline: headline, path: tempPathData[i], date: tempDateData[i]});
            }

            //destination store
            var destinationStore = new CQ.Ext.data.Store({
                reader: reader
            });

            destinationStore.loadData( secondGridData );

            secondGrid.reconfigure( destinationStore, columnModel );
        }

    }
});
CQ.Ext.reg("gridtogrid", CQ.form.GridtoGrid);

2. Create a component in CQ and in the dialog add a widget node with the following properties.

Screen Shot 2013-10-04 at 12.18.59 PM

  • xtype : gridtogrid –> this matches to what you register the widget as in the javascript.
  • dataSrcUrl –> This is the URL which returns a JSON object that is consumed by the JSONReader in the widget JS
  • jsonReaderConfig –> Configuration for the JSON reader object
    • example: [{name: ‘headline’, mapping: ‘headline’},{name:’date’, mapping:’date’}]
  • columnModelConfig –> Configuration for the column model to be used in the left grid.
    • example: [{header: ‘Headline’, width: 500, sortable: false, dataIndex: ‘headline’},{header: ‘Date’, width: 200, sortable: false, dataIndex: ‘date’, hidden: true}]
  • sortField –> The name of the field that the data should be sorted on
  • sortDirection –> ASC/DESC
  • name –> name of property on the content node to store the destination list under. It will stored as a String[].

Screen Shot 2013-10-04 at 12.28.30 PM

3. Add the component on your page and double click to open the dialog. You should now see a list on the left and can populate the right based on the capabilities described above (double clicks).

Screen Shot 2013-10-04 at 12.39.13 PM

 

 

4. An example servlet that returns a JSON object to be consumed by the widget above is below

final ValueMap attributes = resource.adaptTo(ValueMap.class);
try{
TidyJSONWriter writer = new TidyJSONWriter(out);
String headline = "Headline Not Set";
Calendar date = null;
String stringDate = null;
SimpleDateFormat sdf = new SimpleDateFormat( "yyyy-MM-dd" );
String path = null;

String tagsParameter = request.getParameter( "tags" );

if( tagsParameter != null ){
String[] tags = tagsParameter.split( "," );

NodeIterator articleList = Headlines.getArticleListByTags( currentNode.getSession(), tags, 25 );

if( articleList != null && articleList.getSize() > 0 ){
writer.array();
for( NodeIterator ni = articleList; articleList.hasNext(); ){
Node article = articleList.nextNode();

//Get Headline
try{
headline = article.getProperty( "headline" ).getValue().getString();
} catch (Exception e){
log.info( "headline doesn't exist, using static value instead" );
}

//Get Date
Node contentNode = currentNode.getSession().getNode( article.getPath().replace( "/contentpar/articleBody", "" ) );
try{
date = contentNode.getProperty( "cq:lastModified" ).getValue().getDate();
} catch (Exception e){
log.info( "cq:lastModified doesn't exist, using jcr:created instead" );
date = contentNode.getProperty( "jcr:created" ).getValue().getDate();
}

//Convert date to String
if( date != null ){
stringDate = sdf.format( date.getTime() );
} else {
log.info( "Date Object was null" );
}

//Get Path
path = article.getPath().replace("/jcr:content/contentpar/articleBody","");
writer.object();
writer.key("headline").value( headline );
writer.key("path").value( path );
writer.key("date").value(stringDate);
writer.endObject();
}
writer.endArray();
}

} else {
NodeIterator articleList = Headlines.getArticleListByModifiedDate( currentNode.getSession(), 25 );

if( articleList != null && articleList.getSize() > 0 ){
writer.array();
for( NodeIterator ni = articleList; articleList.hasNext(); ){
Node article = articleList.nextNode();
Node bodyNode = currentNode.getSession().getNode( article.getPath() + "/contentpar/articleBody" );

//Get Headline
try{
headline = bodyNode.getProperty("headline").getValue().getString();
} catch( Exception e ){
log.info( "headline doesn't exist, using static value instead" );
}

//Get lastModifiedDate
try{
date = article.getProperty( "cq:lastModified" ).getValue().getDate();
} catch (Exception e){
log.info( "cq:lastModified doesn't exist, using jcr:created instead" );
date = article.getProperty( "jcr:created" ).getValue().getDate();
}

if( date != null ){
stringDate = sdf.format( date.getTime() );
} else {
log.info( "Date Object was null" );
}

//Get Path
path = bodyNode.getPath().replace("/jcr:content/contentpar/articleBody","");

writer.object();
writer.key("headline").value( headline );
writer.key("path").value( path );
writer.key("date").value(stringDate);
writer.endObject();

}
writer.endArray();
}
}

} catch (Exception e){
log.info( e.getMessage() );
}

I can post a video of the widget in action if this blog post isn’t descriptive  enough. As always, please post comments/questions and I will try to get back to you as soon as possible.

12:46 PM Permalink
February 25, 2012

How to integrate CQ5 with Defensio Profanity detection

I have recently worked on a project where Defensio integration is needed to detect profanity in CQ5’s commenting system and discussion forums. Adobe CQ5 comes with OOTB integration with Automattic Kismet (Akismet) which is for Spam filtering but Akismet does not offer profanity filtering. For more information on Adobe CQ5’s integration with Akismet, please look at the following links:

Social Collaboration – Setting an Akismet Key

AkismetService API doc

Defensio is a profanity and spam filtering service on user generated content. It offers a fully RESTful API to automatically detect, reject, and mask profanity from user generated content on a website. To learn more about Defensio, please visit:

Defensio Web Service – Powered by WebSense

Defensio API Specification

 

Now let’s talk about the high level requirements of the integration I have recently worked on, before I go into the technical details.

Requirements:

  • Profanity detection should be enabled for any comments or discussion forum replies (user generated content).
  • If obscene words are found, they should be masked and the comment / forum reply should be marked as spam. The spam message should not be displayed unless approved by a soco (social collaboration) admin user.
  • No profanity messages should be displayed publicly. If it should be displayed (approved by admin), the masked version is used.
  • The Defensio Web Service API URL and also the API key should be configurable.

Technical Details:

  • A workflow process (com.day.cq.workflow.exec.WorkflowProcess) should be created for the profanity check so that it can be enabled in any CQ workflows.
  • To enable profanity detection for comments or discussion forum replies, the workflow process can be inserted into the OOTB “Comment Moderation” workflow.
  • In order to allow system administrators to configure the Defensio Web Service URL and the API key, an OSGi bundle can be created with service component runtime properties like the following:

 

OSGi Bundle implementation:

This blog entry is not to discuss the very detail of how to package and deploy an OSGi Bundle into Apache Felix. The code snippets I’m showing below utilized the Apache Maven software project management tool, and the codes are greatly shorted (life is easy with Java Annotations) using the maven-bundle-plugin and maven-scr-plugin. To learn more about these plugins, here are some links:

Apache Felix Bundle Plugin for Maven

Apache Felix Maven SCR Plugin

The OSGi bundle for the Defensio integration consists of three classes:

com.customername.cq.profanity.DefensioService
com.customername.cq.profanity.DefensioServiceImpl
com.customername.cq.profanity.DefensioProcess

DefensioService is the interface for the Defensio Profanity integration,
DefensioServiceImpl is an implementation of the integration, and
DefensioProcess is a CQ workflow process implementation (makes it available as a process step in a CQ workflow).

So here you go, please note the use of annotations, and also note that some of logs and minor implementation details are skipped (marked by ellipses in comments):

com.customername.cq.profanity.DefensioService:
package com.customername.cq.profanity;
 
public interface DefensioService {
 
    /**
     * Check to see if the Defensio API key has been verified
     *
     * @return <code>true</code> if the API key has been verified, <code>false</code> otherwise
     */	
	public boolean isVerifiedKey();
 
    /**
     * Verify Defensio API key
     *
     * @return <code>true</code> if the API key has been verified, <code>false</code> otherwise
     */	
	public boolean verifyAPIKey();
 
	@SuppressWarnings("unchecked")
	public String profanityCheck(String commentContent);
}
com.customername.cq.profanity.DefensioServiceImpl:
package com.customername.cq.profanity;
 
import java.io.IOException;
import java.util.Dictionary;
 
import org.apache.commons.httpclient.DefaultHttpMethodRetryHandler;
import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.NameValuePair;
import org.apache.commons.httpclient.methods.GetMethod;
import org.apache.commons.httpclient.methods.PostMethod;
import org.apache.commons.httpclient.params.HttpClientParams;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.sling.commons.json.JSONException;
import org.apache.sling.commons.json.JSONObject;
import org.osgi.framework.BundleContext;
import org.osgi.service.component.ComponentContext;
 
/**
 * Defensio Service implementation
 * <p></p>
 * <a href="http://http://www.defensio.com/api">Defensio API</a> documentation.
 *
 * @author Oliver Choy
 *
 * @scr.component label="Adobe CQ AntiProfanity"
 *                description="Defensio configuration"
 * @scr.property name="service.vendor" value="Customer Name"
 * @scr.property name="service.description" value="Defensio Profanity Check for blog comments"
 * @scr.service
 */
public class DefensioServiceImpl implements DefensioService {
 
    private Log logger = LogFactory.getLog(DefensioServiceImpl.class);
 
    // Constants
    private static final String USER_AGENT_HEADER = "User-Agent";
    private static final String USER_AGENT_VALUE = "Adobe CQ5";
    private static final String API_PARAMETER_COMMENT_CONTENT = "comment_content";
 
    private HttpClient httpClient;
    private String apiKey;
    private boolean verifiedKey = false;
    private String providerUrlFragment;
    
    /** @scr.property */
    public static final String PARAM_API_KEY = "defensio.service.api.key";
    /** @scr.property */
    public static final String PARAM_API_URL = "defensio.service.api.url";
 
    public DefensioServiceImpl() {
    }
 
    DefensioServiceImpl(BundleContext bundleContext, Dictionary<String, Object> configuration) {
        this.setup(bundleContext, configuration);
    }
 
    void setup(BundleContext bundeContext, Dictionary<String, Object> configuration) {
    	// get api key
        Object key = configuration.get(PARAM_API_KEY);
        if (key != null) {
            this.apiKey = key.toString();
        }
        // get api url
        Object apiUrl = configuration.get(PARAM_API_URL);
        if (apiUrl != null) {
            this.providerUrlFragment = apiUrl.toString();
        }
 
        // autoverify
        if(providerUrlFragment != null && apiKey != null) {
            if(providerUrlFragment.length() > 0 && apiKey.length() > 0) {
        	logger.info("Defensio API key verification result is " + verifyAPIKey());
            }
        }
    }
 
    void shutdown() {
    	this.apiKey = null;
    }
 
    @SuppressWarnings("unchecked")
    protected void activate(ComponentContext context) {
	httpClient = new HttpClient();
	HttpClientParams httpClientParams = new HttpClientParams();
	DefaultHttpMethodRetryHandler defaultHttpMethodRetryHandler = new DefaultHttpMethodRetryHandler(0, false);
	httpClientParams.setParameter(USER_AGENT_HEADER, USER_AGENT_VALUE);
	httpClientParams.setParameter(HttpClientParams.RETRY_HANDLER,
		defaultHttpMethodRetryHandler);
	httpClient.setParams(httpClientParams);
	setup(context.getBundleContext(), context.getProperties());
    }
 
    protected void deactivate(ComponentContext context) {
        this.shutdown();
    }
 
    public boolean verifyAPIKey() {
    	boolean callResult = false;
        String defensioUserURL = "http://" + providerUrlFragment + apiKey + ".json";
        GetMethod get = new GetMethod(defensioUserURL);
 
        try {
            httpClient.executeMethod(get);
            String result = get.getResponseBodyAsString();
            
            if (result != null && !result.equals("")) {
            	JSONObject respObj = new JSONObject(result);
            	JSONObject defensioResultObj = respObj.getJSONObject("defensio-result");
            	if (defensioResultObj != null) {
            	    String verifyStatus = defensioResultObj.getString("status");
                    if (verifyStatus.equals("success")) {
            		callResult = true;
            	    }
            	} 
            }
        } catch (IOException e) {
            logger.error("...");
        } catch (JSONException e) {
      	    logger.error("...");
	}
 
        verifiedKey = callResult;
        return callResult;
    }
 
    /**
     * Generic call to Defensio
     **/
    @SuppressWarnings("unchecked")
	private String defensioCall(String commentContent) {
    	
    	String filteredResult = "";
        String defensioProfanityURL = "http://" + providerUrlFragment + apiKey + "/profanity-filter.json";
 
        PostMethod post = new PostMethod(defensioProfanityURL);
        post.addRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=utf-8");
 
        if (commentContent != null) {
            post.addParameter(new NameValuePair(API_PARAMETER_COMMENT_CONTENT, commentContent));
        }
 
        try {
            httpClient.executeMethod(post);
            String result = post.getResponseBodyAsString();
 
            if (result != null && !result.equals("")) {
            	JSONObject respObj = new JSONObject(result);
            	JSONObject defensioResultObj = respObj.getJSONObject("defensio-result");
            	if (defensioResultObj != null) {
            	    String verifyStatus = defensioResultObj.getString("status");
            	    if (verifyStatus.equals("success")) {
            		JSONObject filteredObject = defensioResultObj.getJSONObject("filtered");
            		if (filteredObject != null) {
            		    filteredResult = filteredObject.getString(API_PARAMETER_COMMENT_CONTENT);
            		    logger.debug("Defensio filtered comment: " + filteredResult);
            		}
            	    }
            	} 
            }
        } catch (IOException e) {
            logger.error("...");
        } catch (JSONException e) {
       	    logger.error("...");
	}
        return filteredResult;
    }
 
    @SuppressWarnings("unchecked")
	public String profanityCheck(String commentContent) {
        return defensioCall(commentContent);
    }
    
    public boolean isVerifiedKey() {
        return verifiedKey;
    }
}
com.customername.cq.profanity.DefensioProcess:
package com.customername.cq.profanity;
 
import javax.jcr.Node;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
 
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.jcr.resource.JcrResourceResolverFactory;
import org.osgi.service.component.ComponentContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
 
import com.day.cq.workflow.WorkflowException;
import com.day.cq.workflow.WorkflowSession;
import com.day.cq.workflow.exec.WorkItem;
import com.day.cq.workflow.exec.WorkflowData;
import com.day.cq.workflow.exec.WorkflowProcess;
import com.day.cq.workflow.metadata.MetaDataMap;
 
/**
 * This <code>JavaProcess</code> checks a comment or forum reply for profanity
 * 
 * @scr.component metatype="false"
 * @scr.service interface="com.day.cq.workflow.exec.WorkflowProcess"
 * @scr.property name="process.label" value="Defensio Profanity Check"
 */
public class DefensioProcess implements WorkflowProcess {
 
    private static final String COMMENT_RESOURCE_TYPE = "collab/commons/components/comments/comment";
    private static final String FORUM_POST_RESOURCE_TYPE = "collab/forum/components/post";
    private static final String FORUM_TOPIC_RESOURCE_TYPE = "collab/forum/components/topic"
    private static final String SLING_RESOURCE_TYPE = "sling:resourceType";
    private static final String TYPE_JCR_PATH = "JCR_PATH";
    private final Logger logger = LoggerFactory.getLogger(this.getClass().getName());
 
    /**
     * @scr.reference policy="static"
     */
    private DefensioService defensioService;
    /**
     * @scr.reference policy="static"
     */
    private JcrResourceResolverFactory jcrResourceResolverFactory;
 
    protected void activate(final ComponentContext context) {
        // check if the key is valid
        logger.info("Verifying key for Defensio check");
        boolean isValidKey = defensioService.isVerifiedKey();
        if (isValidKey) {
            logger.info("...");
        } else {
            logger.warn("...");
        }
    }
 
    /**
     * @see WorkflowProcess#execute(WorkItem, WorkflowSession, MetaDataMap)
     */
    public void execute(WorkItem workItem, WorkflowSession workflowSession, MetaDataMap args) throws WorkflowException {
        final Session session = workflowSession.getSession();
        final WorkflowData data = workItem.getWorkflowData();
        String path = null;
        String type = data.getPayloadType();
        try {
            if (type.equals(TYPE_JCR_PATH) && data.getPayload() != null) {
                String payloadData = (String) data.getPayload();
                if (session.itemExists(payloadData)) {
                    path = payloadData;
                }
            }
 
            if (path != null) {
                final Node userGeneratedNode = (Node) session.getItem(path);
 
                if (userGeneratedNode.hasProperty(SLING_RESOURCE_TYPE) &&
                        (userGeneratedNode.getProperty(SLING_RESOURCE_TYPE).getString().equals(COMMENT_RESOURCE_TYPE) ||
                        	userGeneratedNode.getProperty(SLING_RESOURCE_TYPE).getString().equals(FORUM_POST_RESOURCE_TYPE) || 
                                userGeneratedNode.getProperty(SLING_RESOURCE_TYPE).getString().equals(FORUM_TOPIC_RESOURCE_TYPE))
                ) {
 
                    logger.debug("Profanity check for comment node at " + userGeneratedNode.getPath());
                    String ipAddress = null;
                    if (userGeneratedNode.hasProperty("ip")) {
                        ipAddress = userGeneratedNode.getProperty("ip").getString();
                    } else {
                        setIsSpam(userGeneratedNode, false, session);
                    }
                    // if the ip is localhost we set isspam to false as well
                    if (ipAddress.equalsIgnoreCase("127.0.0.1") || ipAddress.equalsIgnoreCase("localhost")
                            || ipAddress.equalsIgnoreCase("0:0:0:0:0:0:0:1")) {
                        setIsSpam(userGeneratedNode, false, session);
                    }
 
                    // UserAgent spam check ...
                    // referrer spam check ...
                    // Author spam check ...
                    // author email spam check ...
                    // author URL spam check ...
                    
                    String commentText = null;
                    if (userGeneratedNode.hasProperty("jcr:description")) {
                        commentText = userGeneratedNode.getProperty("jcr:description").getString();
                    }
 
                    String filteredText = defensioService.profanityCheck(commentText);
                    if (!filteredText.equals(commentText)) {
                    	setIsSpam(userGeneratedNode, true, session);
                    	setFilteredText(userGeneratedNode, filteredText, session);
                    }
                    logger.info("Filtered result " + filteredText + " for comment: " + workItem.toString());
 
                } else {
                    logger.warn("Cannot check for profanity because item is not a comment/topic/post. Workitem: "
                            + workItem.toString());
                }
            } else {
                logger.warn("Cannot check for spam because path is null for this workitem: " + workItem.toString());
            }
        } catch (RepositoryException e) {
            throw new WorkflowException(e);
        }
    }
 
    private void setFilteredText(Node commentNode, String value, Session session) throws WorkflowException {
        try {
            commentNode.setProperty("jcr:description", value);
            session.save();
        } catch (Exception e) {
            throw new WorkflowException(e);
        }
    }
    
    private void setIsSpam(Node commentNode, boolean value, Session session) throws WorkflowException {
        try {
            commentNode.setProperty("isSpam", value);
            session.save();
        } catch (Exception e) {
            throw new WorkflowException(e);
        }
    }
}

Workflow Configuration:

Once the OSGi bundle is implemented and installed, a Process Step can be added to the CQ comment moderation workflow to trigger the profanity check.

Note that the Process Step, once dragged into the workflow, will need to be configured to use the OSGi bundle we have installed. In the example above, the Process Step is named as “Check Profanity” and the process chosen is “Defensio Profanity Check”, like the following:

 

Profanity Detection Result:

Once all of the above are implemented, installed, and configured, you can test the comment entry and discussion forum reply in CQ. It should yield something similar to the following:

The above image depicts two commenting scenarios (in CQ author environment): The first entry without profanity was successfully inserted into the system; the second comment was masked due to discovery of profanity, and was marked as spam waiting for approval/denial of a social collaboration administrator.

1:20 PM Permalink
  • Authors

  • Archives

  • Developer Resources