diff --git a/net/activity.js b/net/activity.js index 09b6a2e..ae6c77b 100644 --- a/net/activity.js +++ b/net/activity.js @@ -81,6 +81,7 @@ module.exports = { let activity = req.body let object = resLocal.object resLocal.status = 200 + let question /* isNewActivity: false - this inbox has seen this activity before 'new collection' - known activity, new inbox @@ -189,6 +190,45 @@ module.exports = { } } break + case 'create': + question = resLocal.linked.find(({ type }) => type.toLowerCase() === 'question') + if (question) { + let questionType + const targetActivity = object + const targetActivityChoice = targetActivity.name[0].toLowerCase() + if (Object.hasOwn(question, 'oneOf')) { + questionType = 'oneOf' + } else if (Object.hasOwn(question, 'anyOf')) { + questionType = 'anyOf' + } + const chosenCollection = question[questionType].find(({ name }) => name[0].toLowerCase() === targetActivityChoice) + const chosenCollectionId = apex.objectIdFromValue(chosenCollection.replies) + toDo.push((async () => { + activity = await apex.store.updateActivityMeta(activity, 'collection', chosenCollectionId) + const updatedCollection = await apex.getCollection(chosenCollectionId) + question[questionType].find(({ replies }) => replies.id === chosenCollectionId).replies = updatedCollection + if (question._meta) { + question._meta.voteAndVoter[0].push({ + voter: activity.actor[0], + voteName: activity.object[0].name[0] + }) + question.votersCount = [...new Set(question._meta.voteAndVoter[0].map(obj => obj.voter))].length + } else { + const voteAndVoter = [{ + voter: activity.actor[0], + voteName: activity.object[0].name[0] + }] + question.votersCount = 1 + apex.addMeta(question, 'voteAndVoter', voteAndVoter) + } + const updatedQuestion = await apex.store.updateObject(question, actorId, true) + if (updatedQuestion) { + resLocal.postWork.push(async () => { + return apex.publishUpdate(recipient, updatedQuestion, actorId) + }) + } + })()) + } } Promise.all(toDo).then(() => { // configure event hook to be triggered after response sent @@ -306,9 +346,6 @@ module.exports = { resolveThread (req, res, next) { const apex = req.app.locals.apex const resLocal = res.locals.apex - if (!resLocal.activity) { - return next() - } apex.resolveReferences(req.body).then(refs => { resLocal.linked = refs next() diff --git a/net/index.js b/net/index.js index 2631ade..9dfac35 100644 --- a/net/index.js +++ b/net/index.js @@ -68,11 +68,11 @@ module.exports = { validators.jsonld, validators.targetActorWithMeta, security.verifySignature, + activity.resolveThread, validators.actor, validators.activityObject, validators.inboxActivity, activity.save, - activity.resolveThread, activity.inboxSideEffects, activity.forwardFromInbox, responders.status diff --git a/net/validators.js b/net/validators.js index 570dba4..4587f0f 100644 --- a/net/validators.js +++ b/net/validators.js @@ -136,6 +136,29 @@ function inboxActivity (req, res, next) { return next() } } + const question = resLocal.linked.find(({ type }) => type.toLowerCase() === 'question') + if (question) { + const now = new Date() + const pollEndTime = new Date(question.endTime) + if (now > pollEndTime) { + resLocal.status = 403 + next() + } + if (Object.hasOwn(question, 'oneOf')) { + if (question._meta?.voteAndVoter[0].map(obj => obj.voter).includes(activity.actor[0])) { + resLocal.status = 403 + next() + } + } else if (Object.hasOwn(question, 'anyOf')) { + const hasDuplicateVote = question._meta?.voteAndVoter[0].some(({ voter, voteName }) => { + return voter === activity.actor[0] && activity.object[0].name[0] === voteName + }) + if (hasDuplicateVote) { + resLocal.status = 403 + next() + } + } + } tasks.push(apex.embedCollections(activity)) Promise.all(tasks).then(() => { apex.addMeta(req.body, 'collection', recipient.inbox[0]) diff --git a/pub/federation.js b/pub/federation.js index b786544..c2e1252 100644 --- a/pub/federation.js +++ b/pub/federation.js @@ -44,7 +44,7 @@ const refProps = ['inReplyTo', 'object', 'target', 'tag'] async function resolveReferences (object, depth = 0) { const objectPromises = refProps.map(prop => object[prop]) .flat() // may have multiple tags to resolve - .map(o => this.resolveUnknown(o)) + .map(o => this.resolveUnknown(o, true)) .filter(p => p) const objects = (await Promise.allSettled(objectPromises)) .filter(r => r.status === 'fulfilled' && r.value) diff --git a/pub/object.js b/pub/object.js index 314e63e..96f8235 100644 --- a/pub/object.js +++ b/pub/object.js @@ -36,7 +36,7 @@ async function resolveObject (id, includeMeta, refresh, localOnly) { return object } -async function resolveUnknown (objectOrIRI) { +async function resolveUnknown (objectOrIRI, includeMeta) { let object if (!objectOrIRI) return null // For Link/Mention, we want to resolved the linked object @@ -45,9 +45,9 @@ async function resolveUnknown (objectOrIRI) { } // check if already cached if (this.isString(objectOrIRI)) { - object = await this.store.getActivity(objectOrIRI) + object = await this.store.getActivity(objectOrIRI, includeMeta) if (object) return object - object = await this.store.getObject(objectOrIRI) + object = await this.store.getObject(objectOrIRI, includeMeta) if (object) return object /* As local collections are not represented in the DB, instead being generated * on demand, they up getting requested via http below. Perhaps not the most efficient, diff --git a/spec/functional/inbox.spec.js b/spec/functional/inbox.spec.js index 894a5a1..cf71bd8 100644 --- a/spec/functional/inbox.spec.js +++ b/spec/functional/inbox.spec.js @@ -1510,4 +1510,223 @@ describe('inbox', function () { .expect(400) }) }) + + describe('question', function () { + let activity + let question + let reply + beforeEach(async function () { + question = { + type: 'Question', + id: 'https://localhost/o/49e2d03d-b53a-4c4c-a95c-94a6abf45a19', + attributedTo: ['https://localhost/u/test'], + to: ['https://localhost/u/test'], + audience: ['as:Public'], + content: ['Say, did you finish reading that book I lent you?'], + votersCount: [0], + oneOf: [ + { + type: 'Note', + name: ['Yes'], + replies: { + type: 'Collection', + id: 'https://localhost/o/49e2d03d-b53a-4c4c-a95c-94a6abf45a19/votes/Yes', + totalItems: [0] + } + }, + { + type: 'Note', + name: ['No'], + replies: { + type: 'Collection', + id: 'https://localhost/o/49e2d03d-b53a-4c4c-a95c-94a6abf45a19/votes/No', + totalItems: [0] + } + } + ] + } + activity = { + '@context': ['https://www.w3.org/ns/activitystreams'], + type: 'Create', + id: 'https://localhost/s/a29a6843-9feb-4c74-a7f7-081b9c9201d3', + to: ['https://localhost/u/test'], + audience: ['as:Public'], + actor: ['https://localhost/u/test'], + object: [question], + shares: [{ + id: 'https://localhost/s/a29a6843-9feb-4c74-a7f7-081b9c9201d3/shares', + type: 'OrderedCollection', + totalItems: [0], + first: ['https://localhost/s/a29a6843-9feb-4c74-a7f7-081b9c9201d3/shares?page=true'] + }], + likes: [{ + id: 'https://localhost/s/a29a6843-9feb-4c74-a7f7-081b9c9201d3/likes', + type: 'OrderedCollection', + totalItems: [0], + first: ['https://localhost/s/a29a6843-9feb-4c74-a7f7-081b9c9201d3/likes?page=true'] + }] + } + reply = { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://localhost/s/2131231', + to: 'https://localhost/u/test', + actor: 'https://localhost/u/test', + type: 'Create', + object: { + id: 'https://localhost/o/2131231', + type: 'Note', + name: 'Yes', + attributedTo: 'https://localhost/u/test', + to: 'https://localhost/u/test', + inReplyTo: 'https://localhost/o/49e2d03d-b53a-4c4c-a95c-94a6abf45a19' + } + } + await apex.store.saveActivity(activity) + await apex.store.saveObject(question) + }) + it('tracks replies in a collection', async function () { + await request(app) + .post('/inbox/test') + .set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"') + .send(reply) + .expect(200) + + const storedReply = await apex.store.getActivity(reply.id, true) + expect(storedReply._meta.collection).toContain('https://localhost/o/49e2d03d-b53a-4c4c-a95c-94a6abf45a19/votes/Yes') + }) + it('the question replies collection is updated', async function () { + await request(app) + .post('/inbox/test') + .set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"') + .send(reply) + .expect(200) + + const questionStored = await apex.store.getObject(question.id, true) + const chosenCollection = questionStored.oneOf.find(({ name }) => name[0].toLowerCase() === 'yes') + expect(chosenCollection.replies.totalItems[0]).toBe(1) + }) + it('keeps a voterCount tally', async function () { + await request(app) + .post('/inbox/test') + .set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"') + .send(reply) + .expect(200) + let questionStored = await apex.store.getObject(question.id, true) + expect(questionStored.votersCount).toEqual(1) + const anotherVoter = await apex.createActor('voter', 'voter', 'voting user') + await apex.store.saveObject(anotherVoter) + const anotherReply = { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://localhost/s/2131232', + to: 'https://localhost/u/test', + actor: 'https://localhost/u/voter', + type: 'Create', + object: { + id: 'https://localhost/o/2131232', + type: 'Note', + name: 'Yes', + attributedTo: 'https://localhost/u/voter', + to: 'https://localhost/u/test', + inReplyTo: 'https://localhost/o/49e2d03d-b53a-4c4c-a95c-94a6abf45a19' + } + } + await request(app) + .post('/inbox/test') + .set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"') + .send(anotherReply) + .expect(200) + questionStored = await apex.store.getObject(question.id, true) + expect(questionStored.votersCount).toEqual(2) + }) + it('anyOf property allows a user to vote for multiple choices', async function () { + question.anyOf = question.oneOf + delete question.oneOf + await apex.store.updateObject(question, 'test', true) + await request(app) + .post('/inbox/test') + .set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"') + .send(reply) + .expect(200) + reply.id = 'https://localhost/s/2131232' + reply.object.id = 'https://localhost/o/2131232' + reply.object.name = 'No' + await request(app) + .post('/inbox/test') + .set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"') + .send(reply) + .expect(200) + let questionStored = await apex.store.getObject(question.id, true) + const yesCollection = questionStored.anyOf.find(({ name }) => name[0].toLowerCase() === 'yes') + expect(yesCollection.replies.totalItems[0]).toBe(1) + const noCollection = questionStored.anyOf.find(({ name }) => name[0].toLowerCase() === 'no') + expect(noCollection.replies.totalItems[0]).toBe(1) + questionStored = await apex.store.getObject(question.id, true) + expect(questionStored.votersCount).toEqual(1) + }) + it('publishes the results', async function () { + const addrSpy = spyOn(apex, 'address').and.callFake(async () => ['https://ignore.com/inbox/ignored']) + const requestValidated = new Promise(resolve => { + nock('https://mocked.com').post('/inbox/mocked') + .reply(200) + .on('request', async (req, interceptor, body) => { + const sentActivity = JSON.parse(body) + expect(sentActivity.object.votersCount).toEqual(1) + expect(sentActivity.object.oneOf.find(({ name }) => name.toLowerCase() === 'yes').replies.totalItems).toEqual(1) + resolve() + }) + }) + addrSpy.and.callFake(async () => ['https://mocked.com/inbox/mocked']) + await request(app) + .post('/inbox/test') + .set('Content-Type', 'application/activity+json') + .send(reply) + .expect(200) + await requestValidated + }) + describe('validations', function () { + it('wont allow a vote to a closed poll', async function () { + const closedDate = new Date() + closedDate.setDate(closedDate.getDate() - 1) + question.endTime = closedDate + await apex.store.updateObject(question, 'test', true) + await request(app) + .post('/inbox/test') + .set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"') + .send(reply) + .expect(403) + }) + it('prevents the same user from voting for the same choice twice', async function () { + question.anyOf = question.oneOf + delete question.oneOf + await apex.store.updateObject(question, 'test', true) + await request(app) + .post('/inbox/test') + .set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"') + .send(reply) + .expect(200) + reply.id = 'https://localhost/s/2131232' + reply.object.id = 'https://localhost/o/2131232' + await request(app) + .post('/inbox/test') + .set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"') + .send(reply) + .expect(403) + }) + it('oneOf prevents the same user from voting for multiple choices', async function () { + await request(app) + .post('/inbox/test') + .set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"') + .send(reply) + .expect(200) + reply.id = 'https://localhost/s/2131232' + reply.object.id = 'https://localhost/o/2131232' + reply.object.name = 'No' + await request(app) + .post('/inbox/test') + .set('Content-Type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"') + .send(reply) + .expect(403) + }) + }) + }) }) diff --git a/spec/helpers/reporter.js b/spec/helpers/reporter.js index a828849..704511e 100644 --- a/spec/helpers/reporter.js +++ b/spec/helpers/reporter.js @@ -4,7 +4,8 @@ const SpecReporter = require('jasmine-spec-reporter').SpecReporter jasmine.getEnv().clearReporters() // remove default reporter logs jasmine.getEnv().addReporter(new SpecReporter({ // add jasmine-spec-reporter spec: { - displayPending: true + displayPending: true, + displayStacktrace: 'pretty' }, summary: { displayDuration: false diff --git a/spec/helpers/test-utils.js b/spec/helpers/test-utils.js index c767d53..fa0e009 100644 --- a/spec/helpers/test-utils.js +++ b/spec/helpers/test-utils.js @@ -18,6 +18,7 @@ global.initApex = async function initApex () { blocked: '/u/:actor/blocked', rejections: '/u/:actor/rejections', rejected: '/u/:actor/rejected', + votes: '/o/:question/c/:id', nodeinfo: '/nodeinfo' } const app = express()