Merge branch 'master' of github.com:Human-Connection/Human-Connection into 236-list-social-media-accounts

This commit is contained in:
Matt Rider 2019-03-27 08:33:16 -03:00
commit 3f9916404e
42 changed files with 401 additions and 174 deletions

View File

@ -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",

View File

@ -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 {

View File

@ -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)

View File

@ -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) {

View File

@ -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}`
}
]
}

View File

@ -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': []
}
}

View File

@ -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,

View File

@ -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
}
}

View File

@ -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'])
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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)
})
})
})
})
})

View File

@ -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}",

View File

@ -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

View File

@ -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",

View File

@ -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"

View File

@ -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"

View File

@ -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": []
}
"""

View File

@ -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..."
}
}

View File

@ -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

View File

@ -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

View File

@ -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')

View File

@ -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'
)
})

View File

@ -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', () => {

View File

@ -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 "<url>"
Then I get redirected to "<redirectUrl>"
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 "<url>"
Then I get redirected to "<redirectUrl>"
Examples:
| url | redirectUrl |
| /profile/MHNqce98y1/stephen-hawking | /profile/MHNqce98y1/thehawk |
| /post/bWBjpkTKZp/the-way-you-think | /post/bWBjpkTKZp/101-essays |

View File

@ -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 => {

View File

@ -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
}
}

View File

@ -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 } }
}
}
}

View File

@ -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
}
}
}

View File

@ -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

View File

@ -39,7 +39,7 @@
>
<a
class="avatar-menu-trigger"
:href="$router.resolve({name: 'profile-slug', params: {slug: user.slug}}).href"
:href="$router.resolve({name: 'profile-id-slug', params: {id: user.id, slug: user.slug}}).href"
@click.prevent="toggleMenu"
>
<ds-avatar
@ -182,8 +182,8 @@ export default {
goToPost(item) {
this.$nextTick(() => {
this.$router.push({
name: 'post-slug',
params: { slug: item.slug }
name: 'post-id-slug',
params: { id: item.id, slug: item.slug }
})
})
},

View File

@ -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 })
}
}
}

View File

@ -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)

View File

@ -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() {

View File

@ -14,7 +14,7 @@
slot-scope="scope"
>
<div v-if="scope.row.type === 'Post'">
<nuxt-link :to="{ name: 'post-slug', params: { slug: scope.row.post.slug } }">
<nuxt-link :to="{ name: 'post-id-slug', params: { id: scope.row.post.id, slug: scope.row.post.slug } }">
<b>{{ scope.row.post.title | truncate(50) }}</b>
</nuxt-link><br>
<ds-text
@ -25,7 +25,7 @@
</ds-text>
</div>
<div v-else-if="scope.row.type === 'Comment'">
<nuxt-link :to="{ name: 'post-slug', params: { slug: scope.row.comment.post.slug } }">
<nuxt-link :to="{ name: 'post-id-slug', params: { id: scope.row.comment.post.id, slug: scope.row.comment.post.slug } }">
<b>{{ scope.row.comment.contentExcerpt | truncate(50) }}</b>
</nuxt-link><br>
<ds-text
@ -36,7 +36,7 @@
</ds-text>
</div>
<div v-else>
<nuxt-link :to="{ name: 'profile-slug', params: { slug: scope.row.user.slug } }">
<nuxt-link :to="{ name: 'profile-id-slug', params: { id: scope.row.user.id, slug: scope.row.user.slug } }">
<b>{{ scope.row.user.name | truncate(50) }}</b>
</nuxt-link>
</div>
@ -69,7 +69,7 @@
slot="submitter"
slot-scope="scope"
>
<nuxt-link :to="{ name: 'profile-slug', params: { slug: scope.row.submitter.slug } }">
<nuxt-link :to="{ name: 'profile-id-slug', params: { id: scope.row.submitter.id, slug: scope.row.submitter.slug } }">
{{ scope.row.submitter.name }}
</nuxt-link>
</template>
@ -79,19 +79,19 @@
>
<nuxt-link
v-if="scope.row.type === 'Post' && scope.row.post.disabledBy"
:to="{ name: 'profile-slug', params: { slug: scope.row.post.disabledBy.slug } }"
:to="{ name: 'profile-id-slug', params: { id: scope.row.post.disabledBy.id, slug: scope.row.post.disabledBy.slug } }"
>
<b>{{ scope.row.post.disabledBy.name | truncate(50) }}</b>
</nuxt-link>
<nuxt-link
v-else-if="scope.row.type === 'Comment' && scope.row.comment.disabledBy"
:to="{ name: 'profile-slug', params: { slug: scope.row.comment.disabledBy.slug } }"
:to="{ name: 'profile-id-slug', params: { id: scope.row.comment.disabledBy.id, slug: scope.row.comment.disabledBy.slug } }"
>
<b>{{ scope.row.comment.disabledBy.name | truncate(50) }}</b>
</nuxt-link>
<nuxt-link
v-else-if="scope.row.type === 'User' && scope.row.user.disabledBy"
:to="{ name: 'profile-slug', params: { slug: scope.row.user.disabledBy.slug } }"
:to="{ name: 'profile-id-slug', params: { id: scope.row.user.disabledBy.id, slug: scope.row.user.disabledBy.slug } }"
>
<b>{{ scope.row.user.disabledBy.name | truncate(50) }}</b>
</nuxt-link>

View File

@ -17,35 +17,62 @@
</template>
<script>
import gql from 'graphql-tag'
import PersistentLinks from '~/mixins/persistentLinks.js'
const options = {
queryId: gql`
query($idOrSlug: ID) {
Post(id: $idOrSlug) {
id
slug
}
}
`,
querySlug: gql`
query($idOrSlug: String) {
Post(slug: $idOrSlug) {
id
slug
}
}
`,
path: 'post',
message: 'This post could not be found'
}
const persistentLinks = PersistentLinks(options)
export default {
mixins: [persistentLinks],
computed: {
routes() {
const { slug, id } = this.$route.params
return [
{
name: this.$t('common.post', null, 1),
path: `/post/${this.$route.params.slug}`,
path: `/post/${id}/${slug}`,
children: [
{
name: this.$t('common.comment', null, 2),
path: `/post/${this.$route.params.slug}#comments`
path: `/post/${id}/${slug}#comments`
},
{
name: this.$t('common.letsTalk'),
path: `/post/${this.$route.params.slug}#lets-talk`
path: `/post/${id}/${slug}#lets-talk`
},
{
name: this.$t('common.versus'),
path: `/post/${this.$route.params.slug}#versus`
path: `/post/${id}/${slug}#versus`
}
]
},
{
name: this.$t('common.moreInfo'),
path: `/post/${this.$route.params.slug}/more-info`
path: `/post/${id}/${slug}/more-info`
},
{
name: this.$t('common.takeAction'),
path: `/post/${this.$route.params.slug}/take-action`
path: `/post/${id}/${slug}/take-action`
}
]
}

View File

@ -263,7 +263,7 @@ export default {
</script>
<style lang="scss">
.page-name-post-slug {
.page-name-post-id-slug {
.content-menu {
float: right;
margin-right: -$space-x-small;

View File

@ -0,0 +1,34 @@
<template>
<nuxt-child />
</template>
<script>
import gql from 'graphql-tag'
import PersistentLinks from '~/mixins/persistentLinks.js'
const options = {
queryId: gql`
query($idOrSlug: ID) {
User(id: $idOrSlug) {
id
slug
}
}
`,
querySlug: gql`
query($idOrSlug: String) {
User(slug: $idOrSlug) {
id
slug
}
}
`,
message: 'This user could not be found',
path: 'profile'
}
const persistentLinks = PersistentLinks(options)
export default {
mixins: [persistentLinks]
}
</script>

View File

@ -423,7 +423,7 @@ export default {
border: #fff 5px solid;
}
.page-name-profile-slug {
.page-name-profile-id-slug {
.ds-flex-item:first-child .content-menu {
position: absolute;
top: $space-x-small;

View File

@ -1,8 +0,0 @@
<script>
export default {
layout: 'blank',
asyncData({ error }) {
error({ statusCode: 404, message: 'Profile slug missing' })
}
}
</script>