4
\$\begingroup\$

I've written a server in Node.js that listens to http requests via Express.js and forwards requests to a Neo4j server. It's my first time working with Neo4j and Express.js. The code runs as intended but is an eyesore to read.

The Database has the following Nodes and Relationships:

(:User)-[:RANTED]->(:Rant)
(:User)-[:UPVOTED]->(:Rant)
(:User)-[:DOWNVOTED]->(:Rant)
(:Rant)-[:HAS_COMMENT]->(:Comment)
(:User)-[:COMMENTED]->(:Comment)
(:User)-[:UPVOTED]->(:Comment)
(:User)-[:DOWNVOTED]->(:Comment)

And the API simply supports creation of these nodes and relationships. Edit and Delete functionality is out of the project's scope.

NOTE: The server is meant for a simplistic social network for my final year university project. We are only being marked on the deliverables and not the code quality.

package.json

{
  "name": "Design-Rant-Server",
  "version": "0.1.1",
  "description": "Server layer between DR Database and Frontend",
  "main": "server.js",
  "dependencies": {
    "async": "*",
    "body-parser": "~1.0.1",
    "express": "~4.0.0",
    "neo4j-driver": "*",
    "object-checker": "^0.3.24",
    "validator": "^6.0.0"
  },
  "scripts": {
    "test": "mocha test",
    "start": "node server.js"
  },
  "author": "Paras DPain",
  "license": "MIT",
  "devDependencies": {
    "mocha": "^3.1.1"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/ParasDPain/DRServer.git"
  }
}

server.js

/** DR Starts server and API listeners
 *
 * Written By:
 *         Paras DPain
 *
 * License:
 *        MIT License. All code unless otherwise specified is
 *        Copyright (c) Paras DPain 2016.
 */
"use strict";

// REQUIRES
const express = require("express");
const bodyChecker = require('object-checker').bodyCheckMiddleware;
const bodyParser = require("body-parser");
const validator = require('validator');
const db = require('./api.js');
const checkerOptions = require('./checkerOptions.js')

// GLOBALS
var app = express();
var router = express.Router();
var port = process.env.PORT || 8080;

// CONFIG
app.use(bodyParser.urlencoded({
    extended: true
}));
app.use(bodyParser.json());

// TODO validate auth here
// middleware to use for all requests - order of declaration is important
router.use(function(req, res, next) {
    console.log('Request received');
    next(); // make sure we go to the next routes and don't stop here
});

/// ROUTES
// /api - test server status
router.get("/", function(req, res) {
    res.json({
        message: "We are live!"
    })
});

// POST /api/user - create a user
router.route('/user')

// POST
.post(bodyChecker(checkerOptions.userOptions), function(req, res) {
    // TODO hash passwords
    db.CreateUser(req.body.username, req.body.email, req.body.pass, function(result) {
        if(result === true) { // API returns true if MERGE failed
            res.json({
                response: "User already exists"
            });
        } else {
            res.json({
                response: "User successfully created!"
            });
        }
    }, function(err) {
        res.send(err);
    });
});

// GET /api/user/:username - get user details
router.route('/user/:username')

// GET
.get(function(req, res) {
    // NULL check
    if (req.params.username.match(/^[a-z]+$/g) == null) {
        res.json({
            response: "Invalid Username"
        });
        return;
    }

    db.GetUser(req.params.username, function(result) {
        // Check if rant not found
        if (result && result.length > 0) {
            // result.get(key) filters out the required object
            res.json(result[0].get("user").properties);
        } else {
            res.json({
                response: "User not found"
            })
        }
    }, function(err) {
        res.send(err);
    });
});

// GET /api/feed/:limit - get all rants
router.route('/feed/:limit')

// GET
.get(function(req, res) {
    // Limit type check
    var effectiveLimit;
    // https://stackoverflow.com/questions/1133770/how-do-i-convert-a-string-into-an-integer-in-javascript
    // + converts string to int
    if (Number.isInteger(+req.params.limit) && +req.params.limit > 0) {
        effectiveLimit = +req.params.limit;
    } else {
        effectiveLimit = 10; // DEFAULT
    }

    db.GetRants(effectiveLimit, function(result) {
        // NULL rant check
        if(result.length > 0) {
            // filter and fill array with results
            var array = [];
            result.forEach(function(record) {
                array.push(record.get("rants").properties);
            });
            res.json(array);
        }
        else {
            res.json({"response" : "No Rants found"});
        }

    }, function(err) {
        res.send(err);
    });
});

