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", "description": "GraphQL Backend for Human Connection",
"main": "src/index.js", "main": "src/index.js",
"config": { "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": { "scripts": {
"build": "babel src/ -d dist/ --copy-files", "build": "babel src/ -d dist/ --copy-files",
@ -15,7 +15,7 @@
"test": "nyc --reporter=text-lcov yarn test:jest", "test": "nyc --reporter=text-lcov yarn test:jest",
"test:cypress": "run-p --race test:before:*", "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: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: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: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", "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 { activityPub }
export default class ActivityPub { export default class ActivityPub {
constructor (host, uri) { constructor (activityPubEndpointUri, internalGraphQlUri) {
this.host = host this.endpoint = activityPubEndpointUri
this.dataSource = new NitroDataSource(uri) this.dataSource = new NitroDataSource(internalGraphQlUri)
this.collections = new Collections(this.dataSource) this.collections = new Collections(this.dataSource)
} }
static init (server) { static init (server) {
if (!activityPub) { if (!activityPub) {
dotenv.config() dotenv.config()
const url = new URL(process.env.CLIENT_URI) activityPub = new ActivityPub(process.env.CLIENT_URI || 'http://localhost:3000', process.env.GRAPHQL_URI || 'http://localhost:4000')
activityPub = new ActivityPub(url.host || 'localhost:4000', url.origin)
// integrate into running graphql express server // integrate into running graphql express server
server.express.set('ap', activityPub) server.express.set('ap', activityPub)
server.express.set('port', url.port)
server.express.use(router) server.express.use(router)
console.log('-> ActivityPub middleware added to the graphql express server') console.log('-> ActivityPub middleware added to the graphql express server')
} else { } else {

View File

@ -12,7 +12,9 @@ router.get('/', async function (req, res) {
const nameAndDomain = resource.replace('acct:', '') const nameAndDomain = resource.replace('acct:', '')
const name = nameAndDomain.split('@')[0] const name = nameAndDomain.split('@')[0]
const result = await req.app.get('ap').dataSource.client.query({ let result
try {
result = await req.app.get('ap').dataSource.client.query({
query: gql` query: gql`
query { query {
User(slug: "${name}") { User(slug: "${name}") {
@ -21,6 +23,9 @@ router.get('/', async function (req, res) {
} }
` `
}) })
} catch (error) {
return res.status(500).json({ error })
}
if (result.data && result.data.User.length > 0) { if (result.data && result.data.User.length > 0) {
const webFinger = createWebFinger(name) const webFinger = createWebFinger(name)

View File

@ -11,14 +11,14 @@ export function createNoteObject (text, name, id, published) {
return { return {
'@context': 'https://www.w3.org/ns/activitystreams', '@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', 'type': 'Create',
'actor': `https://${activityPub.host}/activitypub/users/${name}`, 'actor': `${activityPub.endpoint}/activitypub/users/${name}`,
'object': { 'object': {
'id': `https://${activityPub.host}/activitypub/users/${name}/status/${id}`, 'id': `${activityPub.endpoint}/activitypub/users/${name}/status/${id}`,
'type': 'Note', 'type': 'Note',
'published': published, 'published': published,
'attributedTo': `https://${activityPub.host}/activitypub/users/${name}`, 'attributedTo': `${activityPub.endpoint}/activitypub/users/${name}`,
'content': text, 'content': text,
'to': 'https://www.w3.org/ns/activitystreams#Public' 'to': 'https://www.w3.org/ns/activitystreams#Public'
} }
@ -64,8 +64,8 @@ export async function getActorId (name) {
export function sendAcceptActivity (theBody, name, targetDomain, url) { export function sendAcceptActivity (theBody, name, targetDomain, url) {
as.accept() as.accept()
.id(`https://${activityPub.host}/activitypub/users/${name}/status/` + crypto.randomBytes(16).toString('hex')) .id(`${activityPub.endpoint}/activitypub/users/${name}/status/` + crypto.randomBytes(16).toString('hex'))
.actor(`https://${activityPub.host}/activitypub/users/${name}`) .actor(`${activityPub.endpoint}/activitypub/users/${name}`)
.object(theBody) .object(theBody)
.prettyWrite((err, doc) => { .prettyWrite((err, doc) => {
if (!err) { if (!err) {
@ -79,8 +79,8 @@ export function sendAcceptActivity (theBody, name, targetDomain, url) {
export function sendRejectActivity (theBody, name, targetDomain, url) { export function sendRejectActivity (theBody, name, targetDomain, url) {
as.reject() as.reject()
.id(`https://${activityPub.host}/activitypub/users/${name}/status/` + crypto.randomBytes(16).toString('hex')) .id(`${activityPub.endpoint}/activitypub/users/${name}/status/` + crypto.randomBytes(16).toString('hex'))
.actor(`https://${activityPub.host}/activitypub/users/${name}`) .actor(`${activityPub.endpoint}/activitypub/users/${name}`)
.object(theBody) .object(theBody)
.prettyWrite((err, doc) => { .prettyWrite((err, doc) => {
if (!err) { if (!err) {

View File

@ -6,34 +6,35 @@ export function createActor (name, pubkey) {
'https://www.w3.org/ns/activitystreams', 'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1' 'https://w3id.org/security/v1'
], ],
'id': `https://${activityPub.host}/activitypub/users/${name}`, 'id': `${activityPub.endpoint}/activitypub/users/${name}`,
'type': 'Person', 'type': 'Person',
'preferredUsername': `${name}`, 'preferredUsername': `${name}`,
'name': `${name}`, 'name': `${name}`,
'following': `https://${activityPub.host}/activitypub/users/${name}/following`, 'following': `${activityPub.endpoint}/activitypub/users/${name}/following`,
'followers': `https://${activityPub.host}/activitypub/users/${name}/followers`, 'followers': `${activityPub.endpoint}/activitypub/users/${name}/followers`,
'inbox': `https://${activityPub.host}/activitypub/users/${name}/inbox`, 'inbox': `${activityPub.endpoint}/activitypub/users/${name}/inbox`,
'outbox': `https://${activityPub.host}/activitypub/users/${name}/outbox`, 'outbox': `${activityPub.endpoint}/activitypub/users/${name}/outbox`,
'url': `https://${activityPub.host}/activitypub/@${name}`, 'url': `${activityPub.endpoint}/activitypub/@${name}`,
'endpoints': { 'endpoints': {
'sharedInbox': `https://${activityPub.host}/activitypub/inbox` 'sharedInbox': `${activityPub.endpoint}/activitypub/inbox`
}, },
'publicKey': { 'publicKey': {
'id': `https://${activityPub.host}/activitypub/users/${name}#main-key`, 'id': `${activityPub.endpoint}/activitypub/users/${name}#main-key`,
'owner': `https://${activityPub.host}/activitypub/users/${name}`, 'owner': `${activityPub.endpoint}/activitypub/users/${name}`,
'publicKeyPem': pubkey 'publicKeyPem': pubkey
} }
} }
} }
export function createWebFinger (name) { export function createWebFinger (name) {
const { host } = new URL(activityPub.endpoint)
return { return {
'subject': `acct:${name}@${activityPub.host}`, 'subject': `acct:${name}@${host}`,
'links': [ 'links': [
{ {
'rel': 'self', 'rel': 'self',
'type': 'application/activity+json', '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) { export function createOrderedCollection (name, collectionName) {
return { return {
'@context': 'https://www.w3.org/ns/activitystreams', '@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`, 'summary': `${name}s ${collectionName} collection`,
'type': 'OrderedCollection', 'type': 'OrderedCollection',
'first': `https://${activityPub.host}/activitypub/users/${name}/${collectionName}?page=true`, 'first': `${activityPub.endpoint}/activitypub/users/${name}/${collectionName}?page=true`,
'totalItems': 0 'totalItems': 0
} }
} }
@ -16,11 +16,11 @@ export function createOrderedCollection (name, collectionName) {
export function createOrderedCollectionPage (name, collectionName) { export function createOrderedCollectionPage (name, collectionName) {
return { return {
'@context': 'https://www.w3.org/ns/activitystreams', '@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`, 'summary': `${name}s ${collectionName} collection`,
'type': 'OrderedCollectionPage', 'type': 'OrderedCollectionPage',
'totalItems': 0, 'totalItems': 0,
'partOf': `https://${activityPub.host}/activitypub/users/${name}/${collectionName}`, 'partOf': `${activityPub.endpoint}/activitypub/users/${name}/${collectionName}`,
'orderedItems': [] 'orderedItems': []
} }
} }

View File

@ -20,8 +20,8 @@ export function extractIdFromActivityId (uri) {
return splitted[splitted.indexOf('status') + 1] return splitted[splitted.indexOf('status') + 1]
} }
export function constructIdFromName (name, fromDomain = activityPub.host) { export function constructIdFromName (name, fromDomain = activityPub.endpoint) {
return `http://${fromDomain}/activitypub/users/${name}` return `${fromDomain}/activitypub/users/${name}`
} }
export function extractDomainFromUrl (url) { export function extractDomainFromUrl (url) {
@ -76,7 +76,7 @@ export function signAndSend (activity, fromName, targetDomain, url) {
'Host': targetDomain, 'Host': targetDomain,
'Date': date, 'Date': date,
'Signature': createSignature({ privateKey, 'Signature': createSignature({ privateKey,
keyId: `http://${activityPub.host}/activitypub/users/${fromName}#main-key`, keyId: `${activityPub.endpoint}/activitypub/users/${fromName}#main-key`,
url, url,
headers: { headers: {
'Host': targetDomain, '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 { export default {
Mutation: { Mutation: {
CreateUser: async (resolve, root, args, context, info) => { CreateUser: setCreatedAt,
args.createdAt = (new Date()).toISOString() CreatePost: setCreatedAt,
const result = await resolve(root, args, context, info) CreateComment: setCreatedAt,
return result CreateOrganization: setCreatedAt,
}, UpdateUser: setUpdatedAt,
CreatePost: async (resolve, root, args, context, info) => { UpdatePost: setUpdatedAt,
args.createdAt = (new Date()).toISOString() UpdateComment: setUpdatedAt,
const result = await resolve(root, args, context, info) UpdateOrganization: setUpdatedAt
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
}
} }
} }

View File

@ -24,6 +24,6 @@ const includeFieldsRecursively = (includedFields) => {
} }
export default { export default {
Query: includeFieldsRecursively(['id', 'disabled', 'deleted']), Query: includeFieldsRecursively(['id', 'createdAt', 'disabled', 'deleted']),
Mutation: includeFieldsRecursively(['id', 'disabled', 'deleted']) Mutation: includeFieldsRecursively(['id', 'createdAt', 'disabled', 'deleted'])
} }

View File

@ -9,6 +9,7 @@ import xssMiddleware from './xssMiddleware'
import permissionsMiddleware from './permissionsMiddleware' import permissionsMiddleware from './permissionsMiddleware'
import userMiddleware from './userMiddleware' import userMiddleware from './userMiddleware'
import includedFieldsMiddleware from './includedFieldsMiddleware' import includedFieldsMiddleware from './includedFieldsMiddleware'
import orderByMiddleware from './orderByMiddleware'
export default schema => { export default schema => {
let middleware = [ let middleware = [
@ -20,14 +21,17 @@ export default schema => {
fixImageUrlsMiddleware, fixImageUrlsMiddleware,
softDeleteMiddleware, softDeleteMiddleware,
userMiddleware, userMiddleware,
includedFieldsMiddleware includedFieldsMiddleware,
orderByMiddleware
] ]
// add permisions middleware at the first position (unless we're seeding) // add permisions middleware at the first position (unless we're seeding)
// NOTE: DO NOT SET THE PERMISSION FLAT YOUR SELF // NOTE: DO NOT SET THE PERMISSION FLAT YOUR SELF
if (process.env.PERMISSIONS !== 'disabled' && process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
middleware.unshift(activityPubMiddleware) const DISABLED_MIDDLEWARES = process.env.DISABLED_MIDDLEWARES || ''
middleware.unshift(permissionsMiddleware.generate(schema)) const disabled = DISABLED_MIDDLEWARES.split(',')
if (!disabled.includes('activityPub')) middleware.unshift(activityPubMiddleware)
if (!disabled.includes('permissions')) middleware.unshift(permissionsMiddleware.generate(schema))
} }
return middleware 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) { export default function (params) {
const { const {
id = uuid(), id = uuid(),
slug = '',
title = faker.lorem.sentence(), title = faker.lorem.sentence(),
content = [ content = [
faker.lorem.sentence(), faker.lorem.sentence(),
@ -21,6 +22,7 @@ export default function (params) {
mutation { mutation {
CreatePost( CreatePost(
id: "${id}", id: "${id}",
slug: "${slug}",
title: "${title}", title: "${title}",
content: "${content}", content: "${content}",
image: "${image}", image: "${image}",

View File

@ -5,6 +5,7 @@ export default function create (params) {
const { const {
id = uuid(), id = uuid(),
name = faker.name.findName(), name = faker.name.findName(),
slug = '',
email = faker.internet.email(), email = faker.internet.email(),
password = '1234', password = '1234',
role = 'user', role = 'user',
@ -19,6 +20,7 @@ export default function create (params) {
CreateUser( CreateUser(
id: "${id}", id: "${id}",
name: "${name}", name: "${name}",
slug: "${slug}",
password: "${password}", password: "${password}",
email: "${email}", email: "${email}",
avatar: "${avatar}", avatar: "${avatar}",
@ -30,6 +32,7 @@ export default function create (params) {
) { ) {
id id
name name
slug
email email
avatar avatar
role role

View File

@ -29,7 +29,7 @@ Feature: Delete an object
""" """
{ {
"@context": "https://www.w3.org/ns/activitystreams", "@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", "type": "Delete",
"object": { "object": {
"id": "https://aronda.org/activitypub/users/bernd-das-brot/status/kljsdfg9843jknsdf234", "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", "@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", "type": "Follow",
"actor": "http://localhost:4123/activitypub/users/stuart-little", "actor": "http://localhost:4123/activitypub/users/stuart-little",
"object": "http://localhost:4123/activitypub/users/tero-vota" "object": "http://localhost:4123/activitypub/users/tero-vota"
@ -32,11 +32,11 @@ Feature: Follow a user
""" """
{ {
"@context": "https://www.w3.org/ns/activitystreams", "@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", "type": "Undo",
"actor": "http://localhost:4123/activitypub/users/tero-vota", "actor": "http://localhost:4123/activitypub/users/tero-vota",
"object": { "object": {
"id": "https://localhost:4123/activitypub/users/stuart-little/status/83J23549sda1k72fsa4567na42312455kad83", "id": "http://localhost:4123/activitypub/users/stuart-little/status/83J23549sda1k72fsa4567na42312455kad83",
"type": "Follow", "type": "Follow",
"actor": "http://localhost:4123/activitypub/users/stuart-little", "actor": "http://localhost:4123/activitypub/users/stuart-little",
"object": "http://localhost:4123/activitypub/users/tero-vota" "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", "@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", "type": "Create",
"actor": "https://localhost:4123/activitypub/users/karl-heinz", "actor": "http://localhost:4123/activitypub/users/karl-heinz",
"object": { "object": {
"id": "https://localhost:4123/activitypub/users/karl-heinz/status/dkasfljsdfaafg9843jknsdf", "id": "http://localhost:4123/activitypub/users/karl-heinz/status/dkasfljsdfaafg9843jknsdf",
"type": "Article", "type": "Article",
"published": "2019-02-07T19:37:55.002Z", "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?", "content": "Hi Max, how are you?",
"to": "https://www.w3.org/ns/activitystreams#Public" "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", "@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", "type": "Like",
"actor": "http://localhost:4123/activitypub/users/peter-lustiger", "actor": "http://localhost:4123/activitypub/users/peter-lustiger",
"object": "http://localhost:4123/activitypub/users/karl-heinz/status/dkasfljsdfaafg9843jknsdf" "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", "@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", "summary": "renate-oberdorfers outbox collection",
"type": "OrderedCollection", "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 "totalItems": 0
} }
""" """
@ -29,10 +29,10 @@ Feature: Receiving collections
""" """
{ {
"@context": "https://www.w3.org/ns/activitystreams", "@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", "summary": "renate-oberdorfers following collection",
"type": "OrderedCollection", "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 "totalItems": 0
} }
""" """
@ -44,10 +44,10 @@ Feature: Receiving collections
""" """
{ {
"@context": "https://www.w3.org/ns/activitystreams", "@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", "summary": "renate-oberdorfers followers collection",
"type": "OrderedCollection", "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 "totalItems": 0
} }
""" """
@ -59,11 +59,11 @@ Feature: Receiving collections
""" """
{ {
"@context": "https://www.w3.org/ns/activitystreams", "@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", "summary": "renate-oberdorfers outbox collection",
"type": "OrderedCollectionPage", "type": "OrderedCollectionPage",
"totalItems": 0, "totalItems": 0,
"partOf": "https://localhost:4123/activitypub/users/renate-oberdorfer/outbox", "partOf": "http://localhost:4123/activitypub/users/renate-oberdorfer/outbox",
"orderedItems": [] "orderedItems": []
} }
""" """
@ -75,11 +75,11 @@ Feature: Receiving collections
""" """
{ {
"@context": "https://www.w3.org/ns/activitystreams", "@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", "summary": "renate-oberdorfers following collection",
"type": "OrderedCollectionPage", "type": "OrderedCollectionPage",
"totalItems": 0, "totalItems": 0,
"partOf": "https://localhost:4123/activitypub/users/renate-oberdorfer/following", "partOf": "http://localhost:4123/activitypub/users/renate-oberdorfer/following",
"orderedItems": [] "orderedItems": []
} }
""" """
@ -91,11 +91,11 @@ Feature: Receiving collections
""" """
{ {
"@context": "https://www.w3.org/ns/activitystreams", "@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", "summary": "renate-oberdorfers followers collection",
"type": "OrderedCollectionPage", "type": "OrderedCollectionPage",
"totalItems": 0, "totalItems": 0,
"partOf": "https://localhost:4123/activitypub/users/renate-oberdorfer/followers", "partOf": "http://localhost:4123/activitypub/users/renate-oberdorfer/followers",
"orderedItems": [] "orderedItems": []
} }
""" """

View File

@ -4,7 +4,7 @@ Feature: Webfinger discovery
In order to follow the actor In order to follow the actor
Background: 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: And we have the following users in our database:
| Slug | | Slug |
| peter-lustiger | | peter-lustiger |
@ -19,7 +19,7 @@ Feature: Webfinger discovery
{ {
"rel": "self", "rel": "self",
"type": "application/activity+json", "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://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1" "https://w3id.org/security/v1"
], ],
"id": "https://localhost:4123/activitypub/users/peter-lustiger", "id": "http://localhost:4123/activitypub/users/peter-lustiger",
"type": "Person", "type": "Person",
"preferredUsername": "peter-lustiger", "preferredUsername": "peter-lustiger",
"name": "peter-lustiger", "name": "peter-lustiger",
"following": "https://localhost:4123/activitypub/users/peter-lustiger/following", "following": "http://localhost:4123/activitypub/users/peter-lustiger/following",
"followers": "https://localhost:4123/activitypub/users/peter-lustiger/followers", "followers": "http://localhost:4123/activitypub/users/peter-lustiger/followers",
"inbox": "https://localhost:4123/activitypub/users/peter-lustiger/inbox", "inbox": "http://localhost:4123/activitypub/users/peter-lustiger/inbox",
"outbox": "https://localhost:4123/activitypub/users/peter-lustiger/outbox", "outbox": "http://localhost:4123/activitypub/users/peter-lustiger/outbox",
"url": "https://localhost:4123/activitypub/@peter-lustiger", "url": "http://localhost:4123/activitypub/@peter-lustiger",
"endpoints": { "endpoints": {
"sharedInbox": "https://localhost:4123/activitypub/inbox" "sharedInbox": "http://localhost:4123/activitypub/inbox"
}, },
"publicKey": { "publicKey": {
"id": "https://localhost:4123/activitypub/users/peter-lustiger#main-key", "id": "http://localhost:4123/activitypub/users/peter-lustiger#main-key",
"owner": "https://localhost:4123/activitypub/users/peter-lustiger", "owner": "http://localhost:4123/activitypub/users/peter-lustiger",
"publicKeyPem": "adglkjlk89235kjn8obn2384f89z5bv9..." "publicKeyPem": "adglkjlk89235kjn8obn2384f89z5bv9..."
} }
} }

View File

@ -8,7 +8,7 @@ Feature: Search
And we have the following posts in our database: And we have the following posts in our database:
| Author | id | title | content | | Author | id | title | content |
| Brianna Wiest | p1 | 101 Essays that will change the way you think | 101 Essays, of course! | | 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 Given I am logged in
Scenario: Search for specific words Scenario: Search for specific words

View File

@ -17,7 +17,7 @@ Feature: Create a post
for active citizenship. for active citizenship.
""" """
And I click on "Save" 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 And the post was saved successfully
Scenario: See a post on the landing page 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', () => { When('I report the author', () => {
cy.get('.page-name-profile-slug').then(() => { cy.get('.page-name-profile-id-slug').then(() => {
invokeReportOnElement('.ds-card').then(() => { invokeReportOnElement('.ds-card').then(() => {
cy.get('button') cy.get('button')
.contains('Send') .contains('Send')

View File

@ -42,9 +42,13 @@ When('I select an entry', () => {
}) })
Then("I should be on the post's page", () => { Then("I should be on the post's page", () => {
cy.location('pathname').should(
'contain',
'/post/'
)
cy.location('pathname').should( cy.location('pathname').should(
'eq', '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 log out', cy.logout)
When('I visit {string}', page => {
cy.openPage(page)
})
When('I visit the {string} page', page => { When('I visit the {string} page', page => {
cy.openPage(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 => { 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', () => { 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'] const result = res.data[this.id ? 'UpdatePost' : 'CreatePost']
this.$router.push({ this.$router.push({
name: 'post-slug', name: 'post-id-slug',
params: { slug: result.slug } params: { id: result.id, slug: result.slug }
}) })
}) })
.catch(err => { .catch(err => {

View File

@ -106,8 +106,8 @@ export default {
methods: { methods: {
href(post) { href(post) {
return this.$router.resolve({ return this.$router.resolve({
name: 'post-slug', name: 'post-id-slug',
params: { slug: post.slug } params: { id: post.id, slug: post.slug }
}).href }).href
} }
} }

View File

@ -153,9 +153,9 @@ export default {
return count return count
}, },
userLink() { userLink() {
const { slug } = this.user const { id, slug } = this.user
if (!slug) return '' if (!(id && slug)) return ''
return { name: 'profile-slug', params: { slug } } return { name: 'profile-id-slug', params: { slug, id } }
} }
} }
} }

View File

@ -9,58 +9,69 @@ export default app => {
type type
createdAt createdAt
submitter { submitter {
id
slug
name
disabled disabled
deleted deleted
name
slug
} }
user { user {
name id
slug slug
name
disabled disabled
deleted deleted
disabledBy { disabledBy {
id
slug slug
name name
disabled
deleted
} }
} }
comment { comment {
contentExcerpt contentExcerpt
author { author {
name id
slug slug
name
disabled disabled
deleted deleted
} }
post { post {
id
slug
title
disabled disabled
deleted deleted
title
slug
} }
disabledBy { disabledBy {
disabled id
deleted
slug slug
name name
disabled
deleted
} }
} }
post { post {
title id
slug slug
title
disabled disabled
deleted deleted
author { author {
id
slug
name
disabled disabled
deleted deleted
name
slug
} }
disabledBy { disabledBy {
disabled id
deleted
slug slug
name name
disabled
deleted
} }
} }
} }

View File

@ -6,6 +6,7 @@ export default app => {
query User($slug: String!, $first: Int, $offset: Int) { query User($slug: String!, $first: Int, $offset: Int) {
User(slug: $slug) { User(slug: $slug) {
id id
slug
name name
avatar avatar
about about
@ -27,8 +28,8 @@ export default app => {
followingCount followingCount
following(first: 7) { following(first: 7) {
id id
name
slug slug
name
avatar avatar
disabled disabled
deleted deleted
@ -49,10 +50,10 @@ export default app => {
followedByCurrentUser followedByCurrentUser
followedBy(first: 7) { followedBy(first: 7) {
id id
slug
name name
disabled disabled
deleted deleted
slug
avatar avatar
followedByCount followedByCount
followedByCurrentUser followedByCurrentUser
@ -87,6 +88,7 @@ export default app => {
} }
author { author {
id id
slug
avatar avatar
name name
disabled disabled

View File

@ -39,7 +39,7 @@
> >
<a <a
class="avatar-menu-trigger" 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" @click.prevent="toggleMenu"
> >
<ds-avatar <ds-avatar
@ -182,8 +182,8 @@ export default {
goToPost(item) { goToPost(item) {
this.$nextTick(() => { this.$nextTick(() => {
this.$router.push({ this.$router.push({
name: 'post-slug', name: 'post-id-slug',
params: { slug: item.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: { proxy: {
'/.well-known/webfinger': { '/.well-known/webfinger': {
target: process.env.GRAPHQL_URI || 'http://localhost:4000', 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) // make this configurable (nuxt-dotenv)
target: process.env.GRAPHQL_URI || 'http://localhost:4000', 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': { '/api': {
// make this configurable (nuxt-dotenv) // make this configurable (nuxt-dotenv)

View File

@ -64,8 +64,8 @@ export default {
}, },
href(post) { href(post) {
return this.$router.resolve({ return this.$router.resolve({
name: 'post-slug', name: 'post-id-slug',
params: { slug: post.slug } params: { id: post.id, slug: post.slug }
}).href }).href
}, },
showMoreContributions() { showMoreContributions() {

View File

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

View File

@ -17,35 +17,62 @@
</template> </template>
<script> <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 { export default {
mixins: [persistentLinks],
computed: { computed: {
routes() { routes() {
const { slug, id } = this.$route.params
return [ return [
{ {
name: this.$t('common.post', null, 1), name: this.$t('common.post', null, 1),
path: `/post/${this.$route.params.slug}`, path: `/post/${id}/${slug}`,
children: [ children: [
{ {
name: this.$t('common.comment', null, 2), name: this.$t('common.comment', null, 2),
path: `/post/${this.$route.params.slug}#comments` path: `/post/${id}/${slug}#comments`
}, },
{ {
name: this.$t('common.letsTalk'), name: this.$t('common.letsTalk'),
path: `/post/${this.$route.params.slug}#lets-talk` path: `/post/${id}/${slug}#lets-talk`
}, },
{ {
name: this.$t('common.versus'), name: this.$t('common.versus'),
path: `/post/${this.$route.params.slug}#versus` path: `/post/${id}/${slug}#versus`
} }
] ]
}, },
{ {
name: this.$t('common.moreInfo'), name: this.$t('common.moreInfo'),
path: `/post/${this.$route.params.slug}/more-info` path: `/post/${id}/${slug}/more-info`
}, },
{ {
name: this.$t('common.takeAction'), 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> </script>
<style lang="scss"> <style lang="scss">
.page-name-post-slug { .page-name-post-id-slug {
.content-menu { .content-menu {
float: right; float: right;
margin-right: -$space-x-small; 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; border: #fff 5px solid;
} }
.page-name-profile-slug { .page-name-profile-id-slug {
.ds-flex-item:first-child .content-menu { .ds-flex-item:first-child .content-menu {
position: absolute; position: absolute;
top: $space-x-small; 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>