feat: refactor location representation in AP

This commit is contained in:
lesion 2025-01-13 09:50:39 +01:00
commit e0325d5145
No known key found for this signature in database
GPG key ID: 352918250B012177
25 changed files with 387 additions and 57 deletions

View file

@ -20,7 +20,7 @@
<div class='p-location' itemprop="location" itemscope itemtype="https://schema.org/Place">
<nuxt-link class='place d-block pl-0' text
:to='`/place/${encodeURIComponent(event.place.name)}`'>
:to='`/place/${event.place.id}/${encodeURIComponent(event.place.name)}`'>
<v-icon v-text='mdiMapMarker'></v-icon>
<span itemprop='name'>{{ event.place.name }}</span>
</nuxt-link>

View file

@ -7,7 +7,7 @@ v-card
v-row.my-4.d-flex.flex-column.align-center.text-center
.text-h6
v-icon(v-text='mdiMapMarker' )
nuxt-link.ml-2.text-decoration-none(v-text="place.name" :to='`/place/${place.name}`')
nuxt-link.ml-2.text-decoration-none(v-text="place.name" :to='`/place/${place.id}/${encodeURIComponent(place.name)}`')
.mx-2(v-text="`${place.address}`")
v-card-actions.py-4
HowToArriveNav.pl-1(:place='place')

View file

@ -84,7 +84,7 @@ v-container
template(v-slot:item.actions='{ item }')
v-btn(@click='editPlace(item)' color='primary' icon)
v-icon(v-text='mdiPencil')
nuxt-link(:to='`/place/${item.name}`')
nuxt-link(:to='`/place/${item.id}/${encodeURIComponent(item.name)}`')
v-icon(v-text='mdiEye')
</template>

View file

@ -24,7 +24,7 @@
.p-location.h-adr(itemprop="location" itemscope itemtype="https://schema.org/Place")
v-icon(v-text='mdiMapMarker' small)
nuxt-link.vcard.ml-2.p-name.text-decoration-none.text-uppercase(:to='`/place/${encodeURIComponent(event?.place?.name)}`')
nuxt-link.vcard.ml-2.p-name.text-decoration-none.text-uppercase(:to='`/place/${event?.place?.id}/${encodeURIComponent(event?.place?.name)}`')
span(itemprop='name') {{event?.place?.name}}
.font-weight-light.p-street-address(v-if='event?.place?.name !=="online"' itemprop='address') {{event?.place?.address}}

View file

@ -0,0 +1,65 @@
<template>
<v-container id='home' class='px-2 px-sm-6 pt-0'>
<h1 class='d-block text-h4 font-weight-black text-center text-uppercase mt-10 mx-auto w-100 text-underline'>
<u>{{ place.name }}</u>
</h1>
<span v-if='place.name!=="online"' class="d-block text-subtitle text-center w-100">{{ place.address }}</span>
<!-- Map -->
<div v-if='settings.allow_geolocation && place.latitude && place.longitude' >
<div class="mt-4 mx-auto px-4" >
<Map :place='place' :height='mapHeight' />
</div>
<div class="mt-4">
<HowToArriveNav :place='place' class="justify-center" />
</div>
</div>
<!-- Events -->
<div id="events" class='mt-14'>
<v-lazy class='event v-card' :value='idx<9' v-for='(event, idx) in events' :key='event.id' :min-height='hide_thumbs ? 105 : undefined' :options="{ threshold: .5, rootMargin: '500px' }" :class="{ 'theme--dark': is_dark }">
<Event :event='event' :lazy='idx > 9' />
</v-lazy>
</div>
</v-container>
</template>
<script>
import { mapState, mapGetters } from 'vuex'
import Event from '@/components/Event'
import HowToArriveNav from '@/components/HowToArriveNav.vue'
export default {
name: 'Place',
components: {
Event,
HowToArriveNav,
[process.client && 'Map']: () => import('@/components/Map.vue')
},
data() {
return { mapHeight: "14rem" }
},
head() {
const title = `${this.settings.title} - ${this.place.name}`
return {
title,
link: [
{ rel: 'alternate', type: 'application/rss+xml', title, href: this.settings.baseurl + `/feed/rss/place/${this.place.id}` },
{ rel: 'alternate', type: 'text/calendar', title, href: this.settings.baseurl + `/feed/ics/place/${this.place.id}` }
],
}
},
computed: {
...mapState(['settings']),
...mapGetters(['hide_thumbs', 'is_dark']),
},
async asyncData({ $axios, params, error }) {
try {
const { events, place } = await $axios.$get(`/place/${params.id}`)
return { place, events }
} catch (e) {
error({ statusCode: 404, message: 'Place not found!' })
}
}
}
</script>