// GET /api/rant/:rantId
router.route('/rant/:rantId')

// GET - get a rant
.get(function(req, res) {
    // Id format validation and string injection guard
    var id = req.params.rantId;
    // REGEX : start of string + 1 instance of char between [a-z] + integer of any length + end of string
    if (id.match(/^[a-z]\d+$/g) == null) {
        res.json({
            response: "Invalid Id"
        })
        return;
    }
    db.GetRant(id, function(result) {
        // Check if rant not found
        if (result.length > 0) {
            res.json(result[0].get("rant").properties);
        } else {
            res.json({
                response: "Rant not found"
            });
        }
    }, function(err) {
        res.send(err);
    });
});

// POST-PUT-DELETE /api/rant
router.route('/rant')

// POST - create a rant
.post(bodyChecker(checkerOptions.rantCreateOptions), function(req, res) {

    // NULL checks
    if (req.body.username.length > 0) {
        res.json({
            response: "Username missing"
        })
        return;
    } else if (req.body.rantText.length > 0) {
        res.json({
            response: "Rant text missing"
        })
        return;
    }

    // Tags are optional
    var tags = req.body.tags;
    if (tags == null || tags.length < 1) {
        tags = [];
    }

    db.CreateRant(req.body.username, req.body.rantText, tags,
        function(result) {
            res.json({
                response: "Rant created successfully!"
            });
        },
        function(err) {
            res.send(err);
        });
})

// PUT - upvote the rant
.put(bodyChecker(checkerOptions.rantVoteOptions), function(req, res) {
    // TODO check username, rantId
    db.UpvoteRant(req.body.username, req.body.rantId,
        function(result) {
            res.json({
                response: "Rant upvoted successfully!"
            });
        },
        function(err) {
            res.send(err);
        });
})

// TODO use of DELETE request is misleading
// DELETE - downvote the rant
.delete(bodyChecker(checkerOptions.rantVoteOptions), function(req, res) {
    // TODO check username, rantId
    db.DownvoteRant(req.body.username, req.body.rantId,
        function(result) {
            res.json({
                response: "Rant downvoted successfully!"
            });
        },
        function(err) {
            res.send(err);
        });
});


// GET /api/rant/:rantId/comment
router.route('/rant/:rantId/comment')

// GET - get all comments for a rant
.get(function(req, res) {
    // Id format validation and string injection guard
    var id = req.params.rantId;
    // REGEX : start of string + 1 instance of char between [a-z] + integer of any length + end of string
    if (id.match(/^[a-z]\d+$/g) == null) {
        res.json({
            response: "Invalid Id"
        })
        return;
    }
    db.GetComments(id, function(result) {
        // filter and fill array with results
        var array = [];
        result.forEach(function(record) {
            array.push(record.get("comments").properties);
        });
        res.json(array);
    }, function(err) {
        res.send(err);
    });
});

// POST-PUT-DELETE /api/rant/comment
router.route('/rant/comment')

// POST - Add a new comment
.post(bodyChecker(checkerOptions.commentCreateOptions), function(req, res) {
    // TODO check username, rantId, commentText
    db.CreateComment(req.body.username, req.body.rantId, req.body.commentText,
        function(result) {
            res.json({
                response: "Comment added successfully!"
            });
        },
        function(err) {
            res.send(err);
        });
})

// PUT - upvote the comment
.put(bodyChecker(checkerOptions.commentVoteOptions), function(req, res) {
    db.UpvoteComment(req.body.username, req.body.commentId,
        function(result) {
            res.send("Comment upvoted successfully!");
        },
        function(err) {
            res.send(err);
        });
})

// DELETE - downvote the comment
.delete(bodyChecker(checkerOptions.commentVoteOptions), function(req, res) {
    db.DownvoteRant(req.body.username, req.body.commentId,
        function(result) {
            res.send("Comment downvoted successfully!");
        },
        function(err) {
            res.send(err);
        });
});

// REGISTER ROUTES
app.use("/api", router);

// MAIN
app.listen(port);
console.log("The server has started");

dbconnector.js

