diff --git a/backend/package.json b/backend/package.json index 6bd5db20f..46c228485 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,7 @@ "description": "GraphQL Backend for Human Connection", "main": "src/index.js", "config": { - "no_auth": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 PERMISSIONS=disabled" + "no_auth": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 DISABLED_MIDDLEWARES=permissions" }, "scripts": { "build": "babel src/ -d dist/ --copy-files", @@ -15,7 +15,7 @@ "test": "nyc --reporter=text-lcov yarn test:jest", "test:cypress": "run-p --race test:before:*", "test:before:server": "cross-env CLIENT_URI=http://localhost:4123 GRAPHQL_URI=http://localhost:4123 GRAPHQL_PORT=4123 babel-node src/ 2> /dev/null", - "test:before:seeder": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 PERMISSIONS=disabled babel-node src/ 2> /dev/null", + "test:before:seeder": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 DISABLED_MIDDLEWARES=permissions,activityPub babel-node src/ 2> /dev/null", "test:jest:cmd": "wait-on tcp:4001 tcp:4123 && jest --forceExit --detectOpenHandles --runInBand", "test:cucumber:cmd": "wait-on tcp:4001 tcp:4123 && cucumber-js --require-module @babel/register --exit test/", "test:jest:cmd:debug": "wait-on tcp:4001 tcp:4123 && node --inspect-brk ./node_modules/.bin/jest -i --forceExit --detectOpenHandles --runInBand", diff --git a/backend/src/activitypub/ActivityPub.js b/backend/src/activitypub/ActivityPub.js index 6c3fb4082..b89bc16da 100644 --- a/backend/src/activitypub/ActivityPub.js +++ b/backend/src/activitypub/ActivityPub.js @@ -22,21 +22,19 @@ let activityPub = null export { activityPub } export default class ActivityPub { - constructor (host, uri) { - this.host = host - this.dataSource = new NitroDataSource(uri) + constructor (activityPubEndpointUri, internalGraphQlUri) { + this.endpoint = activityPubEndpointUri + this.dataSource = new NitroDataSource(internalGraphQlUri) this.collections = new Collections(this.dataSource) } static init (server) { if (!activityPub) { dotenv.config() - const url = new URL(process.env.CLIENT_URI) - activityPub = new ActivityPub(url.host || 'localhost:4000', url.origin) + activityPub = new ActivityPub(process.env.CLIENT_URI || 'http://localhost:3000', process.env.GRAPHQL_URI || 'http://localhost:4000') // integrate into running graphql express server server.express.set('ap', activityPub) - server.express.set('port', url.port) server.express.use(router) console.log('-> ActivityPub middleware added to the graphql express server') } else { diff --git a/backend/src/activitypub/routes/webFinger.js b/backend/src/activitypub/routes/webFinger.js index ad1c806ad..8def32328 100644 --- a/backend/src/activitypub/routes/webFinger.js +++ b/backend/src/activitypub/routes/webFinger.js @@ -12,15 +12,20 @@ router.get('/', async function (req, res) { const nameAndDomain = resource.replace('acct:', '') const name = nameAndDomain.split('@')[0] - const result = await req.app.get('ap').dataSource.client.query({ - query: gql` + let result + try { + result = await req.app.get('ap').dataSource.client.query({ + query: gql` query { User(slug: "${name}") { slug } } ` - }) + }) + } catch (error) { + return res.status(500).json({ error }) + } if (result.data && result.data.User.length > 0) { const webFinger = createWebFinger(name) diff --git a/backend/src/activitypub/utils/activity.js b/backend/src/activitypub/utils/activity.js index a8c525302..57b6dfb83 100644 --- a/backend/src/activitypub/utils/activity.js +++ b/backend/src/activitypub/utils/activity.js @@ -11,14 +11,14 @@ export function createNoteObject (text, name, id, published) { return { '@context': 'https://www.w3.org/ns/activitystreams', - 'id': `https://${activityPub.host}/activitypub/users/${name}/status/${createUuid}`, + 'id': `${activityPub.endpoint}/activitypub/users/${name}/status/${createUuid}`, 'type': 'Create', - 'actor': `https://${activityPub.host}/activitypub/users/${name}`, + 'actor': `${activityPub.endpoint}/activitypub/users/${name}`, 'object': { - 'id': `https://${activityPub.host}/activitypub/users/${name}/status/${id}`, + 'id': `${activityPub.endpoint}/activitypub/users/${name}/status/${id}`, 'type': 'Note', 'published': published, - 'attributedTo': `https://${activityPub.host}/activitypub/users/${name}`, + 'attributedTo': `${activityPub.endpoint}/activitypub/users/${name}`, 'content': text, 'to': 'https://www.w3.org/ns/activitystreams#Public' } @@ -64,8 +64,8 @@ export async function getActorId (name) { export function sendAcceptActivity (theBody, name, targetDomain, url) { as.accept() - .id(`https://${activityPub.host}/activitypub/users/${name}/status/` + crypto.randomBytes(16).toString('hex')) - .actor(`https://${activityPub.host}/activitypub/users/${name}`) + .id(`${activityPub.endpoint}/activitypub/users/${name}/status/` + crypto.randomBytes(16).toString('hex')) + .actor(`${activityPub.endpoint}/activitypub/users/${name}`) .object(theBody) .prettyWrite((err, doc) => { if (!err) { @@ -79,8 +79,8 @@ export function sendAcceptActivity (theBody, name, targetDomain, url) { export function sendRejectActivity (theBody, name, targetDomain, url) { as.reject() - .id(`https://${activityPub.host}/activitypub/users/${name}/status/` + crypto.randomBytes(16).toString('hex')) - .actor(`https://${activityPub.host}/activitypub/users/${name}`) + .id(`${activityPub.endpoint}/activitypub/users/${name}/status/` + crypto.randomBytes(16).toString('hex')) + .actor(`${activityPub.endpoint}/activitypub/users/${name}`) .object(theBody) .prettyWrite((err, doc) => { if (!err) { diff --git a/backend/src/activitypub/utils/actor.js b/backend/src/activitypub/utils/actor.js index 0a3884023..27612517b 100644 --- a/backend/src/activitypub/utils/actor.js +++ b/backend/src/activitypub/utils/actor.js @@ -6,34 +6,35 @@ export function createActor (name, pubkey) { 'https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1' ], - 'id': `https://${activityPub.host}/activitypub/users/${name}`, + 'id': `${activityPub.endpoint}/activitypub/users/${name}`, 'type': 'Person', 'preferredUsername': `${name}`, 'name': `${name}`, - 'following': `https://${activityPub.host}/activitypub/users/${name}/following`, - 'followers': `https://${activityPub.host}/activitypub/users/${name}/followers`, - 'inbox': `https://${activityPub.host}/activitypub/users/${name}/inbox`, - 'outbox': `https://${activityPub.host}/activitypub/users/${name}/outbox`, - 'url': `https://${activityPub.host}/activitypub/@${name}`, + 'following': `${activityPub.endpoint}/activitypub/users/${name}/following`, + 'followers': `${activityPub.endpoint}/activitypub/users/${name}/followers`, + 'inbox': `${activityPub.endpoint}/activitypub/users/${name}/inbox`, + 'outbox': `${activityPub.endpoint}/activitypub/users/${name}/outbox`, + 'url': `${activityPub.endpoint}/activitypub/@${name}`, 'endpoints': { - 'sharedInbox': `https://${activityPub.host}/activitypub/inbox` + 'sharedInbox': `${activityPub.endpoint}/activitypub/inbox` }, 'publicKey': { - 'id': `https://${activityPub.host}/activitypub/users/${name}#main-key`, - 'owner': `https://${activityPub.host}/activitypub/users/${name}`, + 'id': `${activityPub.endpoint}/activitypub/users/${name}#main-key`, + 'owner': `${activityPub.endpoint}/activitypub/users/${name}`, 'publicKeyPem': pubkey } } } export function createWebFinger (name) { + const { host } = new URL(activityPub.endpoint) return { - 'subject': `acct:${name}@${activityPub.host}`, + 'subject': `acct:${name}@${host}`, 'links': [ { 'rel': 'self', 'type': 'application/activity+json', - 'href': `https://${activityPub.host}/activitypub/users/${name}` + 'href': `${activityPub.endpoint}/activitypub/users/${name}` } ] } diff --git a/backend/src/activitypub/utils/collection.js b/backend/src/activitypub/utils/collection.js index 61670bb47..e3a63c74d 100644 --- a/backend/src/activitypub/utils/collection.js +++ b/backend/src/activitypub/utils/collection.js @@ -5,10 +5,10 @@ const debug = require('debug')('ea:utils:collections') export function createOrderedCollection (name, collectionName) { return { '@context': 'https://www.w3.org/ns/activitystreams', - 'id': `https://${activityPub.host}/activitypub/users/${name}/${collectionName}`, + 'id': `${activityPub.endpoint}/activitypub/users/${name}/${collectionName}`, 'summary': `${name}s ${collectionName} collection`, 'type': 'OrderedCollection', - 'first': `https://${activityPub.host}/activitypub/users/${name}/${collectionName}?page=true`, + 'first': `${activityPub.endpoint}/activitypub/users/${name}/${collectionName}?page=true`, 'totalItems': 0 } } @@ -16,11 +16,11 @@ export function createOrderedCollection (name, collectionName) { export function createOrderedCollectionPage (name, collectionName) { return { '@context': 'https://www.w3.org/ns/activitystreams', - 'id': `https://${activityPub.host}/activitypub/users/${name}/${collectionName}?page=true`, + 'id': `${activityPub.endpoint}/activitypub/users/${name}/${collectionName}?page=true`, 'summary': `${name}s ${collectionName} collection`, 'type': 'OrderedCollectionPage', 'totalItems': 0, - 'partOf': `https://${activityPub.host}/activitypub/users/${name}/${collectionName}`, + 'partOf': `${activityPub.endpoint}/activitypub/users/${name}/${collectionName}`, 'orderedItems': [] } } diff --git a/backend/src/activitypub/utils/index.js b/backend/src/activitypub/utils/index.js index 4ae2b073f..e6853cecb 100644 --- a/backend/src/activitypub/utils/index.js +++ b/backend/src/activitypub/utils/index.js @@ -20,8 +20,8 @@ export function extractIdFromActivityId (uri) { return splitted[splitted.indexOf('status') + 1] } -export function constructIdFromName (name, fromDomain = activityPub.host) { - return `http://${fromDomain}/activitypub/users/${name}` +export function constructIdFromName (name, fromDomain = activityPub.endpoint) { + return `${fromDomain}/activitypub/users/${name}` } export function extractDomainFromUrl (url) { @@ -76,7 +76,7 @@ export function signAndSend (activity, fromName, targetDomain, url) { 'Host': targetDomain, 'Date': date, 'Signature': createSignature({ privateKey, - keyId: `http://${activityPub.host}/activitypub/users/${fromName}#main-key`, + keyId: `${activityPub.endpoint}/activitypub/users/${fromName}#main-key`, url, headers: { 'Host': targetDomain, diff --git a/backend/src/middleware/dateTimeMiddleware.js b/backend/src/middleware/dateTimeMiddleware.js index 473dbf444..73b75070c 100644 --- a/backend/src/middleware/dateTimeMiddleware.js +++ b/backend/src/middleware/dateTimeMiddleware.js @@ -1,44 +1,21 @@ +const setCreatedAt = (resolve, root, args, context, info) => { + args.createdAt = (new Date()).toISOString() + return resolve(root, args, context, info) +} +const setUpdatedAt = (resolve, root, args, context, info) => { + args.updatedAt = (new Date()).toISOString() + return resolve(root, args, context, info) +} + export default { Mutation: { - CreateUser: async (resolve, root, args, context, info) => { - args.createdAt = (new Date()).toISOString() - const result = await resolve(root, args, context, info) - return result - }, - CreatePost: async (resolve, root, args, context, info) => { - args.createdAt = (new Date()).toISOString() - const result = await resolve(root, args, context, info) - return result - }, - CreateComment: async (resolve, root, args, context, info) => { - args.createdAt = (new Date()).toISOString() - const result = await resolve(root, args, context, info) - return result - }, - CreateOrganization: async (resolve, root, args, context, info) => { - args.createdAt = (new Date()).toISOString() - const result = await resolve(root, args, context, info) - return result - }, - UpdateUser: async (resolve, root, args, context, info) => { - args.updatedAt = (new Date()).toISOString() - const result = await resolve(root, args, context, info) - return result - }, - UpdatePost: async (resolve, root, args, context, info) => { - args.updatedAt = (new Date()).toISOString() - const result = await resolve(root, args, context, info) - return result - }, - UpdateComment: async (resolve, root, args, context, info) => { - args.updatedAt = (new Date()).toISOString() - const result = await resolve(root, args, context, info) - return result - }, - UpdateOrganization: async (resolve, root, args, context, info) => { - args.updatedAt = (new Date()).toISOString() - const result = await resolve(root, args, context, info) - return result - } + CreateUser: setCreatedAt, + CreatePost: setCreatedAt, + CreateComment: setCreatedAt, + CreateOrganization: setCreatedAt, + UpdateUser: setUpdatedAt, + UpdatePost: setUpdatedAt, + UpdateComment: setUpdatedAt, + UpdateOrganization: setUpdatedAt } } diff --git a/backend/src/middleware/includedFieldsMiddleware.js b/backend/src/middleware/includedFieldsMiddleware.js index 6d8ccf8f1..5dd63cd3c 100644 --- a/backend/src/middleware/includedFieldsMiddleware.js +++ b/backend/src/middleware/includedFieldsMiddleware.js @@ -24,6 +24,6 @@ const includeFieldsRecursively = (includedFields) => { } export default { - Query: includeFieldsRecursively(['id', 'disabled', 'deleted']), - Mutation: includeFieldsRecursively(['id', 'disabled', 'deleted']) + Query: includeFieldsRecursively(['id', 'createdAt', 'disabled', 'deleted']), + Mutation: includeFieldsRecursively(['id', 'createdAt', 'disabled', 'deleted']) } diff --git a/backend/src/middleware/index.js b/backend/src/middleware/index.js index 6ed0955e8..8f86a88e6 100644 --- a/backend/src/middleware/index.js +++ b/backend/src/middleware/index.js @@ -9,6 +9,7 @@ import xssMiddleware from './xssMiddleware' import permissionsMiddleware from './permissionsMiddleware' import userMiddleware from './userMiddleware' import includedFieldsMiddleware from './includedFieldsMiddleware' +import orderByMiddleware from './orderByMiddleware' export default schema => { let middleware = [ @@ -20,14 +21,17 @@ export default schema => { fixImageUrlsMiddleware, softDeleteMiddleware, userMiddleware, - includedFieldsMiddleware + includedFieldsMiddleware, + orderByMiddleware ] // add permisions middleware at the first position (unless we're seeding) // NOTE: DO NOT SET THE PERMISSION FLAT YOUR SELF - if (process.env.PERMISSIONS !== 'disabled' && process.env.NODE_ENV !== 'production') { - middleware.unshift(activityPubMiddleware) - middleware.unshift(permissionsMiddleware.generate(schema)) + if (process.env.NODE_ENV !== 'production') { + const DISABLED_MIDDLEWARES = process.env.DISABLED_MIDDLEWARES || '' + const disabled = DISABLED_MIDDLEWARES.split(',') + if (!disabled.includes('activityPub')) middleware.unshift(activityPubMiddleware) + if (!disabled.includes('permissions')) middleware.unshift(permissionsMiddleware.generate(schema)) } return middleware } diff --git a/backend/src/middleware/orderByMiddleware.js b/backend/src/middleware/orderByMiddleware.js new file mode 100644 index 000000000..5f8aabb9e --- /dev/null +++ b/backend/src/middleware/orderByMiddleware.js @@ -0,0 +1,19 @@ +import cloneDeep from 'lodash/cloneDeep' + +const defaultOrderBy = (resolve, root, args, context, resolveInfo) => { + const copy = cloneDeep(resolveInfo) + const newestFirst = { + kind: 'Argument', + name: { kind: 'Name', value: 'orderBy' }, + value: { kind: 'EnumValue', value: 'createdAt_desc' } + } + const [fieldNode] = copy.fieldNodes + if (fieldNode) fieldNode.arguments.push(newestFirst) + return resolve(root, args, context, copy) +} + +export default { + Query: { + Post: defaultOrderBy + } +} diff --git a/backend/src/middleware/orderByMiddleware.spec.js b/backend/src/middleware/orderByMiddleware.spec.js new file mode 100644 index 000000000..2d85452e5 --- /dev/null +++ b/backend/src/middleware/orderByMiddleware.spec.js @@ -0,0 +1,62 @@ +import Factory from '../seed/factories' +import { host } from '../jest/helpers' +import { GraphQLClient } from 'graphql-request' + +let client +let headers +let query +const factory = Factory() + +beforeEach(async () => { + const userParams = { name: 'Author', email: 'author@example.org', password: '1234' } + await factory.create('User', userParams) + await factory.authenticateAs(userParams) + await factory.create('Post', { title: 'first' }) + await factory.create('Post', { title: 'second' }) + await factory.create('Post', { title: 'third' }) + await factory.create('Post', { title: 'last' }) + headers = {} + client = new GraphQLClient(host, { headers }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('Query', () => { + describe('Post', () => { + beforeEach(() => { + query = '{ Post { title } }' + }) + + describe('orderBy', () => { + it('createdAt descending is default', async () => { + const posts = [ + { title: 'last' }, + { title: 'third' }, + { title: 'second' }, + { title: 'first' } + ] + const expected = { Post: posts } + await expect(client.request(query)).resolves.toEqual(expected) + }) + + describe('(orderBy: createdAt_asc)', () => { + beforeEach(() => { + query = '{ Post(orderBy: createdAt_asc) { title } }' + }) + + it('orders by createdAt ascending', async () => { + const posts = [ + { title: 'first' }, + { title: 'second' }, + { title: 'third' }, + { title: 'last' } + ] + const expected = { Post: posts } + await expect(client.request(query)).resolves.toEqual(expected) + }) + }) + }) + }) +}) diff --git a/backend/src/seed/factories/posts.js b/backend/src/seed/factories/posts.js index e2bc2ab66..83d5844d7 100644 --- a/backend/src/seed/factories/posts.js +++ b/backend/src/seed/factories/posts.js @@ -4,6 +4,7 @@ import uuid from 'uuid/v4' export default function (params) { const { id = uuid(), + slug = '', title = faker.lorem.sentence(), content = [ faker.lorem.sentence(), @@ -21,6 +22,7 @@ export default function (params) { mutation { CreatePost( id: "${id}", + slug: "${slug}", title: "${title}", content: "${content}", image: "${image}", diff --git a/backend/src/seed/factories/users.js b/backend/src/seed/factories/users.js index b6b2384b7..6b2ac9439 100644 --- a/backend/src/seed/factories/users.js +++ b/backend/src/seed/factories/users.js @@ -5,6 +5,7 @@ export default function create (params) { const { id = uuid(), name = faker.name.findName(), + slug = '', email = faker.internet.email(), password = '1234', role = 'user', @@ -19,6 +20,7 @@ export default function create (params) { CreateUser( id: "${id}", name: "${name}", + slug: "${slug}", password: "${password}", email: "${email}", avatar: "${avatar}", @@ -30,6 +32,7 @@ export default function create (params) { ) { id name + slug email avatar role diff --git a/backend/test/features/activity-delete.feature b/backend/test/features/activity-delete.feature index f5e269cce..76c734952 100644 --- a/backend/test/features/activity-delete.feature +++ b/backend/test/features/activity-delete.feature @@ -29,7 +29,7 @@ Feature: Delete an object """ { "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://localhost:4123/activitypub/users/karl-heinz/status/a4DJ2afdg323v32641vna42lkj685kasd2", + "id": "http://localhost:4123/activitypub/users/karl-heinz/status/a4DJ2afdg323v32641vna42lkj685kasd2", "type": "Delete", "object": { "id": "https://aronda.org/activitypub/users/bernd-das-brot/status/kljsdfg9843jknsdf234", diff --git a/backend/test/features/activity-follow.feature b/backend/test/features/activity-follow.feature index 6634a342b..3cfe73340 100644 --- a/backend/test/features/activity-follow.feature +++ b/backend/test/features/activity-follow.feature @@ -15,7 +15,7 @@ Feature: Follow a user """ { "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://localhost:4123/activitypub/users/stuart-little/status/83J23549sda1k72fsa4567na42312455kad83", + "id": "http://localhost:4123/activitypub/users/stuart-little/status/83J23549sda1k72fsa4567na42312455kad83", "type": "Follow", "actor": "http://localhost:4123/activitypub/users/stuart-little", "object": "http://localhost:4123/activitypub/users/tero-vota" @@ -32,11 +32,11 @@ Feature: Follow a user """ { "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://localhost:4123/activitypub/users/tero-vota/status/a4DJ2afdg323v32641vna42lkj685kasd2", + "id": "http://localhost:4123/activitypub/users/tero-vota/status/a4DJ2afdg323v32641vna42lkj685kasd2", "type": "Undo", "actor": "http://localhost:4123/activitypub/users/tero-vota", "object": { - "id": "https://localhost:4123/activitypub/users/stuart-little/status/83J23549sda1k72fsa4567na42312455kad83", + "id": "http://localhost:4123/activitypub/users/stuart-little/status/83J23549sda1k72fsa4567na42312455kad83", "type": "Follow", "actor": "http://localhost:4123/activitypub/users/stuart-little", "object": "http://localhost:4123/activitypub/users/tero-vota" diff --git a/backend/test/features/activity-like.feature b/backend/test/features/activity-like.feature index 35d32c842..ec8c99110 100644 --- a/backend/test/features/activity-like.feature +++ b/backend/test/features/activity-like.feature @@ -13,14 +13,14 @@ Feature: Like an object like an article or note """ { "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://localhost:4123/activitypub/users/karl-heinz/status/faslkasa7dasfzkjn2398hsfd", + "id": "http://localhost:4123/activitypub/users/karl-heinz/status/faslkasa7dasfzkjn2398hsfd", "type": "Create", - "actor": "https://localhost:4123/activitypub/users/karl-heinz", + "actor": "http://localhost:4123/activitypub/users/karl-heinz", "object": { - "id": "https://localhost:4123/activitypub/users/karl-heinz/status/dkasfljsdfaafg9843jknsdf", + "id": "http://localhost:4123/activitypub/users/karl-heinz/status/dkasfljsdfaafg9843jknsdf", "type": "Article", "published": "2019-02-07T19:37:55.002Z", - "attributedTo": "https://localhost:4123/activitypub/users/karl-heinz", + "attributedTo": "http://localhost:4123/activitypub/users/karl-heinz", "content": "Hi Max, how are you?", "to": "https://www.w3.org/ns/activitystreams#Public" } @@ -32,7 +32,7 @@ Feature: Like an object like an article or note """ { "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://localhost:4123/activitypub/users/peter-lustiger/status/83J23549sda1k72fsa4567na42312455kad83", + "id": "http://localhost:4123/activitypub/users/peter-lustiger/status/83J23549sda1k72fsa4567na42312455kad83", "type": "Like", "actor": "http://localhost:4123/activitypub/users/peter-lustiger", "object": "http://localhost:4123/activitypub/users/karl-heinz/status/dkasfljsdfaafg9843jknsdf" diff --git a/backend/test/features/collection.feature b/backend/test/features/collection.feature index 536d3aa2d..1bb4737e0 100644 --- a/backend/test/features/collection.feature +++ b/backend/test/features/collection.feature @@ -14,10 +14,10 @@ Feature: Receiving collections """ { "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://localhost:4123/activitypub/users/renate-oberdorfer/outbox", + "id": "http://localhost:4123/activitypub/users/renate-oberdorfer/outbox", "summary": "renate-oberdorfers outbox collection", "type": "OrderedCollection", - "first": "https://localhost:4123/activitypub/users/renate-oberdorfer/outbox?page=true", + "first": "http://localhost:4123/activitypub/users/renate-oberdorfer/outbox?page=true", "totalItems": 0 } """ @@ -29,10 +29,10 @@ Feature: Receiving collections """ { "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://localhost:4123/activitypub/users/renate-oberdorfer/following", + "id": "http://localhost:4123/activitypub/users/renate-oberdorfer/following", "summary": "renate-oberdorfers following collection", "type": "OrderedCollection", - "first": "https://localhost:4123/activitypub/users/renate-oberdorfer/following?page=true", + "first": "http://localhost:4123/activitypub/users/renate-oberdorfer/following?page=true", "totalItems": 0 } """ @@ -44,10 +44,10 @@ Feature: Receiving collections """ { "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://localhost:4123/activitypub/users/renate-oberdorfer/followers", + "id": "http://localhost:4123/activitypub/users/renate-oberdorfer/followers", "summary": "renate-oberdorfers followers collection", "type": "OrderedCollection", - "first": "https://localhost:4123/activitypub/users/renate-oberdorfer/followers?page=true", + "first": "http://localhost:4123/activitypub/users/renate-oberdorfer/followers?page=true", "totalItems": 0 } """ @@ -59,11 +59,11 @@ Feature: Receiving collections """ { "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://localhost:4123/activitypub/users/renate-oberdorfer/outbox?page=true", + "id": "http://localhost:4123/activitypub/users/renate-oberdorfer/outbox?page=true", "summary": "renate-oberdorfers outbox collection", "type": "OrderedCollectionPage", "totalItems": 0, - "partOf": "https://localhost:4123/activitypub/users/renate-oberdorfer/outbox", + "partOf": "http://localhost:4123/activitypub/users/renate-oberdorfer/outbox", "orderedItems": [] } """ @@ -75,11 +75,11 @@ Feature: Receiving collections """ { "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://localhost:4123/activitypub/users/renate-oberdorfer/following?page=true", + "id": "http://localhost:4123/activitypub/users/renate-oberdorfer/following?page=true", "summary": "renate-oberdorfers following collection", "type": "OrderedCollectionPage", "totalItems": 0, - "partOf": "https://localhost:4123/activitypub/users/renate-oberdorfer/following", + "partOf": "http://localhost:4123/activitypub/users/renate-oberdorfer/following", "orderedItems": [] } """ @@ -91,11 +91,11 @@ Feature: Receiving collections """ { "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://localhost:4123/activitypub/users/renate-oberdorfer/followers?page=true", + "id": "http://localhost:4123/activitypub/users/renate-oberdorfer/followers?page=true", "summary": "renate-oberdorfers followers collection", "type": "OrderedCollectionPage", "totalItems": 0, - "partOf": "https://localhost:4123/activitypub/users/renate-oberdorfer/followers", + "partOf": "http://localhost:4123/activitypub/users/renate-oberdorfer/followers", "orderedItems": [] } """ diff --git a/backend/test/features/webfinger.feature b/backend/test/features/webfinger.feature index c9f9e587b..72062839a 100644 --- a/backend/test/features/webfinger.feature +++ b/backend/test/features/webfinger.feature @@ -4,7 +4,7 @@ Feature: Webfinger discovery In order to follow the actor Background: - Given our own server runs at "http://localhost:4100" + Given our own server runs at "http://localhost:4123" And we have the following users in our database: | Slug | | peter-lustiger | @@ -19,7 +19,7 @@ Feature: Webfinger discovery { "rel": "self", "type": "application/activity+json", - "href": "https://localhost:4123/activitypub/users/peter-lustiger" + "href": "http://localhost:4123/activitypub/users/peter-lustiger" } ] } @@ -44,21 +44,21 @@ Feature: Webfinger discovery "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1" ], - "id": "https://localhost:4123/activitypub/users/peter-lustiger", + "id": "http://localhost:4123/activitypub/users/peter-lustiger", "type": "Person", "preferredUsername": "peter-lustiger", "name": "peter-lustiger", - "following": "https://localhost:4123/activitypub/users/peter-lustiger/following", - "followers": "https://localhost:4123/activitypub/users/peter-lustiger/followers", - "inbox": "https://localhost:4123/activitypub/users/peter-lustiger/inbox", - "outbox": "https://localhost:4123/activitypub/users/peter-lustiger/outbox", - "url": "https://localhost:4123/activitypub/@peter-lustiger", + "following": "http://localhost:4123/activitypub/users/peter-lustiger/following", + "followers": "http://localhost:4123/activitypub/users/peter-lustiger/followers", + "inbox": "http://localhost:4123/activitypub/users/peter-lustiger/inbox", + "outbox": "http://localhost:4123/activitypub/users/peter-lustiger/outbox", + "url": "http://localhost:4123/activitypub/@peter-lustiger", "endpoints": { - "sharedInbox": "https://localhost:4123/activitypub/inbox" + "sharedInbox": "http://localhost:4123/activitypub/inbox" }, "publicKey": { - "id": "https://localhost:4123/activitypub/users/peter-lustiger#main-key", - "owner": "https://localhost:4123/activitypub/users/peter-lustiger", + "id": "http://localhost:4123/activitypub/users/peter-lustiger#main-key", + "owner": "http://localhost:4123/activitypub/users/peter-lustiger", "publicKeyPem": "adglkjlk89235kjn8obn2384f89z5bv9..." } } diff --git a/cypress/integration/06.Search.feature b/cypress/integration/06.Search.feature index 0a4450829..71aee608a 100644 --- a/cypress/integration/06.Search.feature +++ b/cypress/integration/06.Search.feature @@ -8,7 +8,7 @@ Feature: Search And we have the following posts in our database: | Author | id | title | content | | Brianna Wiest | p1 | 101 Essays that will change the way you think | 101 Essays, of course! | - | Brianna Wiest | p1 | No searched for content | will be found in this post, I guarantee | + | Brianna Wiest | p2 | No searched for content | will be found in this post, I guarantee | Given I am logged in Scenario: Search for specific words diff --git a/cypress/integration/06.WritePost.feature b/cypress/integration/06.WritePost.feature index 0193e44bf..fed1bbf2f 100644 --- a/cypress/integration/06.WritePost.feature +++ b/cypress/integration/06.WritePost.feature @@ -17,7 +17,7 @@ Feature: Create a post for active citizenship. """ And I click on "Save" - Then I get redirected to "/post/my-first-post/" + Then I get redirected to ".../my-first-post" And the post was saved successfully Scenario: See a post on the landing page diff --git a/cypress/integration/common/report.js b/cypress/integration/common/report.js index 12f1b326f..e3d3f3975 100644 --- a/cypress/integration/common/report.js +++ b/cypress/integration/common/report.js @@ -71,7 +71,7 @@ When('I click on the author', () => { }) When('I report the author', () => { - cy.get('.page-name-profile-slug').then(() => { + cy.get('.page-name-profile-id-slug').then(() => { invokeReportOnElement('.ds-card').then(() => { cy.get('button') .contains('Send') diff --git a/cypress/integration/common/search.js b/cypress/integration/common/search.js index 4809e8a13..1c1981581 100644 --- a/cypress/integration/common/search.js +++ b/cypress/integration/common/search.js @@ -42,9 +42,13 @@ When('I select an entry', () => { }) Then("I should be on the post's page", () => { + cy.location('pathname').should( + 'contain', + '/post/' + ) cy.location('pathname').should( 'eq', - '/post/101-essays-that-will-change-the-way-you-think/' + '/post/p1/101-essays-that-will-change-the-way-you-think' ) }) diff --git a/cypress/integration/common/steps.js b/cypress/integration/common/steps.js index 1a9891a7a..8944b7c25 100644 --- a/cypress/integration/common/steps.js +++ b/cypress/integration/common/steps.js @@ -86,6 +86,10 @@ Given('my user account has the role {string}', role => { When('I log out', cy.logout) +When('I visit {string}', page => { + cy.openPage(page) +}) + When('I visit the {string} page', page => { cy.openPage(page) }) @@ -220,7 +224,7 @@ Then('the post shows up on the landing page at position {int}', index => { }) Then('I get redirected to {string}', route => { - cy.location('pathname').should('contain', route) + cy.location('pathname').should('contain', route.replace('...', '')) }) Then('the post was saved successfully', () => { diff --git a/cypress/integration/identifier/PersistentLinks.feature b/cypress/integration/identifier/PersistentLinks.feature new file mode 100644 index 000000000..5ea48ef6a --- /dev/null +++ b/cypress/integration/identifier/PersistentLinks.feature @@ -0,0 +1,41 @@ +Feature: Persistent Links + As a user + I want all links to carry permanent information that identifies the linked resource + In order to have persistent links even if a part of the URL might change + + | | Modifiable | Referenceable | Unique | Purpose | + | -- | -- | -- | -- | -- | + | ID | no | yes | yes | Identity, Traceability, Links | + | Slug | yes | yes | yes | @-Mentions, SEO-friendly URL | + | Name | yes | no | no | Search, self-description | + + + Background: + Given we have the following user accounts: + | id | name | slug | + | MHNqce98y1 | Stephen Hawking | thehawk | + And we have the following posts in our database: + | id | title | slug | + | bWBjpkTKZp | 101 Essays that will change the way you think | 101-essays | + And I have a user account + And I am logged in + + Scenario Outline: Link with slug only is valid and gets auto-completed + When I visit "" + Then I get redirected to "" + Examples: + | url | redirectUrl | + | /profile/thehawk | /profile/MHNqce98y1/thehawk | + | /post/101-essays | /post/bWBjpkTKZp/101-essays | + + Scenario: Link with id only will always point to the same user + When I visit "/profile/MHNqce98y1" + Then I get redirected to "/profile/MHNqce98y1/thehawk" + + Scenario Outline: ID takes precedence over slug + When I visit "" + Then I get redirected to "" + Examples: + | url | redirectUrl | + | /profile/MHNqce98y1/stephen-hawking | /profile/MHNqce98y1/thehawk | + | /post/bWBjpkTKZp/the-way-you-think | /post/bWBjpkTKZp/101-essays | diff --git a/webapp/components/ContributionForm.vue b/webapp/components/ContributionForm.vue index cf7c28ece..3ef041569 100644 --- a/webapp/components/ContributionForm.vue +++ b/webapp/components/ContributionForm.vue @@ -111,8 +111,8 @@ export default { const result = res.data[this.id ? 'UpdatePost' : 'CreatePost'] this.$router.push({ - name: 'post-slug', - params: { slug: result.slug } + name: 'post-id-slug', + params: { id: result.id, slug: result.slug } }) }) .catch(err => { diff --git a/webapp/components/PostCard.vue b/webapp/components/PostCard.vue index 8f534f6ff..767835f74 100644 --- a/webapp/components/PostCard.vue +++ b/webapp/components/PostCard.vue @@ -106,8 +106,8 @@ export default { methods: { href(post) { return this.$router.resolve({ - name: 'post-slug', - params: { slug: post.slug } + name: 'post-id-slug', + params: { id: post.id, slug: post.slug } }).href } } diff --git a/webapp/components/User.vue b/webapp/components/User.vue index 1c78b34cc..dd176a67d 100644 --- a/webapp/components/User.vue +++ b/webapp/components/User.vue @@ -153,9 +153,9 @@ export default { return count }, userLink() { - const { slug } = this.user - if (!slug) return '' - return { name: 'profile-slug', params: { slug } } + const { id, slug } = this.user + if (!(id && slug)) return '' + return { name: 'profile-id-slug', params: { slug, id } } } } } diff --git a/webapp/graphql/ModerationListQuery.js b/webapp/graphql/ModerationListQuery.js index d8105e388..940ada6f6 100644 --- a/webapp/graphql/ModerationListQuery.js +++ b/webapp/graphql/ModerationListQuery.js @@ -9,58 +9,69 @@ export default app => { type createdAt submitter { + id + slug + name disabled deleted - name - slug } user { - name + id slug + name disabled deleted disabledBy { + id slug name + disabled + deleted } } comment { contentExcerpt author { - name + id slug + name disabled deleted } post { + id + slug + title disabled deleted - title - slug } disabledBy { - disabled - deleted + id slug name + disabled + deleted } } post { - title + id slug + title disabled deleted author { + id + slug + name disabled deleted - name - slug } disabledBy { - disabled - deleted + id slug name + disabled + deleted } } } diff --git a/webapp/graphql/UserProfileQuery.js b/webapp/graphql/UserProfileQuery.js index 683f0e3ac..f0d7720ae 100644 --- a/webapp/graphql/UserProfileQuery.js +++ b/webapp/graphql/UserProfileQuery.js @@ -6,6 +6,7 @@ export default app => { query User($slug: String!, $first: Int, $offset: Int) { User(slug: $slug) { id + slug name avatar about @@ -27,8 +28,8 @@ export default app => { followingCount following(first: 7) { id - name slug + name avatar disabled deleted @@ -49,10 +50,10 @@ export default app => { followedByCurrentUser followedBy(first: 7) { id + slug name disabled deleted - slug avatar followedByCount followedByCurrentUser @@ -87,6 +88,7 @@ export default app => { } author { id + slug avatar name disabled diff --git a/webapp/layouts/default.vue b/webapp/layouts/default.vue index bdb41f8b2..991662350 100644 --- a/webapp/layouts/default.vue +++ b/webapp/layouts/default.vue @@ -39,7 +39,7 @@ > { this.$router.push({ - name: 'post-slug', - params: { slug: item.slug } + name: 'post-id-slug', + params: { id: item.id, slug: item.slug } }) }) }, diff --git a/webapp/mixins/persistentLinks.js b/webapp/mixins/persistentLinks.js new file mode 100644 index 000000000..5cecbbdbd --- /dev/null +++ b/webapp/mixins/persistentLinks.js @@ -0,0 +1,32 @@ +export default function(options = {}) { + const { queryId, querySlug, path, message = 'Page not found.' } = options + return { + asyncData: async context => { + const { + params: { id, slug }, + redirect, + error, + app: { apolloProvider } + } = context + const idOrSlug = id || slug + + const variables = { idOrSlug } + const client = apolloProvider.defaultClient + + let response + let resource + response = await client.query({ query: queryId, variables }) + resource = response.data[Object.keys(response.data)[0]][0] + if (resource && resource.slug === slug) return // all good + if (resource && resource.slug !== slug) { + return redirect(`/${path}/${resource.id}/${resource.slug}`) + } + + response = await client.query({ query: querySlug, variables }) + resource = response.data[Object.keys(response.data)[0]][0] + if (resource) return redirect(`/${path}/${resource.id}/${resource.slug}`) + + return error({ statusCode: 404, message }) + } + } +} diff --git a/webapp/nuxt.config.js b/webapp/nuxt.config.js index 80b17a26c..6cac26ea1 100644 --- a/webapp/nuxt.config.js +++ b/webapp/nuxt.config.js @@ -123,13 +123,22 @@ module.exports = { proxy: { '/.well-known/webfinger': { target: process.env.GRAPHQL_URI || 'http://localhost:4000', - toProxy: true // cloudflare needs that + toProxy: true, // cloudflare needs that + headers: { + Accept: 'application/json', + 'X-UI-Request': true, + 'X-API-TOKEN': process.env.BACKEND_TOKEN || 'NULL' + } }, - '/activityPub': { + '/activitypub': { // make this configurable (nuxt-dotenv) target: process.env.GRAPHQL_URI || 'http://localhost:4000', - pathRewrite: { '^/activityPub': '' }, - toProxy: true // cloudflare needs that + toProxy: true, // cloudflare needs that + headers: { + Accept: 'application/json', + 'X-UI-Request': true, + 'X-API-TOKEN': process.env.BACKEND_TOKEN || 'NULL' + } }, '/api': { // make this configurable (nuxt-dotenv) diff --git a/webapp/pages/index.vue b/webapp/pages/index.vue index e28d6b2c3..ee824eb59 100644 --- a/webapp/pages/index.vue +++ b/webapp/pages/index.vue @@ -64,8 +64,8 @@ export default { }, href(post) { return this.$router.resolve({ - name: 'post-slug', - params: { slug: post.slug } + name: 'post-id-slug', + params: { id: post.id, slug: post.slug } }).href }, showMoreContributions() { diff --git a/webapp/pages/moderation/index.vue b/webapp/pages/moderation/index.vue index cd41dc17c..fc5d1fbe6 100644 --- a/webapp/pages/moderation/index.vue +++ b/webapp/pages/moderation/index.vue @@ -14,7 +14,7 @@ slot-scope="scope" >
- + {{ scope.row.post.title | truncate(50) }}
- + {{ scope.row.comment.contentExcerpt | truncate(50) }}
- + {{ scope.row.user.name | truncate(50) }}
@@ -69,7 +69,7 @@ slot="submitter" slot-scope="scope" > - + {{ scope.row.submitter.name }} @@ -79,19 +79,19 @@ > {{ scope.row.post.disabledBy.name | truncate(50) }} {{ scope.row.comment.disabledBy.name | truncate(50) }} {{ scope.row.user.disabledBy.name | truncate(50) }} diff --git a/webapp/pages/post/_slug.vue b/webapp/pages/post/_id.vue similarity index 54% rename from webapp/pages/post/_slug.vue rename to webapp/pages/post/_id.vue index d4a233f0f..21dd4b292 100644 --- a/webapp/pages/post/_slug.vue +++ b/webapp/pages/post/_id.vue @@ -17,35 +17,62 @@