From bdae46a304242cdbceb375a0cbeea43159d03a21 Mon Sep 17 00:00:00 2001 From: lesion Date: Mon, 23 Dec 2024 17:06:16 +0100 Subject: [PATCH 01/15] add slug place property --- server/api/models/index.js | 1 + server/api/models/place.js | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/server/api/models/index.js b/server/api/models/index.js index ce748ff7..3e8e04be 100644 --- a/server/api/models/index.js +++ b/server/api/models/index.js @@ -82,6 +82,7 @@ const db = { Event.belongsTo(Event, { as: 'parent' }) SequelizeSlugify.slugifyModel(Event, { source: ['title'], overwrite: false }) + SequelizeSlugify.slugifyModel(Place, { source: ['name'], overwrite: false }) }, close() { diff --git a/server/api/models/place.js b/server/api/models/place.js index 734824d5..547bc64a 100644 --- a/server/api/models/place.js +++ b/server/api/models/place.js @@ -6,6 +6,11 @@ module.exports = (sequelize, DataTypes) => index: true, allowNull: false }, + slug: { + type: DataTypes.STRING, + index: true, + unique: true + }, address: DataTypes.STRING, latitude: DataTypes.FLOAT, longitude: DataTypes.FLOAT, From f705b6afccd4420fbfdb96cb61e6fe4c366ceb60 Mon Sep 17 00:00:00 2001 From: lesion Date: Mon, 23 Dec 2024 17:07:17 +0100 Subject: [PATCH 02/15] add slug place migration --- .../20241223144951-slugifyPlaceName.js | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 server/migrations/20241223144951-slugifyPlaceName.js diff --git a/server/migrations/20241223144951-slugifyPlaceName.js b/server/migrations/20241223144951-slugifyPlaceName.js new file mode 100644 index 00000000..9afd55c2 --- /dev/null +++ b/server/migrations/20241223144951-slugifyPlaceName.js @@ -0,0 +1,39 @@ +const SequelizeSlugify = require('sequelize-slugify') +let Place = require('../api/models/place') + +module.exports = { + up: async (queryInterface, Sequelize) => { + const transaction = await queryInterface.sequelize.transaction() + try { + await queryInterface.addColumn('places', 'slug', { type: Sequelize.STRING }) + const place = Place(queryInterface.sequelize, Sequelize.DataTypes) + await SequelizeSlugify.slugifyModel(place, { source: ['name'], overwrite: false }) + const places = await place.findAll({transaction }) + for(const place of places) { + await place.regenerateSlug(transaction) + await place.save() + } + await queryInterface.changeColumn('places', 'slug', { + type: Sequelize.STRING, + index: true, + unique: true + }) + await transaction.commit() + } catch (e) { + console.error(e) + if (transaction) { + await transaction.rollback() + } + return Promise.reject(e) + } + }, + + down: (queryInterface, Sequelize) => { + /** + * Add reverting commands here. + * + * Example: + * await queryInterface.dropTable('users'); + */ + } +}; From 4a6009eaab4d451b5173dfda379a562662a44760 Mon Sep 17 00:00:00 2001 From: lesion Date: Mon, 23 Dec 2024 17:07:40 +0100 Subject: [PATCH 03/15] add id to place in AP representation --- server/api/models/event.js | 1 + 1 file changed, 1 insertion(+) diff --git a/server/api/models/event.js b/server/api/models/event.js index b974c4aa..7433d919 100644 --- a/server/api/models/event.js +++ b/server/api/models/event.js @@ -93,6 +93,7 @@ module.exports = (sequelize, DataTypes) => { startTime: DateTime.fromSeconds(this.start_datetime, opt).toISO(), ...( this.end_datetime ? { endTime : DateTime.fromSeconds(this.end_datetime, opt).toISO() } : {} ), location: { + id: `${config.baseurl}/federation/p/${this.place.slug}`, type: 'Place', name: this.place.name, address: this.place.address, From 2de042daaa0338e6866662e48b4b9bfbd8c130ec Mon Sep 17 00:00:00 2001 From: lesion Date: Mon, 23 Dec 2024 22:45:43 +0100 Subject: [PATCH 04/15] use internal place id in AP place representation --- server/api/models/event.js | 2 +- server/api/models/place.js | 5 --- .../20241223144951-slugifyPlaceName.js | 39 ------------------- 3 files changed, 1 insertion(+), 45 deletions(-) delete mode 100644 server/migrations/20241223144951-slugifyPlaceName.js diff --git a/server/api/models/event.js b/server/api/models/event.js index 7433d919..68064cbb 100644 --- a/server/api/models/event.js +++ b/server/api/models/event.js @@ -93,7 +93,7 @@ module.exports = (sequelize, DataTypes) => { startTime: DateTime.fromSeconds(this.start_datetime, opt).toISO(), ...( this.end_datetime ? { endTime : DateTime.fromSeconds(this.end_datetime, opt).toISO() } : {} ), location: { - id: `${config.baseurl}/federation/p/${this.place.slug}`, + id: this.place?.ap_id ?? `${config.baseurl}/federation/p/${this.place.id}`, type: 'Place', name: this.place.name, address: this.place.address, diff --git a/server/api/models/place.js b/server/api/models/place.js index 547bc64a..734824d5 100644 --- a/server/api/models/place.js +++ b/server/api/models/place.js @@ -6,11 +6,6 @@ module.exports = (sequelize, DataTypes) => index: true, allowNull: false }, - slug: { - type: DataTypes.STRING, - index: true, - unique: true - }, address: DataTypes.STRING, latitude: DataTypes.FLOAT, longitude: DataTypes.FLOAT, diff --git a/server/migrations/20241223144951-slugifyPlaceName.js b/server/migrations/20241223144951-slugifyPlaceName.js deleted file mode 100644 index 9afd55c2..00000000 --- a/server/migrations/20241223144951-slugifyPlaceName.js +++ /dev/null @@ -1,39 +0,0 @@ -const SequelizeSlugify = require('sequelize-slugify') -let Place = require('../api/models/place') - -module.exports = { - up: async (queryInterface, Sequelize) => { - const transaction = await queryInterface.sequelize.transaction() - try { - await queryInterface.addColumn('places', 'slug', { type: Sequelize.STRING }) - const place = Place(queryInterface.sequelize, Sequelize.DataTypes) - await SequelizeSlugify.slugifyModel(place, { source: ['name'], overwrite: false }) - const places = await place.findAll({transaction }) - for(const place of places) { - await place.regenerateSlug(transaction) - await place.save() - } - await queryInterface.changeColumn('places', 'slug', { - type: Sequelize.STRING, - index: true, - unique: true - }) - await transaction.commit() - } catch (e) { - console.error(e) - if (transaction) { - await transaction.rollback() - } - return Promise.reject(e) - } - }, - - down: (queryInterface, Sequelize) => { - /** - * Add reverting commands here. - * - * Example: - * await queryInterface.dropTable('users'); - */ - } -}; From 86743b5e8b6f109931abd5ac284e0bc4a4de59e8 Mon Sep 17 00:00:00 2001 From: lesion Date: Mon, 23 Dec 2024 22:49:18 +0100 Subject: [PATCH 05/15] forgot this --- server/api/controller/event.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/api/controller/event.js b/server/api/controller/event.js index 16c8f6fa..461b631a 100644 --- a/server/api/controller/event.js +++ b/server/api/controller/event.js @@ -478,6 +478,10 @@ const eventController = { if (Number(body.end_datetime) > 1000*24*60*60*365) { return res.status(400).send('are you sure?') } + + if (!req.user.is_admin && Number(body.start_datetime) < DateTime.local().toUnixInteger()) { + return res.status(400).send('Only administrators could add a past event') + } } From 476c6b6b2e3fe48109c0abd1fc296f6a02799390 Mon Sep 17 00:00:00 2001 From: lesion Date: Mon, 23 Dec 2024 22:51:59 +0100 Subject: [PATCH 06/15] Revert "forgot this" This reverts commit 86743b5e8b6f109931abd5ac284e0bc4a4de59e8. --- server/api/controller/event.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/server/api/controller/event.js b/server/api/controller/event.js index 461b631a..16c8f6fa 100644 --- a/server/api/controller/event.js +++ b/server/api/controller/event.js @@ -478,10 +478,6 @@ const eventController = { if (Number(body.end_datetime) > 1000*24*60*60*365) { return res.status(400).send('are you sure?') } - - if (!req.user.is_admin && Number(body.start_datetime) < DateTime.local().toUnixInteger()) { - return res.status(400).send('Only administrators could add a past event') - } } From 565cf1eb97e1b8831091f2d3f325a10119d2a468 Mon Sep 17 00:00:00 2001 From: lesion Date: Mon, 23 Dec 2024 22:52:19 +0100 Subject: [PATCH 07/15] forgot this --- server/api/models/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/server/api/models/index.js b/server/api/models/index.js index 3e8e04be..ce748ff7 100644 --- a/server/api/models/index.js +++ b/server/api/models/index.js @@ -82,7 +82,6 @@ const db = { Event.belongsTo(Event, { as: 'parent' }) SequelizeSlugify.slugifyModel(Event, { source: ['title'], overwrite: false }) - SequelizeSlugify.slugifyModel(Place, { source: ['name'], overwrite: false }) }, close() { From fbffa422c298a1066c5842982cedb384d23e52b1 Mon Sep 17 00:00:00 2001 From: lesion Date: Wed, 8 Jan 2025 17:51:36 +0100 Subject: [PATCH 08/15] add AP endpoint for location --- server/api/models/place.js | 45 +++++++++++++++++++++++++++---------- server/federation/index.js | 3 +++ server/federation/places.js | 21 +++++++++++++++++ 3 files changed, 57 insertions(+), 12 deletions(-) create mode 100644 server/federation/places.js diff --git a/server/api/models/place.js b/server/api/models/place.js index 734824d5..c3f86dc2 100644 --- a/server/api/models/place.js +++ b/server/api/models/place.js @@ -1,12 +1,33 @@ -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, -}) \ No newline at end of file +const config = require('../../config') + +module.exports = (sequelize, DataTypes) => { + const Place = sequelize.define('place', { + name: { + type: DataTypes.STRING, + unique: true, + index: true, + allowNull: false + }, + address: DataTypes.STRING, + latitude: DataTypes.FLOAT, + longitude: DataTypes.FLOAT, + }) + + /** + * @description WIP -> https://codeberg.org/fediverse/fep/src/commit/4a75a1bc50bc6d19fc1e6112f02c52621bc178fe/fep/8a8e/fep-8a8e.md#location + * @todo support PlaceAddress type + * @returns ActivityStream location representation + */ + Place.prototype.toAP = function () { + return { + id: `${config.baseurl}/federation/p/${this.id}`, + type: 'Place', + name: this.name, + address: this.address, + latitude: this.latitude, + longitude: this.longitude + } + } + + return Place +} diff --git a/server/federation/index.js b/server/federation/index.js index ea4298e5..1d9e45aa 100644 --- a/server/federation/index.js +++ b/server/federation/index.js @@ -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}`) diff --git a/server/federation/places.js b/server/federation/places.js new file mode 100644 index 00000000..c63e81fb --- /dev/null +++ b/server/federation/places.js @@ -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) + }, +} From 8ef52db372f7bc814edd9071db2ce412dcad27e6 Mon Sep 17 00:00:00 2001 From: lesion Date: Wed, 8 Jan 2025 17:53:33 +0100 Subject: [PATCH 09/15] add internal ap_id representation for federated location --- server/api/models/place.js | 6 +++++- server/migrations/20250108163040-apIdPlace.js | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 server/migrations/20250108163040-apIdPlace.js diff --git a/server/api/models/place.js b/server/api/models/place.js index c3f86dc2..0726d4d0 100644 --- a/server/api/models/place.js +++ b/server/api/models/place.js @@ -8,6 +8,10 @@ module.exports = (sequelize, DataTypes) => { index: true, allowNull: false }, + ap_id: { + type: DataTypes.STRING, + index: true + }, address: DataTypes.STRING, latitude: DataTypes.FLOAT, longitude: DataTypes.FLOAT, @@ -20,7 +24,7 @@ module.exports = (sequelize, DataTypes) => { */ Place.prototype.toAP = function () { return { - id: `${config.baseurl}/federation/p/${this.id}`, + id: this?.ap_id ?? `${config.baseurl}/federation/p/${this.id}`, type: 'Place', name: this.name, address: this.address, diff --git a/server/migrations/20250108163040-apIdPlace.js b/server/migrations/20250108163040-apIdPlace.js new file mode 100644 index 00000000..afaf6cdd --- /dev/null +++ b/server/migrations/20250108163040-apIdPlace.js @@ -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') + } +}; From 19c50527f875fb2a4f5b0682837ad937f298d22b Mon Sep 17 00:00:00 2001 From: lesion Date: Wed, 8 Jan 2025 18:01:41 +0100 Subject: [PATCH 10/15] minor --- server/api/models/place.js | 5 ++--- server/federation/helpers.js | 3 ++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/api/models/place.js b/server/api/models/place.js index 0726d4d0..0bd8e6b9 100644 --- a/server/api/models/place.js +++ b/server/api/models/place.js @@ -19,7 +19,7 @@ module.exports = (sequelize, DataTypes) => { /** * @description WIP -> https://codeberg.org/fediverse/fep/src/commit/4a75a1bc50bc6d19fc1e6112f02c52621bc178fe/fep/8a8e/fep-8a8e.md#location - * @todo support PlaceAddress type + * @todo support PostalAddress type * @returns ActivityStream location representation */ Place.prototype.toAP = function () { @@ -28,8 +28,7 @@ module.exports = (sequelize, DataTypes) => { type: 'Place', name: this.name, address: this.address, - latitude: this.latitude, - longitude: this.longitude + ...( this.latitude && this.longitude && ({ latitude: this.latitude, longitude: this.longitude})) } } diff --git a/server/federation/helpers.js b/server/federation/helpers.js index d913e2b9..baa14079 100644 --- a/server/federation/helpers.js +++ b/server/federation/helpers.js @@ -178,6 +178,7 @@ const Helpers = { async parsePlace (APEvent) { const eventController = require('../api/controller/event') let place + if (APEvent?.location) { place = { place_name: APEvent.location?.name, @@ -233,7 +234,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) { From d7eec9bf29d0597c224b4465fd7b44c55d4713cc Mon Sep 17 00:00:00 2001 From: lesion Date: Wed, 8 Jan 2025 18:02:32 +0100 Subject: [PATCH 11/15] add an AP representation for online_location and use new place representation --- server/api/models/event.js | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/server/api/models/event.js b/server/api/models/event.js index 68064cbb..b9a69813 100644 --- a/server/api/models/event.js +++ b/server/api/models/event.js @@ -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,14 +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: { - id: this.place?.ap_id ?? `${config.baseurl}/federation/p/${this.place.id}`, - 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, From ce6c452b9137ff7b5371a1e7ebd3447127db4b27 Mon Sep 17 00:00:00 2001 From: lesion Date: Fri, 10 Jan 2025 18:33:22 +0100 Subject: [PATCH 12/15] unit test AP location --- tests/ap.test.js | 60 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/ap.test.js b/tests/ap.test.js index 37708dfa..8459fccf 100644 --- a/tests/ap.test.js +++ b/tests/ap.test.js @@ -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') From 0899e716844530b2af5f29f33954b6c5280ba2ac Mon Sep 17 00:00:00 2001 From: lesion Date: Fri, 10 Jan 2025 18:33:49 +0100 Subject: [PATCH 13/15] AP locations fixtures --- .../bad-location-without-a-name.json | 4 +++ .../AP-location/multiple-mixed-locations.json | 30 +++++++++++++++++++ .../physical-location-no-address.json | 5 ++++ ...physical-location-with-postal-address.json | 13 ++++++++ ...l-location-with-simple-string-address.json | 6 ++++ .../physical-location-without-id.json | 4 +++ .../AP-location/virtual-location.json | 9 ++++++ 7 files changed, 71 insertions(+) create mode 100644 tests/fixtures/AP-location/bad-location-without-a-name.json create mode 100644 tests/fixtures/AP-location/multiple-mixed-locations.json create mode 100644 tests/fixtures/AP-location/physical-location-no-address.json create mode 100644 tests/fixtures/AP-location/physical-location-with-postal-address.json create mode 100644 tests/fixtures/AP-location/physical-location-with-simple-string-address.json create mode 100644 tests/fixtures/AP-location/physical-location-without-id.json create mode 100644 tests/fixtures/AP-location/virtual-location.json diff --git a/tests/fixtures/AP-location/bad-location-without-a-name.json b/tests/fixtures/AP-location/bad-location-without-a-name.json new file mode 100644 index 00000000..8db55551 --- /dev/null +++ b/tests/fixtures/AP-location/bad-location-without-a-name.json @@ -0,0 +1,4 @@ +{ + "id" : "http://localhost:13120/federation/p/1", + "type" : "Place" +} diff --git a/tests/fixtures/AP-location/multiple-mixed-locations.json b/tests/fixtures/AP-location/multiple-mixed-locations.json new file mode 100644 index 00000000..722aed21 --- /dev/null +++ b/tests/fixtures/AP-location/multiple-mixed-locations.json @@ -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" + } +] diff --git a/tests/fixtures/AP-location/physical-location-no-address.json b/tests/fixtures/AP-location/physical-location-no-address.json new file mode 100644 index 00000000..74c731ba --- /dev/null +++ b/tests/fixtures/AP-location/physical-location-no-address.json @@ -0,0 +1,5 @@ +{ + "id" : "http://localhost:13120/federation/p/1", + "name" : "Location with a name", + "type" : "Place" +} diff --git a/tests/fixtures/AP-location/physical-location-with-postal-address.json b/tests/fixtures/AP-location/physical-location-with-postal-address.json new file mode 100644 index 00000000..8e41a452 --- /dev/null +++ b/tests/fixtures/AP-location/physical-location-with-postal-address.json @@ -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" +} diff --git a/tests/fixtures/AP-location/physical-location-with-simple-string-address.json b/tests/fixtures/AP-location/physical-location-with-simple-string-address.json new file mode 100644 index 00000000..705fc5ed --- /dev/null +++ b/tests/fixtures/AP-location/physical-location-with-simple-string-address.json @@ -0,0 +1,6 @@ +{ + "id" : "http://localhost:13120/federation/p/3", + "name" : "Location with a simple string address", + "address": "Simple string address", + "type" : "Place" +} diff --git a/tests/fixtures/AP-location/physical-location-without-id.json b/tests/fixtures/AP-location/physical-location-without-id.json new file mode 100644 index 00000000..3af62463 --- /dev/null +++ b/tests/fixtures/AP-location/physical-location-without-id.json @@ -0,0 +1,4 @@ +{ + "name" : "Location without id", + "type" : "Place" +} diff --git a/tests/fixtures/AP-location/virtual-location.json b/tests/fixtures/AP-location/virtual-location.json new file mode 100644 index 00000000..c41e4e74 --- /dev/null +++ b/tests/fixtures/AP-location/virtual-location.json @@ -0,0 +1,9 @@ +{ + "id" : "https://virtual.location.org", + "name" : "A Virtual location", + "address": { + "type": "VirtualLocation", + "url": "https://virtual.location.org" + }, + "type" : "Place" +} From 8f9bc7e9decd158273c5f0f1e64f854179eb5114 Mon Sep 17 00:00:00 2001 From: lesion Date: Fri, 10 Jan 2025 18:37:22 +0100 Subject: [PATCH 14/15] use id in places url (ics and rss feeds, place page) with a reasonable backward compatible fallback --- components/Event.vue | 2 +- components/EventMapDialog.vue | 2 +- components/admin/Places.vue | 2 +- pages/event/_slug.vue | 2 +- pages/place/_id/_place.vue | 65 +++++++++++++++++++ pages/place/_place.vue | 4 +- server/api/controller/place.js | 10 +-- server/api/index.js | 2 +- server/api/models/place.js | 6 +- server/federation/helpers.js | 4 +- .../20250109092712-removeNameUniqueness.js | 23 +++++++ server/routes.js | 2 +- 12 files changed, 107 insertions(+), 17 deletions(-) create mode 100644 pages/place/_id/_place.vue create mode 100644 server/migrations/20250109092712-removeNameUniqueness.js diff --git a/components/Event.vue b/components/Event.vue index dd15ce83..f080b4de 100644 --- a/components/Event.vue +++ b/components/Event.vue @@ -20,7 +20,7 @@
+ :to='`/place/${event.place.id}/${encodeURIComponent(event.place.name)}`'> {{ event.place.name }} diff --git a/components/EventMapDialog.vue b/components/EventMapDialog.vue index 4bae03b6..ebe4d23d 100644 --- a/components/EventMapDialog.vue +++ b/components/EventMapDialog.vue @@ -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') diff --git a/components/admin/Places.vue b/components/admin/Places.vue index 94172cd4..5a939ecb 100644 --- a/components/admin/Places.vue +++ b/components/admin/Places.vue @@ -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') diff --git a/pages/event/_slug.vue b/pages/event/_slug.vue index ba5461a8..8bc13ae8 100644 --- a/pages/event/_slug.vue +++ b/pages/event/_slug.vue @@ -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}} diff --git a/pages/place/_id/_place.vue b/pages/place/_id/_place.vue new file mode 100644 index 00000000..4bd83f68 --- /dev/null +++ b/pages/place/_id/_place.vue @@ -0,0 +1,65 @@ + + diff --git a/pages/place/_place.vue b/pages/place/_place.vue index 8049b2ec..f3108932 100644 --- a/pages/place/_place.vue +++ b/pages/place/_place.vue @@ -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}` } ], } }, diff --git a/server/api/controller/place.js b/server/api/controller/place.js index 5b88b572..b705dd0b 100644 --- a/server/api/controller/place.js +++ b/server/api/controller/place.js @@ -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: diff --git a/server/api/index.js b/server/api/index.js index 5c7cc2d5..09748fa2 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -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) diff --git a/server/api/models/place.js b/server/api/models/place.js index 0bd8e6b9..1ce669e9 100644 --- a/server/api/models/place.js +++ b/server/api/models/place.js @@ -4,7 +4,6 @@ module.exports = (sequelize, DataTypes) => { const Place = sequelize.define('place', { name: { type: DataTypes.STRING, - unique: true, index: true, allowNull: false }, @@ -18,9 +17,10 @@ module.exports = (sequelize, DataTypes) => { }) /** - * @description WIP -> https://codeberg.org/fediverse/fep/src/commit/4a75a1bc50bc6d19fc1e6112f02c52621bc178fe/fep/8a8e/fep-8a8e.md#location - * @todo support PostalAddress type * @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 { diff --git a/server/federation/helpers.js b/server/federation/helpers.js index baa14079..3ffcaac9 100644 --- a/server/federation/helpers.js +++ b/server/federation/helpers.js @@ -288,7 +288,9 @@ const Helpers = { return false }) - await event.setPlace(place) + if (place) { + await event.setPlace(place) + } // create/assign tags let tags = [] diff --git a/server/migrations/20250109092712-removeNameUniqueness.js b/server/migrations/20250109092712-removeNameUniqueness.js new file mode 100644 index 00000000..f852d418 --- /dev/null +++ b/server/migrations/20250109092712-removeNameUniqueness.js @@ -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 }) + } +}; diff --git a/server/routes.js b/server/routes.js index 62399f31..e91cab33 100644 --- a/server/routes.js +++ b/server/routes.js @@ -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) From 0268443822487274514c327cdf4ab5953781f700 Mon Sep 17 00:00:00 2001 From: lesion Date: Fri, 10 Jan 2025 18:38:26 +0100 Subject: [PATCH 15/15] parse federated location --- server/api/controller/event.js | 15 +++++-- server/federation/helpers.js | 73 ++++++++++++++++++++++++---------- 2 files changed, 64 insertions(+), 24 deletions(-) diff --git a/server/api/controller/event.js b/server/api/controller/event.js index 16c8f6fa..fdc24068 100644 --- a/server/api/controller/event.js +++ b/server/api/controller/event.js @@ -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) diff --git a/server/federation/helpers.js b/server/federation/helpers.js index 3ffcaac9..3b6a3b38 100644 --- a/server/federation/helpers.js +++ b/server/federation/helpers.js @@ -175,39 +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] }, /**