/** Neo4j database connector
 *
 * Written By:
 *         Paras DPain
 *
 * License:
 *        MIT License. All code unless otherwise specified is
 *        Copyright (c) Paras DPain 2016.
 */
"use strict";

// REQUIRES
const neo4j = require("neo4j-driver").v1;
const db_auth = require("./config.json");

// GLOBALS
var driver = neo4j.driver("bolt://localhost", neo4j.auth.basic(db_auth.username, db_auth.pass));

// Method perform all queries to the database
exports.query = function(query, params, result, callback) {
    var session = driver.session();
    var collection = []; // Keep a record of all results
    session
        .run(query, params)
        .subscribe({
            onNext: function(record) {
                collection.push(record);
            },
            onCompleted: function() {
                if (result) {
                    result(collection);
                }
                session.close();
            },
            onError: function(err) {
                if (callback) {
                    callback(err);
                }
            }
        });
};

api.js

/** Contains implements of all API methods
 *
 * Written By:
 *         Paras DPain
 *
 * License:
 *        MIT License. All code unless otherwise specified is
 *        Copyright (c) Paras DPain 2016.
 */
"use strict";

// TODO scores missing from rants

// REQUIRES
const async = require('async');
const queryDB = require('./dbconnector.js').query;

// HELPER METHODS
// Method checks relationships between two nodes and performs operations selectively
var performVotes = function(primaryNode, secondaryNode, pValue, sValue, novote, callback) {
    var relationshipType;
    // fetch existing relationship between the nodes
    async.series([
        function fn(cb) {
            queryDB(
                "MATCH (primary : " + primaryNode[0] + " {" + primaryNode[1] + " : {pKey} })" +
                "-[relationship]->" +
                "(secondary : " + secondaryNode[0] + " {" + secondaryNode[1] + " : {sKey} }) " +
                "RETURN type(relationship) AS relationship",
                {
                    pKey: pValue,
                    sKey: sValue
                },
                function(result) {
                    if(result.length > 0) {
                        relationshipType = result[0].get("relationship") + "";
                    } else {
                        relationshipType = "";
                    }
                    cb(null, "");
                },
                function(err) {
                    callback(err);
                    return; // stop further processing
                }
            );
        },
        // Redundant wrap required for async
        function switchByResult() {
            switch (relationshipType) {
                case "COMMENTED": // Operation not allowed
                    callback("Cannot vote your own comments");
                    break;
                case "RANTED": // Operation not allowed
                    callback("Cannot vote your own rants");
                    break;
                case "UPVOTED": // already upvoted
                    callback("Node is already upvoted");
                    break;
                case "DOWNVOTED": // already downvoted
                    callback("Node is already downvoted");
                    break;
                case "": // No relationship found
                    if (novote) {
                        novote();
                    };
                    break;
                default: // Unknown relationship found
                    callback("Unknown relationship status found");
                    cb(null, "");
            };
        }
    ]);
};

// GLOBALS
var rantCount = 0;
// Fetch current rant count
queryDB("MATCH (r : Rant) RETURN count(*) AS count", {}, function(result) {
    rantCount = result[0].get("count").toString();
}, function(err) {
    console.error(err.message);
});
// async.series helper method
var cb = function(err, result) {
    if (err) {
        console.error(err.message);
    } else {
        console.log(result);
    }
}

