enigma-bbs/core/activitypub/collection.js

755 lines
23 KiB
JavaScript

const { parseTimestampOrNow } = require('./util');
const Endpoints = require('./endpoint');
const ActivityPubObject = require('./object');
const apDb = require('../database').dbs.activitypub;
const { getISOTimestampString } = require('../database');
const { Errors } = require('../enig_error.js');
const {
PublicCollectionId,
ActivityStreamMediaType,
Collections,
ActorCollectionId,
} = require('./const');
const UserProps = require('../user_property');
const { getJson } = require('../http_util');
// deps
const { isString } = require('lodash');
const Log = require('../logger').log;
const async = require('async');
module.exports = class Collection extends ActivityPubObject {
constructor(obj) {
super(obj);
}
static getRemoteCollectionStats(collectionUrl, cb) {
const headers = {
Accept: ActivityStreamMediaType,
};
getJson(
collectionUrl,
{ headers, validContentTypes: [ActivityStreamMediaType] },
(err, collection) => {
if (err) {
return cb(err);
}
collection = new Collection(collection);
if (!collection.isValid()) {
return cb(Errors.Invalid('Invalid Collection'));
}
const { totalItems, type, id, summary } = collection;
return cb(null, {
totalItems,
type,
id,
summary,
});
}
);
}
static followers(collectionId, page, cb) {
return Collection.publicOrderedById(
Collections.Followers,
collectionId,
page,
e => e.id,
cb
);
}
static following(collectionId, page, cb) {
return Collection.publicOrderedById(
Collections.Following,
collectionId,
page,
e => e.id,
cb
);
}
static followRequests(owningUser, page, cb) {
return Collection.ownedOrderedByUser(
Collections.FollowRequests,
owningUser,
true, // private
page,
null, // return full Follow Request Activity
cb
);
}
static outbox(collectionId, page, cb) {
return Collection.publicOrderedById(
Collections.Outbox,
collectionId,
page,
null,
cb
);
}
static addFollower(owningUser, followingActor, ignoreDupes, cb) {
const collectionId = Endpoints.followers(owningUser);
return Collection.addToCollection(
Collections.Followers,
owningUser,
collectionId,
followingActor.id, // Actor following owningUser
followingActor,
false, // we'll check dynamically when queried
ignoreDupes,
cb
);
}
static addFollowRequest(owningUser, requestActivity, cb) {
const collectionId = Endpoints.makeUserUrl(owningUser) + '/follow-requests';
return Collection.addToCollection(
Collections.FollowRequests,
owningUser,
collectionId,
requestActivity.id,
requestActivity,
true, // private
true, // ignoreDupes
cb
);
}
static addFollowing(owningUser, followingActor, ignoreDupes, cb) {
const collectionId = Endpoints.following(owningUser);
return Collection.addToCollection(
Collections.Following,
owningUser,
collectionId,
followingActor.id, // Actor owningUser is following
followingActor,
false, // we'll check dynamically when queried
ignoreDupes,
cb
);
}
static addOutboxItem(owningUser, outboxItem, isPrivate, ignoreDupes, cb) {
const collectionId = Endpoints.outbox(owningUser);
return Collection.addToCollection(
Collections.Outbox,
owningUser,
collectionId,
outboxItem.id,
outboxItem,
isPrivate,
ignoreDupes,
cb
);
}
static addInboxItem(inboxItem, owningUser, ignoreDupes, cb) {
const collectionId = Endpoints.inbox(owningUser);
return Collection.addToCollection(
Collections.Inbox,
owningUser,
collectionId,
inboxItem.id,
inboxItem,
true,
ignoreDupes,
cb
);
}
static addSharedInboxItem(inboxItem, ignoreDupes, cb) {
return Collection.addToCollection(
Collections.SharedInbox,
null, // N/A
PublicCollectionId,
inboxItem.id,
inboxItem,
false,
ignoreDupes,
cb
);
}
// Actors is a special collection
static actor(actorIdOrSubject, cb) {
// We always store subjects prefixed with '@'
if (!/^https?:\/\//.test(actorIdOrSubject) && '@' !== actorIdOrSubject[0]) {
actorIdOrSubject = `@${actorIdOrSubject}`;
}
apDb.get(
`SELECT c.name, c.timestamp, c.owner_actor_id, c.is_private, c.object_json, m.meta_value
FROM collection c, collection_object_meta m
WHERE c.collection_id = ? AND c.name = ? AND m.object_id = c.object_id AND (c.object_id LIKE ? OR (m.meta_name = ? AND m.meta_value LIKE ?))
LIMIT 1;`,
[
ActorCollectionId,
Collections.Actors,
actorIdOrSubject,
'actor_subject',
actorIdOrSubject,
],
(err, row) => {
if (err) {
return cb(err);
}
if (!row) {
return cb(
Errors.DoesNotExist(`No Actor found for "${actorIdOrSubject}"`)
);
}
const obj = ActivityPubObject.fromJsonString(row.object_json);
if (!obj) {
return cb(Errors.Invalid('Failed to parse Object JSON'));
}
const info = Collection._rowToObjectInfo(row);
if (row.meta_value) {
info.subject = row.meta_value;
} else {
info.subject = obj.id;
}
return cb(null, obj, info);
}
);
}
static addActor(actor, subject, cb) {
async.waterfall(
[
callback => {
return apDb.beginTransaction(callback);
},
(trans, callback) => {
trans.run(
`REPLACE INTO collection (collection_id, name, timestamp, owner_actor_id, object_id, object_json, is_private)
VALUES(?, ?, ?, ?, ?, ?, ?);`,
[
ActorCollectionId,
Collections.Actors,
getISOTimestampString(),
PublicCollectionId,
actor.id,
JSON.stringify(actor),
false,
],
err => {
return callback(err, trans);
}
);
},
(trans, callback) => {
trans.run(
`REPLACE INTO collection_object_meta (collection_id, name, object_id, meta_name, meta_value)
VALUES(?, ?, ?, ?, ?);`,
[
ActorCollectionId,
Collections.Actors,
actor.id,
'actor_subject',
subject,
],
err => {
return callback(err, trans);
}
);
},
],
(err, trans) => {
if (err) {
trans.rollback(err => {
return cb(err);
});
} else {
trans.commit(err => {
return cb(err);
});
}
}
);
}
static removeExpiredActors(maxAgeDays, cb) {
apDb.run(
`DELETE FROM collection
WHERE collection_id = ? AND name = ? AND DATETIME(timestamp, "+${maxAgeDays} days") > DATETIME("now");`,
[ActorCollectionId, Collections.Actors],
err => {
return cb(err);
}
);
}
// Get Object(s) by ID; There may be multiples as they may be
// e.g. Actors belonging to multiple followers collections.
// This method also returns information about the objects
// and any items that can't be parsed
static objectsById(objectId, cb) {
apDb.all(
`SELECT name, timestamp, owner_actor_id, object_json, is_private
FROM collection
WHERE object_id = ?;`,
[objectId],
(err, rows) => {
if (err) {
return cb(err);
}
const results = (rows || []).map(r => {
const info = {
info: this._rowToObjectInfo(r),
object: ActivityPubObject.fromJsonString(r.object_json),
};
if (!info.object) {
info.raw = r.object_json;
}
return info;
});
return cb(null, results);
}
);
}
static ownedObjectByNameAndId(collectionName, owningUser, objectId, cb) {
const actorId = owningUser.getProperty(UserProps.ActivityPubActorId);
if (!actorId) {
return cb(
Errors.MissingProperty(
`User "${owningUser.username}" is missing property '${UserProps.ActivityPubActorId}'`
)
);
}
apDb.get(
`SELECT name, timestamp, owner_actor_id, object_json, is_private
FROM collection
WHERE name = ? AND owner_actor_id = ? AND object_id = ?
LIMIT 1;`,
[collectionName, actorId, objectId],
(err, row) => {
if (err) {
return cb(err);
}
if (!row) {
return cb(null, null);
}
const obj = ActivityPubObject.fromJsonString(row.object_json);
if (!obj) {
return cb(Errors.Invalid('Failed to parse Object JSON'));
}
return cb(null, obj, Collection._rowToObjectInfo(row));
}
);
}
static objectByNameAndId(collectionName, objectId, cb) {
apDb.get(
`SELECT name, timestamp, owner_actor_id, object_json, is_private
FROM collection
WHERE name = ? AND object_id = ?
LIMIT 1;`,
[collectionName, objectId],
(err, row) => {
if (err) {
return cb(err);
}
if (!row) {
return cb(null, null);
}
const obj = ActivityPubObject.fromJsonString(row.object_json);
if (!obj) {
return cb(Errors.Invalid('Failed to parse Object JSON'));
}
return cb(null, obj, Collection._rowToObjectInfo(row));
}
);
}
static objectByEmbeddedId(objectId, cb) {
apDb.get(
`SELECT name, timestamp, owner_actor_id, object_json, is_private
FROM collection
WHERE json_extract(object_json, '$.object.id') = ?
LIMIT 1;`,
[objectId],
(err, row) => {
if (err) {
return cb(err);
}
if (!row) {
// no match
return cb(null, null);
}
const obj = ActivityPubObject.fromJsonString(row.object_json);
if (!obj) {
return cb(Errors.Invalid('Failed to parse Object JSON'));
}
return cb(null, obj, Collection._rowToObjectInfo(row));
}
);
}
static publicOrderedById(collectionName, collectionId, page, mapper, cb) {
if (!page) {
return apDb.get(
`SELECT COUNT(collection_id) AS count
FROM collection
WHERE name = ? AND collection_id = ? AND is_private = FALSE;`,
[collectionName, collectionId],
(err, row) => {
if (err) {
return cb(err);
}
let obj;
if (row.count > 0) {
obj = {
id: collectionId,
type: 'OrderedCollection',
first: `${collectionId}?page=1`,
totalItems: row.count,
};
} else {
obj = {
id: collectionId,
type: 'OrderedCollection',
totalItems: 0,
orderedItems: [],
};
}
return cb(null, new Collection(obj));
}
);
}
// :TODO: actual paging...
apDb.all(
`SELECT object_json
FROM collection
WHERE name = ? AND collection_id = ? AND is_private = FALSE
ORDER BY timestamp;`,
[collectionName, collectionId],
(err, entries) => {
if (err) {
return cb(err);
}
try {
entries = (entries || []).map(e => JSON.parse(e.object_json));
} catch (e) {
Log.error(`Collection "${collectionId}" error: ${e.message}`);
}
if (mapper && entries.length > 0) {
entries = entries.map(mapper);
}
let obj;
if ('all' === page) {
obj = {
id: collectionId,
type: 'OrderedCollection',
totalItems: entries.length,
orderedItems: entries,
};
} else {
obj = {
id: `${collectionId}/page=${page}`,
type: 'OrderedCollectionPage',
totalItems: entries.length,
orderedItems: entries,
partOf: collectionId,
};
}
return cb(null, new Collection(obj));
}
);
}
static ownedOrderedByUser(
collectionName,
owningUser,
includePrivate,
page,
mapper,
cb
) {
const privateQuery = includePrivate ? '' : ' AND is_private = FALSE';
const actorId = owningUser.getProperty(UserProps.ActivityPubActorId);
if (!actorId) {
return cb(
Errors.MissingProperty(
`User "${owningUser.username}" is missing property '${UserProps.ActivityPubActorId}'`
)
);
}
// e.g. http://somewhere.com/_enig/ap/users/NuSkooler/followers
const collectionId = Endpoints.makeUserUrl(owningUser) + `/${collectionName}`;
if (!page) {
return apDb.get(
`SELECT COUNT(collection_id) AS count
FROM collection
WHERE owner_actor_id = ? AND name = ?${privateQuery};`,
[actorId, collectionName],
(err, row) => {
if (err) {
return cb(err);
}
//
// Mastodon for instance, will never follow up for the
// actual data from some Collections such as 'followers';
// Instead, they only use the |totalItems| to form an
// approximate follower count.
//
let obj;
if (row.count > 0) {
obj = {
id: collectionId,
type: 'OrderedCollection',
first: `${collectionId}?page=1`,
totalItems: row.count,
};
} else {
obj = {
id: collectionId,
type: 'OrderedCollection',
totalItems: 0,
orderedItems: [],
};
}
return cb(null, new Collection(obj));
}
);
}
// :TODO: actual paging...
apDb.all(
`SELECT object_json
FROM collection
WHERE owner_actor_id = ? AND name = ?${privateQuery}
ORDER BY timestamp;`,
[actorId, collectionName],
(err, entries) => {
if (err) {
return cb(err);
}
try {
entries = (entries || []).map(e => JSON.parse(e.object_json));
} catch (e) {
Log.error(`Collection "${collectionId}" error: ${e.message}`);
}
if (mapper && entries.length > 0) {
entries = entries.map(mapper);
}
const obj = {
id: `${collectionId}/page=${page}`,
type: 'OrderedCollectionPage',
totalItems: entries.length,
orderedItems: entries,
partOf: collectionId,
};
return cb(null, new Collection(obj));
}
);
}
// https://www.w3.org/TR/activitypub/#update-activity-inbox
static updateCollectionEntry(collectionName, objectId, obj, cb) {
if (!isString(obj)) {
obj = JSON.stringify(obj);
}
apDb.run(
`UPDATE collection
SET object_json = ?, timestamp = ?
WHERE name = ? AND object_id = ?;`,
[obj, collectionName, getISOTimestampString(), objectId],
err => {
return cb(err);
}
);
}
static addToCollection(
collectionName,
owningUser,
collectionId,
objectId,
obj,
isPrivate,
ignoreDupes,
cb
) {
if (!isString(obj)) {
obj = JSON.stringify(obj);
}
let actorId;
if (owningUser) {
actorId = owningUser.getProperty(UserProps.ActivityPubActorId);
if (!actorId) {
return cb(
Errors.MissingProperty(
`User "${owningUser.username}" is missing property '${UserProps.ActivityPubActorId}'`
)
);
}
} else {
actorId = PublicCollectionId;
}
isPrivate = isPrivate ? 1 : 0;
apDb.run(
`INSERT OR IGNORE INTO collection (name, timestamp, collection_id, owner_actor_id, object_id, object_json, is_private)
VALUES (?, ?, ?, ?, ?, ?, ?);`,
[
collectionName,
getISOTimestampString(),
collectionId,
actorId,
objectId,
obj,
isPrivate,
],
function res(err) {
// non-arrow for 'this' scope
if (err && 'SQLITE_CONSTRAINT' === err.code) {
if (ignoreDupes) {
err = null; // ignore
}
return cb(err);
}
return cb(err, this.lastID);
}
);
}
static removeOwnedById(collectionName, owningUser, objectId, cb) {
const actorId = owningUser.getProperty(UserProps.ActivityPubActorId);
if (!actorId) {
return cb(
Errors.MissingProperty(
`User "${owningUser.username}" is missing property '${UserProps.ActivityPubActorId}'`
)
);
}
apDb.run(
`DELETE FROM collection
WHERE name = ? AND owner_actor_id = ? AND object_id = ?;`,
[collectionName, actorId, objectId],
err => {
return cb(err);
}
);
}
static removeById(collectionName, objectId, cb) {
apDb.run(
`DELETE FROM collection
WHERE name = ? AND object_id = ?;`,
[collectionName, objectId],
err => {
return cb(err);
}
);
}
static removeByMaxCount(collectionName, maxCount, cb) {
apDb.run(
`DELETE FROM collection
WHERE _rowid_ IN (
SELECT _rowid_
FROM collection
WHERE name = ?
ORDER BY _rowid_ DESC
LIMIT -1 OFFSET ${maxCount}
);`,
[maxCount],
function res(err) {
// non-arrow function for 'this'
Collection._removeByLogHelper(
collectionName,
'MaxCount',
err,
maxCount,
this.changes
);
return cb(err);
}
);
}
static removeByMaxAgeDays(collectionName, maxAgeDays, cb) {
apDb.run(
`DELETE FROM collection
WHERE name = ? AND timestamp < DATE('now', '-${maxAgeDays} days');`,
[maxAgeDays],
function res(err) {
// non-arrow function for 'this'
Collection._removeByLogHelper(
collectionName,
'MaxAgeDays',
err,
maxAgeDays,
this.changes
);
return cb(err);
}
);
}
static _removeByLogHelper(collectionName, type, err, value, deletedCount) {
if (err) {
Log.error(
{ collectionName, error: err.message, type, value },
'Error trimming collection'
);
} else {
Log.debug(
{ collectionName, type, value, deletedCount },
'Collection trimmed successfully'
);
}
}
static _rowToObjectInfo(row) {
return {
name: row.name,
timestamp: parseTimestampOrNow(row.timestamp),
ownerActorId: row.owner_actor_id,
isPrivate: row.is_private,
};
}
};