View file

@ -44,8 +44,8 @@ export default {
return {
title,
link: [
{ rel: 'alternate', type: 'application/rss+xml', title, href: this.settings.baseurl + `/feed/rss/place/${this.place.name}` },
{ rel: 'alternate', type: 'text/calendar', title, href: this.settings.baseurl + `/feed/ics/place/${this.place.name}` }
{ rel: 'alternate', type: 'application/rss+xml', title, href: this.settings.baseurl + `/feed/rss/place/${this.place.id}` },
{ rel: 'alternate', type: 'text/calendar', title, href: this.settings.baseurl + `/feed/ics/place/${this.place.id}` }
],
}
},

View file

@ -23,7 +23,7 @@ const collectionController = require('./collection')
const eventController = {
async _findOrCreatePlace (body) {
if (body.place_id) {
if (body?.place_id) {
const place = await Place.findByPk(body.place_id)
if (!place) {
throw new Error(`Place not found`)
@ -31,18 +31,25 @@ const eventController = {
return place
}
if (body?.place_ap_id) {
const place = await Place.findOne({ where: { ap_id: body.place_ap_id } })
if (place) {
return place
}
}
const place_name = body.place_name && body.place_name.trim()
const place_address = body.place_address && body.place_address.trim()
if (!place_name || !place_address && place_name?.toLocaleLowerCase() !== 'online') {
throw new Error(`place_id or place_name and place_address are required`)
throw new Error(`place_id or place_name and place_address are required: ${JSON.stringify(body)}`)
}
let place = await Place.findOne({ where: Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('name')), Sequelize.Op.eq, place_name.toLocaleLowerCase()) })
if (!place) {
place = await Place.create({
name: place_name,
address: place_address || '',
latitude: Number(body.place_latitude) || null,
longitude: Number(body.place_longitude) || null
...( body.place_ap_id && { ap_id: body.place_ap_id }),
...( body.place_latitude && body.place_longitude && ({ latitude: Number(body.place_latitude), longitude: Number(body.place_longitude) }))
}).catch(e => {
console.error(e)
console.error(e?.errors)

View file

@ -9,22 +9,22 @@ const { Op, where, col, fn, cast } = require('sequelize')
module.exports = {
async getEvents (req, res) {
const placeName = req.params.placeName
const place = await Place.findOne({ where: { name: placeName }})
const placeNameOrId = req.params.placeNameOrId
const place = await Place.findOne({ where: { [Op.or]: { id: placeNameOrId, name: placeNameOrId }}})
if (!place) {
log.warn(`Place ${placeName} not found`)
log.warn(`Place ${placeNameOrId} not found`)
return res.sendStatus(404)
}
const format = req.params.format || 'json'
log.debug(`Events for place: ${placeName}`)
log.debug(`Events for place: ${place.name}`)
const events = await eventController._select({ places: String(place.id), show_recurrent: true, show_multidate: true })
switch (format) {
case 'rss':
return exportController.feed(req, res, events,
`${res.locals.settings.title} - Place @${place.name}`,
`${res.locals.settings.baseurl}/feed/rss/place/${place.name}`)
`${res.locals.settings.baseurl}/feed/rss/place/${place.id}`)
case 'ics':
return exportController.ics(req, res, events)
default:

View file

@ -190,7 +190,7 @@ module.exports = () => {
// - PLACES
api.get('/places', isAdmin, placeController.getAll)
api.get('/place/:placeName', cors, placeController.getEvents)
api.get('/place/:placeNameOrId', cors, placeController.getEvents)
api.get('/place', cors, placeController.search)
api.put('/place', isAdmin, placeController.updatePlace)

View file

@ -52,8 +52,13 @@ module.exports = (sequelize, DataTypes) => {
const summary = `${this.place && this.place.name}, ${datetime}`
let attachment = []
let location = []
if (this?.online_locations?.length) {
location = this.online_locations.map(url => ({
type: 'VirtualLocation',
url
}))
attachment = this.online_locations.map( href => ({
type: 'Link',
mediaType: 'text/html',
@ -61,6 +66,15 @@ module.exports = (sequelize, DataTypes) => {
href
}))
}
if (this.place.name !== 'online') {
location.push(this.place.toAP())
}
// in case we only have a single location (the common case) do not use an array to simplify federation parser
if (location.length === 1) {
location = location[0]
}
if (this?.media?.length) {
attachment.push({
@ -92,13 +106,7 @@ module.exports = (sequelize, DataTypes) => {
type: 'Event',
startTime: DateTime.fromSeconds(this.start_datetime, opt).toISO(),
...( this.end_datetime ? { endTime : DateTime.fromSeconds(this.end_datetime, opt).toISO() } : {} ),
location: {
type: 'Place',
name: this.place.name,
address: this.place.address,
latitude: this.place.latitude,
longitude: this.place.longitude
},
location,
attachment,
tag: tags,
published: this.createdAt,

View file

@ -1,12 +1,36 @@
module.exports = (sequelize, DataTypes) =>
sequelize.define('place', {
name: {
type: DataTypes.STRING,
unique: true,
index: true,
allowNull: false
},
address: DataTypes.STRING,
latitude: DataTypes.FLOAT,
longitude: DataTypes.FLOAT,
})
const config = require('../../config')
module.exports = (sequelize, DataTypes) => {
const Place = sequelize.define('place', {
name: {
type: DataTypes.STRING,
index: true,
allowNull: false
},
ap_id: {
type: DataTypes.STRING,
index: true
},
address: DataTypes.STRING,
latitude: DataTypes.FLOAT,
longitude: DataTypes.FLOAT,
})
/**
* @returns ActivityStream location representation
* @link https://www.w3.org/TR/activitystreams-vocabulary/#places
* @todo support PostalAddress type
* @link WIP -> https://codeberg.org/fediverse/fep/src/commit/4a75a1bc50bc6d19fc1e6112f02c52621bc178fe/fep/8a8e/fep-8a8e.md#location
*/
Place.prototype.toAP = function () {
return {
id: this?.ap_id ?? `${config.baseurl}/federation/p/${this.id}`,
type: 'Place',
name: this.name,
address: this.address,
...( this.latitude && this.longitude && ({ latitude: this.latitude, longitude: this.longitude}))
}
}
return Place
}

View file

@ -175,38 +175,72 @@ const Helpers = {
}
},
async parsePlace (APEvent) {
/**
* Parses the location of an ActivityPub Event to extract physical and online locations.
* @link https://www.w3.org/TR/activitystreams-vocabulary/#places
* @link https://codeberg.org/fediverse/fep/src/commit/4a75a1bc50bc6d19fc1e6112f02c52621bc178fe/fep/8a8e/fep-8a8e.md#location
* @param {Object} APEvent - The ActivityPub Event object
* @returns {Array} An array containing the Place and a list of online locations
*/
async parsePlace(APEvent) {
const eventController = require('../api/controller/event')
let place
if (APEvent?.location) {
let place = null
if (!APEvent?.location) {
log.warn(`[FEDI] Event "${APEvent?.name}" has no location field`)
return [null, null]
}
const locations = Array.isArray(APEvent.location) ? APEvent.location : [APEvent.location]
// find the first physical place from locations
let APPlace = locations.find(location => location.address && !location?.address?.url) || locations.find(location => !location.address?.url)
// get the list of online locations
let onlineLocations = locations.filter(location => location?.address?.url).map(location => location.address.url)
// we have a physical place
if (APPlace) {
place = {
place_name: APEvent.location?.name,
place_address: APEvent.location?.address?.streetAddress ?? APEvent.location?.address?.addressLocality ?? APEvent.location?.address?.addressCountry ?? APEvent.location?.address ?? '',
place_latitude: APEvent.location?.latitude,
place_longitude: APEvent.location?.longitude
place_name: APPlace?.name,
...(APPlace?.id && { place_ap_id: APPlace.id }),
...(APPlace?.latitude && APPlace?.longitude && { place_latitude: APPlace.latitude, place_longitude: APPlace.longitude }),
}
// no physical but at least virtual location
} else if (onlineLocations.length) {
place = {
place_name: 'online'
}
// nothing...
} else {
log.warn(`[FEDI] No Physical nor Virtual location: ${JSON.stringify(APEvent.location)}`)
return [null, null]
}
// could have online locations too
let online_locations = []
if (APEvent?.attachment?.length) {
online_locations = APEvent.attachment.filter(a => a?.type === 'Link' && a?.href).map(a => a.href)
}
if (!place) {
if (online_locations) {
place = { place_name: 'online' }
// the `address` field could be Text, PostalAddress or VirtualLocation, we do support the name as a fallback
const addr = APPlace?.address
if (addr) {
if (typeof addr === 'string') {
place.place_address = addr
} else if ( addr?.streetAddress || addr?.addressLocality || addr?.addressCountry || addr?.addressRegion ) {
place.place_address = [ addr?.streetAddress, addr?.addressLocality, addr?.addressRegion, addr?.addressCountry].filter(part => part).join(', ')
} else if (addr?.url) {
place.place_name = 'online'
} else {
throw new Error ('No location nor online location')
console.warn(`[FEDI] Event "${APEvent?.name}" has bad address location: ${JSON.stringify(APPlace?.address)}`)
}
} else {
place.place_address = place.place_name
}
place = await eventController._findOrCreatePlace(place)
if (!place) {
throw new Error('Place not found nor created')
throw new Error('Place not found nor created')
}
return [place, online_locations]
return [place, onlineLocations]
},
/**
@ -233,7 +267,7 @@ const Helpers = {
const APEvent = message.object
// validate coming events
// validate incoming events
const required_fields = ['name', 'startTime', 'id']
let missing_field = required_fields.find(required_field => !APEvent[required_field])
if (missing_field) {
@ -287,7 +321,9 @@ const Helpers = {
return false
})
await event.setPlace(place)
if (place) {
await event.setPlace(place)
}
// create/assign tags
let tags = []

View file

@ -2,6 +2,7 @@ const express = require('express')
const router = express.Router()
// const cors = require('cors')
const Users = require('./users')
const Places = require('./places')
const { Event, User, Tag, Place } = require('../api/models/models')
const settingsController = require('../api/controller/settings')
@ -75,6 +76,8 @@ router.get('/u/:name/outbox', Users.outbox)
router.get('/u/:name', Users.get)
router.get('/p/:id', Places.get)
// Handle 404
router.use((req, res) => {
log.warn(`[FEDI] 404 Page not found: ${req.path}`)

View file

@ -0,0 +1,21 @@
const { Place } = require('../api/models/models')
const log = require('../log')
module.exports = {
async get (req, res) {
log.debug('[FEDI] Get location')
const id = req.params.id
if (!id) { return res.status(400).send('Bad request.') }
const place = await Place.findByPk(id)
if (!place) {
log.warn(`[FEDI] Place ${id} not found`)
return res.status(404).send('Not found.')
}
const ret = place.toAP()
ret['@context'] = ['https://www.w3.org/ns/activitystreams']
res.type('application/activity+json; charset=utf-8')
res.json(ret)
},
}

View file

@ -0,0 +1,12 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up (queryInterface, Sequelize) {
return queryInterface.addColumn('places', 'ap_id', { type: Sequelize.STRING, index: true })
},
async down (queryInterface, Sequelize) {
return queryInterface.removeColumn('places', 'ap_id')
}
};

View file

@ -0,0 +1,23 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up (queryInterface, Sequelize) {
// needed as in sequelize there is no support for alter table and sequelize simulate this by creating a backup table and dropping the old one:
// this will cause a foreign key error
const dialect = queryInterface.sequelize.getDialect()
if (dialect === 'sqlite') {
await queryInterface.sequelize.query('PRAGMA foreign_keys = OFF')
}
await queryInterface.changeColumn('places', 'name', { type: Sequelize.STRING, index: true, allowNull: false, unique: false })
},
async down (queryInterface, Sequelize) {
const dialect = queryInterface.sequelize.getDialect()
if (dialect === 'sqlite') {
await queryInterface.sequelize.query('PRAGMA foreign_keys = OFF')
}
await queryInterface.changeColumn('places', 'name', { type: Sequelize.STRING, index: true, allowNull: false, unique: true })
}
};

View file

@ -42,7 +42,7 @@ async function main () {
// rss / ics feed
app.use(helpers.feedRedirect)
app.get('/feed/:format/tag/:tag', cors(), tagController.getEvents)
app.get('/feed/:format/place/:placeName', cors(), placeController.getEvents)
app.get('/feed/:format/place/:placeNameOrId', cors(), placeController.getEvents)
app.get('/feed/:format/collection/:name', cors(), collectionController.getEvents)
app.get('/feed/:format', cors(), exportController.export)

View file

@ -102,6 +102,66 @@ describe('AP', () => {
expect(response.body.type).toBe('Application')
})
describe('Location', () => {
test('should fail parsing a location without a name', async () => {
const { parsePlace } = require('../server/federation/helpers.js')
const location = require('./fixtures/AP-location/bad-location-without-a-name.json')
expect(parsePlace({ location })).rejects.toThrow()
})
test ('should parse a location with only a name', async () => {
const { parsePlace } = require('../server/federation/helpers.js')
const location = require('./fixtures/AP-location/physical-location-without-id.json')
let [place] = await parsePlace({ location })
expect(place.name).toBe('Location without id')
expect(place.ap_id).toBeUndefined()
expect(place.id).toBe(1)
})
test ('should parse a location with id and name', async () => {
const { parsePlace } = require('../server/federation/helpers.js')
const location = require('./fixtures/AP-location/physical-location-no-address.json')
let [place] = await parsePlace({ location })
expect(place.name).toBe('Location with a name')
expect(place.address).toBe('Location with a name')
expect(place.id).toBe(2)
})
test ('should parse a location with a simple string address', async () => {
const { parsePlace } = require('../server/federation/helpers.js')
const location = require('./fixtures/AP-location/physical-location-with-simple-string-address.json')
let [place] = await parsePlace({ location })
expect(place.name).toBe('Location with a simple string address')
})
test ('should parse a location with a postal address', async () => {
const { parsePlace } = require('../server/federation/helpers.js')
const location = require('./fixtures/AP-location/physical-location-with-postal-address.json')
let [place] = await parsePlace({ location })
expect(place.name).toBe('Location with a postal address')
})
test ('should parse a virtual location', async () => {
const { parsePlace } = require('../server/federation/helpers.js')
const location = require('./fixtures/AP-location/virtual-location.json')
let [place, online_locations] = await parsePlace({ location })
expect(place.name).toBe('online')
expect(online_locations.length).toBe(1)
expect(online_locations[0]).toBe("https://virtual.location.org")
})
test ('should parse a mixed location', async () => {
const { parsePlace } = require('../server/federation/helpers.js')
const location = require('./fixtures/AP-location/multiple-mixed-locations.json')
let [place, online_locations] = await parsePlace({ location })
expect(place.name).toBe('Location with a name')
expect(online_locations.length).toBe(2)
expect(online_locations[0]).toBe('https://virtual.location.org')
})
})
// test('should not allow to create a new Event from a random instance', async () => {
// const response = await request(app)
// .post('/federation/u/relay/inbox')

View file

@ -0,0 +1,4 @@
{
"id" : "http://localhost:13120/federation/p/1",
"type" : "Place"
}

View file

@ -0,0 +1,30 @@
[
{
"id" : "http://localhost:13120/federation/p/4",
"name" : "Location with a name",
"type" : "Place"
},
{
"id" : "http://localhost:13120/federation/p/5",
"name" : "A second location with a name",
"type" : "Place"
},
{
"id" : "https://virtual.location.org",
"name" : "A Virtual location",
"address": {
"type": "VirtualLocation",
"url": "https://virtual.location.org"
},
"type" : "Place"
},
{
"id" : "https://a.second.fallback.virtual.location.org",
"name" : "A Fallback Virtual location",
"address": {
"type": "VirtualLocation",
"url": "https://a.second.fallback.virtual.location.org"
},
"type" : "Place"
}
]

View file

@ -0,0 +1,5 @@
{
"id" : "http://localhost:13120/federation/p/1",
"name" : "Location with a name",
"type" : "Place"
}

View file

@ -0,0 +1,13 @@
{
"id" : "http://localhost:13120/federation/p/2",
"name" : "Location with a postal address",
"address": {
"type": "PostalAddress",
"addressCountry": "Austria",
"addressLocality": "Fediverse Town",
"addressRegion": "Steiermark",
"postalCode": "8010",
"streetAddress": "15 Fediverse Street"
},
"type" : "Place"
}

View file

@ -0,0 +1,6 @@
{
"id" : "http://localhost:13120/federation/p/3",
"name" : "Location with a simple string address",
"address": "Simple string address",
"type" : "Place"
}

View file

@ -0,0 +1,4 @@
{
"name" : "Location without id",
"type" : "Place"
}

View file

@ -0,0 +1,9 @@
{
"id" : "https://virtual.location.org",
"name" : "A Virtual location",
"address": {
"type": "VirtualLocation",
"url": "https://virtual.location.org"
},
"type" : "Place"
}