// API Implements
exports.GetUser = function(username, result, callback) {
    var foundUser;
    var score;

    // Find scores ((upvoted rants - downvoted rants) + (upvoted comments - downvoted comments))
    // TODO simplify into a single CYPHER query
    async.series([
        // Upvoted rants
        function fn(cb) {
            queryDB(
                "MATCH (user : User {username : {uname} }) " +
                "MATCH (user)-[:RANTED]->(rants : Rant) " +
                "MATCH (:User)-[uprants : UPVOTED]->(rants) " +
                "RETURN COUNT(uprants) AS count", {
                    uname: username
                },
                function(countRes) {
                    score = countRes[0].get("count").toNumber();
                    cb(null, "");
                },
                callback);
        },

        // Downvoted rants
        function fn(cb) {
            queryDB(
                "MATCH (user : User {username : {uname} }) " +
                "MATCH (user)-[:RANTED]->(rants : Rant) " +
                "MATCH (:User)-[downrants : DOWNVOTED]->(rants) " +
                "RETURN COUNT(downrants) AS count", {
                    uname: username
                },
                function(countRes) {
                    score -= countRes[0].get("count").toNumber();
                    cb(null, "");
                },
                callback);
        },

        // Upvoted comments
        function fn(cb) {
            queryDB(
                "MATCH (user : User {username : {uname} }) " +
                "MATCH (user)-[:COMMENTED]->(coms : Comment) " +
                "MATCH (:User)-[upcoms : UPVOTED]->(coms) " +
                "RETURN COUNT(upcoms) AS count", {
                    uname: username
                },
                function(countRes) {
                    score += countRes[0].get("count").toNumber();
                    cb(null, "");
                },
                callback);
        },

        // Downvoted comments
        function fn(cb) {
            queryDB(
                "MATCH (user : User {username : {uname} }) " +
                "MATCH (user)-[:COMMENTED]->(coms : Comment) " +
                "MATCH (:User)-[downcoms : DOWNVOTED]->(coms) " +
                "RETURN COUNT(downcoms) AS count", {
                    uname: username
                },
                function(countRes) {
                    score -= countRes[0].get("count").toNumber();
                    cb(null, "");
                },
                callback);
        },

        // Get User and add calculated scoee
        function fn(cb) {
            queryDB("MATCH (user : User {username : {uname} }) RETURN user", {
                    uname: username
                },
                function(finalRes) {
                    if (finalRes.length > 0) { // NULL CHECK
                        finalRes[0].get("user").properties["score"] = score;
                    }
                    result(finalRes); // return
                    cb(null, "");
                },
                callback);
        }
    ]);
};

exports.CreateUser = function(username, email, passHash, result, callback) {
    queryDB(
        "MERGE (user : User {username : {uname}, email : {uemail}, hash : {uhash} }) " +
        "ON MERGE RETURN TRUE", // return true if user already exists
        {
            uname: username,
            uemail: email,
            uhash: passHash
        },
        result,
        callback);
};

exports.GetRants = function(resultLimit, result, callback) {
    var rantArray;
    async.series([
        function fn(cb) {
            queryDB("MATCH (rants : Rant) RETURN rants LIMIT {limit}", {
                limit: resultLimit
            },
            function(res) {
                rantArray = res;
                cb(null, "");
            },
            callback);
        },

        // Foreach rant, add it's score
        // TODO multiple calls should be fixed by refactoring the database design
        function fn(cb) {
            var callCount = 0; // HACK to enforce all function calls to return
            async.eachOf(rantArray, function(element, index , cbb) {
                queryDB(
                    "OPTIONAL MATCH (user : User)-[:UPVOTED]->(:Rant {id : {rid} }) " +
                    "WITH COUNT(user) AS upvotes " +
                    "OPTIONAL MATCH (user : User)-[:DOWNVOTED]-(:Rant {id : {rid} }) " +
                    "RETURN upvotes - COUNT(user) AS count", {
                        rid: element.get("rants").properties.id
                    },
                    function(res) {
                        element.get("rants").properties["score"] = res[0].get("count").toNumber();
                        console.log(res[0].get("count").toNumber());

                        callCount++;
                        // HACK callback
                        if(callCount === rantArray.length) {
                            console.log("we here with array as");
                            result(rantArray);
                            cb(null, "");
                        }
                    },
                    callback);
            }, function (err) {
                if(err) {
                    console.error(err.message);
                }
            });
        }
    ]);
};

exports.GetRant = function(rantId, result, callback) {
    var score;
    async.series([
        // User Votes
        function fn(cb) {
            queryDB(
                "OPTIONAL MATCH (user : User)-[:UPVOTED]->(:Rant {id : {rid} }) " +
                "WITH COUNT(user) AS upvotes " +
                "OPTIONAL MATCH (user : User)-[:DOWNVOTED]-(:Rant {id : {rid} }) " +
                "RETURN upvotes - COUNT(user) AS count", {
                    rid: rantId
                },
                function(countRes) {
                    score = countRes[0].get("count").toNumber();
                    cb(null, "");
                },
                callback);
        },

        // Get Rant details
        function fn(cb) {
            queryDB("MATCH (rant : Rant {id : {rid} }) RETURN rant", {
                    rid: rantId
                },
                function(finalRes) {
                    if (finalRes.length > 0) { // NULL CHECK
                        finalRes[0].get("rant").properties["score"] = score;
                    }
                    result(finalRes); // return
                    cb(null, "");
                },
                callback);
        }
    ]);
};

