connect-arango
Version:
ArangoDB session store for Connect.
287 lines (245 loc) • 7.46 kB
text/typescript
import { Database } from "arangojs";
import { Config } from "arangojs/connection";
import { DocumentData } from "arangojs/documents";
import crypto from "crypto";
import session from "express-session";
// Database options
export interface ArangoSessionStoreDatabase {
url: string;
databaseName: string;
auth: {
username: string;
password: string;
};
}
// Hash options
export type ArangoSessionStoreHash = {
algorithm: string;
salt: string;
};
// Options object
export interface ArangoSessionStoreOptions {
db: Database | Config;
collection: string;
hash: boolean | ArangoSessionStoreHash;
autoRemove: boolean;
disableTouch: boolean;
serialize: (s: ArangoSessionData) => ArangoSessionData;
deserialize: (s: ArangoSessionData) => ArangoSessionData;
}
// Define default options
const defaultOptions = {
collection: "sessions", // collection name
hash: true, // encrypt sid, true for defaults, false to turn off, or {salt, algorithm}
autoRemove: true, // turn off db ttl index
disableTouch: false, // prevent express-session updating expirey on touch
serialize: (s: ArangoSessionData) => s, // lazy default so no need for if statements
deserialize: (s: ArangoSessionData) => s, // lazy default so no need for if statements
};
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
// Session data stored in database
type ArangoSessionData = DocumentData<
session.SessionData & {
expires?: Date;
}
>;
// Constructor arguments, make all default options optional.
type Options = PartialBy<
ArangoSessionStoreOptions,
keyof typeof defaultOptions
>;
class ArangoSessionStore extends session.Store {
private options: ArangoSessionStoreOptions;
private db: Database;
private hash?: ArangoSessionStoreHash;
private initiatedCollection = false;
constructor(options: Options) {
super();
const opts: ArangoSessionStoreOptions = {
...defaultOptions,
...options,
};
const { db, hash } = opts;
if (db instanceof Database) {
if (!db.isArangoDatabase)
throw new Error("Database is not an Arango database");
this.db = db;
} else {
this.db = new Database(db);
}
if (hash) {
this.hash = {
salt: "connect-arango",
algorithm: "sha1",
...(typeof hash === "boolean" ? {} : hash),
};
}
this.options = opts;
}
private serialize(s: ArangoSessionData) {
try {
return this.options.serialize?.(s);
} catch {
throw Error("Serialization failed.");
}
}
private deserialize(s: ArangoSessionData) {
try {
return this.options.deserialize(s);
} catch {
throw Error("Serialization failed.");
}
}
// make sure collection is intitiated
// and return collection
async initCollection() {
const collection = this.db.collection(this.options.collection);
if (!this.initiatedCollection) {
if (!(await collection.exists())) {
await collection.create();
}
if (this.options.autoRemove) {
// arango will remove doc 0 seconds after the "expires" value
await collection.ensureIndex({
name: "sessionExpire",
type: "ttl",
fields: ["expires"],
expireAfter: 0,
});
}
this.initiatedCollection = true;
}
return collection;
}
// can't use async in constructor - or in getter
// so setup collection here
// need to await for collection before use
get collection() {
return this.initCollection();
}
hashId(sid: string) {
return this.hash
? crypto
.createHash(this.hash.algorithm)
.update(this.hash.salt + sid)
.digest("hex")
: sid;
}
/**
* Gets the session from the store given a session ID and passes it to `callback`.
*
* The `session` argument should be a `Session` object if found, otherwise `null` or `undefined` if the session was not found and there was no error.
* A special case is made when `error.code === 'ENOENT'` to act like `callback(null, null)`.
*/
async get(
sid: string,
callback: (err: unknown, session?: session.SessionData | null) => void
) {
sid = this.hashId(sid);
try {
const collection = await this.collection;
let session = null;
if (await collection.documentExists(sid)) {
session = await collection.document(sid);
session = this.deserialize(session);
}
callback(null, session);
} catch (err) {
callback(err);
}
}
/** Upsert a session in the store given a session ID and `SessionData` */
async set(
sid: string,
session: session.SessionData,
callback?: (err?: unknown) => void
) {
sid = this.hashId(sid);
try {
const arangoSession = this.serialize({
_key: sid,
// expires can also be timestamp in seconds but they don't seem to autoRemove at the correct times
// date seems to work as expected however
expires: session?.cookie?.expires,
...session, // avoid mutating the session
});
const collection = await this.collection;
await collection.save(arangoSession, {
overwrite: true, // depricated in arango 3.7
overwriteMode: "update", // arango 3.7+
});
callback?.();
} catch (err) {
callback?.(err);
}
}
/** "Touches" a given session, resetting the idle timer. */
async touch(
sid: string,
session: session.SessionData,
callback?: () => void
) {
if (this.options.disableTouch) {
callback?.();
} else {
await this.set(sid, session, callback);
}
}
/** Destroys the dession with the given session ID. */
async destroy(sid: string, callback?: (err?: unknown) => void) {
sid = this.hashId(sid);
try {
const collection = await this.collection;
if (await collection.documentExists(sid)) {
await collection.remove(sid);
}
callback?.();
} catch (err) {
callback?.(err);
}
}
/** Delete all sessions from the store. */
async clear(callback?: (err?: unknown) => void) {
try {
const collection = await this.collection;
await collection.truncate();
callback?.();
} catch (err) {
callback?.(err);
}
}
/** Returns the amount of sessions in the store. */
async length?(callback: (err: unknown, length: number) => void) {
try {
const collection = await this.collection;
const { count: length } = await collection.count();
callback?.(null, length);
} catch (err) {
// express-session expects undefined if error is called.
// connect-mongo uses ts-ignore, which is not to prefer, so we do explicit cast instead
callback?.(err, undefined as unknown as number);
}
}
/** Returns all sessions in the store */
// https://github.com/DefinitelyTyped/DefinitelyTyped/pull/38783, https://github.com/expressjs/session/pull/700#issuecomment-540855551
async all?(
callback: (
err: unknown,
obj?:
| session.SessionData[]
| { [sid: string]: session.SessionData }
| null
) => void
) {
try {
const cursor = await this.db.query(`
FOR doc IN ${this.options.collection}
RETURN doc
`);
callback(null, await cursor.map((s) => this.deserialize(s)));
} catch (err) {
callback(err);
}
}
}
export default ArangoSessionStore;