exports.CreateRant = function(username, rantText, tags, result, callback) {
    queryDB(
        "MATCH (user : User {username: {uname} }) " +
        "CREATE (rant : Rant {id : {rid}, text : {rText}, tags : {rTags} })" +
        "CREATE (user)-[:RANTED {on: {time} }]->(rant)", {
            uname: username,
            rid: "r" + ++rantCount,
            rText: rantText,
            rTags: tags,
            time: Date.now()
        },
        result,
        callback);
};

exports.GetComments = function(rantId, result, callback) {
    queryDB(
        "OPTIONAL MATCH (rant : Rant {id : {rid} })" +
        "-[:HAS_COMMENT]->" +
        "(comments : Comment) RETURN comments", {
            rid: rantId
        },
        result,
        callback);
};

exports.CreateComment = function(username, rantId, commentText, result, callback) {
    // Fetch comment count
    var commentCount = 0;
    queryDB(
        "OPTIONAL MATCH (r : Rant {id : {rid} })" +
        "-[c : HAS_COMMENT]->" +
        "(:Comment) RETURN count(c) AS count", {
            rid: rantId
        },
        function(res) {
            commentCount = res[0].get("count").toString(); // fetch current rant count

            // Inject main function
            queryDB(
                "MATCH (user : User {username: {uname} }) " +
                "MATCH (rant : Rant {id : {rid} })" +
                "CREATE (com : Comment {id : {cid}, text : {cText} })" +
                "CREATE (rant)-[:HAS_COMMENT {on: {time} }]->(com)" +
                "CREATE (user)-[:COMMENTED]->(com)", {
                    uname: username,
                    rid: rantId,
                    cid: "c" + ++commentCount,
                    cText: commentText,
                    time: Date.now()
                },
                result,
                callback);

        },
        function(err) {
            console.error(err.message);
        });
};

/*
 * Voting API functions lineraly and leaves UX features of toggling votes to the frontend
 */
 // TODO Implement checks for non matches to protect against wrong input
exports.UpvoteRant = function(username, rantId, result, callback) {
    performVotes(["User", "username"], ["Rant", "id"], username, rantId,
        function() {
            queryDB(
                "MATCH (user : User {username: {uname} }) " +
                "MATCH (rant : Rant {id : {rid} }) " +
                "CREATE (user)-[:UPVOTED]->(rant) ", {
                    uname: username,
                    rid: rantId
                },
                function(res) {
                    result("Rant upvoted successfully!")
                },
                callback);
        },
        callback);
};

exports.DownvoteRant = function(username, rantId, result, callback) {
    performVotes(["User", "username"], ["Rant", "id"], username, rantId,
        function() {
            queryDB(
                "MATCH (user : User {username : {uname} })" +
                "-[relationship : DOWNVOTED]->" +
                "MATCH (rant : Rant {id : {rid} }) " +
                "DELETE relationship", {
                    uname: username,
                    rid: rantId
                },
                function(res) {
                    result("Rant downvoted successfully!")
                },
                callback);
        },
        callback);
};

exports.UpvoteComment = function(username, commentId, result, callback) {
    performVotes(["User", "username"], ["Comment", "id"], username, commentId,
        function() {
            queryDB(
                "MATCH (user : User {username: {uname} }) " +
                "MATCH (com : Comment {id : {cid} }) " +
                "CREATE (user)-[:UPVOTED]->(com)", {
                    uname: username,
                    cid: commentId
                },
                function(res) {
                    result("Comment upvoted successfully!")
                },
                callback);
        },
        callback);
};

exports.DownvoteComment = function(username, commentId, result, callback) {
    performVotes(["User", "username"], ["Comment", "id"], username, commentId,
        function() {
            queryDB(
                "MATCH (user : User {username : {uname} })" +
                "-[relationship : DOWNVOTED]->" +
                "MATCH (com : Comment {id : {cid} })" +
                "DELETE relationship", {
                    uname: username,
                    cid: commentId
                },
                function(res) {
                    result("Comment downvoted successfully!")
                },
                callback);
        },
        callback);
};

checkerOptions.js

/** Validation options for object-checker to validate incoming requests
 *
 * Written By:
 *         Paras DPain
 *
 * License:
 *        MIT License. All code unless otherwise specified is
 *        Copyright (c) Paras DPain 2016.
 */
"use strict";

// REQUIRES
const validator = require('validator');

// CHECKER OPTIONS
exports.userCreateOptions = {
    username: {
        $matchRegExp: /^[a-z]+$/
    },
    email: {
        $assertTrue: validator.isEmail
    },
    pass: {
        // TODO
    }
};
exports.rantCreateOptions = {
    username: {
        $matchRegExp: /^[a-z]+$/g
    },
    rantText: {
        $minLength: 1
    },
    tags: {
        $isOptional: true,
        $isArray: true
    }
};
exports.rantVoteOptions = {
    username: {
        $matchRegExp: /^[a-z]+$/g
    },
    rantId: {
        $matchRegExp: /^[a-z]\d+$/g
    }
}
exports.commentCreateOptions = {
    username: {
        $matchRegExp: /^[a-z]+$/g
    },
    rantId: {
        $matchRegExp: /^[a-z]\d+$/g
    },
    commentText: {
        $minLength: 1
    }
};
exports.commentVoteOptions = {
    username: {
        $matchRegExp: /^[a-z]+$/g
    },
    commentId: {
        $matchRegExp: /^[a-z]\d+$/g
    }
};

One noticeable improvement might be exports.GetUser inside dbconnector.js. The method makes five different calls to the database to calculate the User's score.

I did try to implement it in a single neo4j query but due to issues with the behaviour of the OPTIONAL MATCH function (documented here, no the solution didn't work for more than two MATCHs) I decided separate the query. If there is a better solution, do let me know.

\$\endgroup\$
0

1 Answer 1

3
\$\begingroup\$

It's often a good idea to separate routes into their own files to get a better overview of the project. Try out express-generator and see how the example project is organized.

Use a logging framework to log requests. I've used morgan which is really nice.

Callbacks are like any other parameters and should be given a descriptive name. For instance, query has two callback parameters, result and callback. A more descriptive name would perhaps be resultCallback and errorCallback. performVotes even has both callback and cb, which is very confusing to a reader!

Comments is a very useful tool to help readers understand particularly tricky code. They can however lead to less readable code when used too much. Obvious comments like // REQUIRES before a block of require calls are simply a nuance in my opinion.

I don't know if I'm missing something, but it seems like your routing comments are misleading. They all mention the prefix /api, but I don't see it anywhere in the code.

Assignment is not necessary when defining functions. You can drop var and = in the definition of performVotes.

In fact, don't use var at all. Since ES6, the new version of JavaScript, there are two new assignment statements const and let. let roughly corresponds to var, but with saner rules, while const is a whole new concept that should be used whenever reassignment isn't strictly required. Read this article if you're interested in why.

Sometimes it’s tempting to create an identifier to represent some data and then use that identifier as a temporary place to store values in transition from one representation to another.

For instance, you may be after a query string parameter value, and start by storing an entire URL, then just the query string, then the value. This practice should be avoided.

It’s easier to understand if you use one identifier for the URL, a different one for the query string, and finally, an identifier to store the parameter value you were after.

This is why I favor const over let in ES6. In JavaScript, const means that the identifier can’t be reassigned.

[...]

var is now the weakest signal available when you define a variable in JavaScript. The variable may or may not be reassigned, and the variable may or may not be used for an entire function, or just for the purpose of a block or loop.

Generally you should avoid global mutable state. rantCount can easily lead to race conditions and other hard to track bugs. I don't know Neo4j, but databases usually have some atomic increment feature to do globally unique ids you could use instead.

checkerOptions.js has lots of duplicated code. Say you want to make a change to allow usernames with upper case letters and numbers as well. You'll have to edit five different regexes! Instead, define a globals for username and id patterns and reuse them.

const USER_NAME_PATTERN = /^[a-z]+$/g;

Concerning these regular expressions, there's no need for the g flag in your case. It stores the index of each match to allow multiple matches on the same string, but since all your patterns are anchored with ^ and $, only one match is ever possible!

\$\endgroup\$
0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.