Commit 2fb8ead5 authored by Lukas Matt's avatar Lukas Matt

Merge branch 'activity_pub' into 'master'

Add ActivityPub API calls

See merge request !4
parents f669924c 09ec5aad
package controllers
//
// GangGo API Library
// Copyright (C) 2018 Lukas Matt <lukas@zauberstuhl.de>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
import (
"net/http"
api "git.feneas.org/ganggo/api/app"
"github.com/revel/revel"
"git.feneas.org/ganggo/ganggo/app/models"
run "github.com/revel/modules/jobs/app/jobs"
"git.feneas.org/ganggo/federation"
"git.feneas.org/ganggo/ganggo/app/jobs"
"strconv"
"fmt"
)
/**
* @apiDefine ActivityPub ActivityPub related API calls
*
* Fetch ActivityStream objects (see https://www.w3.org/TR/activitystreams-core)
*/
type ApiApUser struct {
ApiHelper
}
/**
* @api {get} /ap/user/:username/actor Display actor information
* @apiName ApiApUser.Actor
* @apiGroup ApiApUsers
*
* @apiParam {String} username The name associated with the user account
*
* @apiSuccess {String} @context Activitystreams context
* @apiSuccess {String} id Link to actor profile
* @apiSuccess {String} type Object type e.g. Person
* @apiSuccess {String} inbox Link to user inbox
* @apiSuccess {String} outbox Link to user outbox
*
* @apiSuccessExample {json} Success-Response
* HTTP/1.1 200 OK
* {
* "@context": [
* "https://www.w3.org/ns/activitystreams"
* ],
* "id": "http://localhost/api/v0/ap/user/g1/actor",
* "type": "Person",
* "inbox": "http://localhost/api/v0/ap/user/g1/inbox",
* "outbox": "http://localhost/api/v0/ap/user/g1/outbox",
* "following": "http://localhost/api/v0/ap/user/g1/following",
* "followers": "http://localhost/api/v0/ap/user/g1/followers",
* "preferredUsername": "g1",
* "url": "http://localhost/profiles/48e5074b9cada4d58e585f2394b6a4b1",
* "publicKey": {
* "id": "http://localhost/api/v0/ap/user/g1/actor#main-key",
* "owner": "http://localhost/api/v0/ap/user/g1/actor",
* "publicKeyPem": "-----BEGIN PUBLIC KEY-----\n[...]\n-----END PUBLIC KEY-----\n"
* },
* "icon": {
* "url": "http://localhost/public/img/avatar.png"
* },
* "endpoints": {
* "sharedInbox": "http://localhost/api/v0/ap/inbox"
* }
* }
*
* @apiError (Errors) {String} error Contains the recent error message
*
* @apiErrorExample {json} NotFound
* HTTP/1.1 404 Not Found
* {
* "error": "[...]"
* }
*
*/
func (a ApiApUser) Actor(username string) revel.Result {
var person models.Person
err := person.FindByAuthor(username + "@" + api.ADDRESS)
if err != nil {
a.Log.Error(TAG, "db", err, "api", ERR_NOT_FOUND)
return a.ApiError(http.StatusNotFound, ERR_NOT_FOUND)
}
host := fmt.Sprintf("%s%s", api.PROTO, api.ADDRESS)
profile := fmt.Sprintf("%s/profiles/%s", host, person.Guid)
actor := federation.ActivityPubUpdate{
Object: federation.ActivityPubActor{
ActivityPubContext: federation.ActivityPubContext{
Context: []interface{}{federation.ACTIVITY_STREAMS},
},
Url: &profile,
PublicKey: &federation.ActivityPubActorPubKey{
Id: fmt.Sprintf("%s%s#main-key", host, a.Request.URL.Path),
Owner: fmt.Sprintf("%s%s", host, a.Request.URL.Path),
PublicKeyPem: person.SerializedPublicKey,
},
Icon: &federation.ActivityPubActorIcon{
Url: fmt.Sprintf("%s%s", host, person.Profile.ImageUrl),
},
Endpoints: &federation.ActivityPubActorEndpoints{
SharedInbox: fmt.Sprintf("%s/api/%s/ap/inbox", host, api.API_VERSION),
},
},
}
actor.SetAuthor(person.Author)
actor.Marshal(nil, nil)
return a.RenderJSONLD(actor.Object)
}
/**
* @api {post} /ap/user/:username/inbox User inbox
* @apiName ApiApUser.Inbox
* @apiGroup ApiApUsers
*
* @apiParam {String} username The name associated with the user account
*
* @apiSuccessExample {json} Success-Response
* HTTP/1.1 200 OK
* {
* "@context": [
* "https://www.w3.org/ns/activitystreams"
* ],
* "id": "http://localhost/api/v0/ap/user/g1/outbox",
* "type": "OrderedCollection",
* "totalItems": 1,
* "first": "http://localhost/api/v0/ap/user/g1/outbox?page=1"
* }
*
* @apiError (Errors) {String} error Contains the recent error message
*
* @apiErrorExample {json} ServerError
* HTTP/1.1 500 Internal Server Error
* {
* "error": "[...]"
* }
*
*/
func (a ApiApUser) Inbox(username string) revel.Result {
request := a.Request.In.GetRaw().(*http.Request)
msg, err := federation.ActivityPubParse(request)
if err != nil {
a.Log.Error(TAG, "activitypub", err, "api", ERR_SERVER)
return a.ApiError(http.StatusInternalServerError, ERR_SERVER)
}
var guid string
if username != "" {
var user models.User
err = user.FindByUsername(username)
if err != nil {
a.Log.Error(TAG, "activitypub", err, "api", ERR_SERVER)
return a.ApiError(http.StatusInternalServerError, ERR_SERVER)
}
guid = user.Person.Guid
}
run.Now(jobs.Receiver{Guid: guid, Message: msg})
return a.Outbox(username)
}
/**
* @api {get} /ap/user/:username/outbox User outbox
* @apiName ApiApUser.Outbox
* @apiGroup ApiApUsers
*
* @apiParam {String} username The name associated with the user account
*
* @apiSuccessExample {json} Success-Response
* HTTP/1.1 200 OK
* {
* "@context": [
* "https://www.w3.org/ns/activitystreams"
* ],
* "id": "http://localhost/api/v0/ap/user/g1/outbox",
* "type": "OrderedCollection",
* "totalItems": 1,
* "first": "http://localhost/api/v0/ap/user/g1/outbox?page=1"
* }
*
* @apiError (Errors) {String} error Contains the recent error message
*
* @apiErrorExample {json} ServerError
* HTTP/1.1 500 Internal Server Error
* {
* "error": "[...]"
* }
*
*/
func (a ApiApUser) Outbox(username string) revel.Result {
var page string
a.Params.Bind(&page, "page")
var person models.Person
err := person.FindByAuthor(username + "@" + api.ADDRESS)
if err != nil {
a.Log.Error(TAG, "db", err, "api", ERR_NOT_FOUND)
return a.ApiError(http.StatusNotFound, ERR_NOT_FOUND)
}
outbox := fmt.Sprintf("%s%s%s", api.PROTO, api.ADDRESS, a.Request.URL.Path)
context := federation.ActivityPubContext{
Context: []interface{}{federation.ACTIVITY_STREAMS},
ActivityPubBase: federation.ActivityPubBase{
Id: outbox, Type: federation.ActivityTypeOrderedCollection,
},
}
var posts models.Posts
pageNumber, err := strconv.ParseUint(page, 10, 32);
if err != nil {
err = posts.FindAllPublicByPerson(person, 0)
if err != nil {
a.Log.Error(TAG, "db", err, "api", ERR_NOT_FOUND)
return a.ApiError(http.StatusNotFound, ERR_NOT_FOUND)
}
lenPosts := len(posts)
var nextPtr *string = nil
if lenPosts > 0 {
next := fmt.Sprintf("%s?page=1", outbox)
nextPtr = &next
}
return a.RenderJSONLD(federation.ActivityPubCollection{
ActivityPubContext: context, TotalItems: lenPosts, First: nextPtr,
})
}
var offset uint = ((uint(pageNumber) - 1) * 10)
err = posts.FindAllPublicByPerson(person, offset)
if err != nil {
a.Log.Error(TAG, "db", err, "api", ERR_NOT_FOUND)
return a.ApiError(http.StatusNotFound, ERR_NOT_FOUND)
}
var lenPosts = len(posts)
var notes []federation.ActivityPubNote
for _, post := range posts {
msg := federation.ActivityPubCreatePost{}
msg.SetAuthor(post.Person.Author)
msg.SetText(post.Text)
msg.SetGuid(post.Guid)
msg.SetPublic(post.Public)
msg.SetCreatedAt(post.CreatedAt)
msg.Marshal(nil, nil)
notes = append(notes, msg.Object)
}
var nextPtr *string = nil
if lenPosts == 10 {
next := fmt.Sprintf("%s?page=%d", outbox, pageNumber + 1)
nextPtr = &next
}
return a.RenderJSONLD(federation.ActivityPubCollectionPage{
ActivityPubContext: context, TotalItems: lenPosts,
PartOf: fmt.Sprintf("%s%s%s", api.PROTO,
api.ADDRESS, a.Request.URL.Path),
Next: nextPtr, OrderedItems: notes,
})
}
/**
* @api {get} /ap/user/:username/following User following statistics
* @apiName ApiApUser.Following
* @apiGroup ApiApUsers
*
* @apiParam {String} username The name associated with the user account
*
* @apiSuccessExample {json} Success-Response
* HTTP/1.1 200 OK
* {
* "@context": [
* "https://www.w3.org/ns/activitystreams"
* ],
* "id": "http://localhost/api/v0/ap/user/g1/outbox",
* "type": "OrderedCollection",
* "totalItems": 1,
* "first": "http://localhost/api/v0/ap/user/g1/following?page=1"
* }
*
* @apiError (Errors) {String} error Contains the recent error message
*
* @apiErrorExample {json} NotFound
* HTTP/1.1 404 Not Found
* {
* "error": "[...]"
* }
*
*/
func (a ApiApUser) Following(username string) revel.Result {
return a.follow("following", username)
}
/**
* @api {get} /ap/user/:username/followers User follower statistics
* @apiName ApiApUser.Followers
* @apiGroup ApiApUsers
*
* @apiParam {String} username The name associated with the user account
*
* @apiSuccessExample {json} Success-Response
* HTTP/1.1 200 OK
* {
* "@context": [
* "https://www.w3.org/ns/activitystreams"
* ],
* "id": "http://localhost/api/v0/ap/user/g1/outbox",
* "type": "OrderedCollection",
* "totalItems": 1,
* "first": "http://localhost/api/v0/ap/user/g1/followers?page=1"
* }
*
* @apiError (Errors) {String} error Contains the recent error message
*
* @apiErrorExample {json} NotFound
* HTTP/1.1 404 Not Found
* {
* "error": "[...]"
* }
*
*/
func (a ApiApUser) Followers(username string) revel.Result {
return a.follow("followers", username)
}
func (a ApiApUser) follow(mode, username string) revel.Result {
var person models.Person
err := person.FindByAuthor(username + "@" + api.ADDRESS)
if err != nil {
a.Log.Error(TAG, "db", err, "api", ERR_NOT_FOUND)
return a.ApiError(http.StatusNotFound, ERR_NOT_FOUND)
}
var contacts models.Contacts
err = contacts.FindByUserID(person.UserID)
if err != nil {
a.Log.Error(TAG, "db", err, "api", ERR_NOT_FOUND)
return a.ApiError(http.StatusNotFound, ERR_NOT_FOUND)
}
var follows int
if mode == "followers" {
for _, contact := range contacts {
if contact.Sharing {
follows = follows + 1
}
}
} else if mode == "following" {
var aspects models.Aspects
err := aspects.FindByUserID(person.UserID)
if err == nil {
countMap := make(map[uint]bool)
for _, aspect := range aspects {
for _, membership := range aspect.Memberships {
countMap[membership.PersonID] = true
}
}
follows = len(countMap)
}
}
return a.RenderJSONLD(federation.ActivityPubCollection{
ActivityPubContext: federation.ActivityPubContext{
Context: []interface{}{federation.ACTIVITY_STREAMS},
ActivityPubBase: federation.ActivityPubBase{
Id: fmt.Sprintf("%s%s%s", api.PROTO, api.ADDRESS, a.Request.URL.Path),
Type: federation.ActivityTypeOrderedCollection,
},
},
TotalItems: follows,
})
}
......@@ -22,7 +22,6 @@ import (
"git.feneas.org/ganggo/api/app/helpers"
"git.feneas.org/ganggo/ganggo/app/jobs"
run "github.com/revel/modules/jobs/app/jobs"
federation "git.feneas.org/ganggo/federation"
"git.feneas.org/ganggo/ganggo/app/models"
"net/http"
)
......@@ -173,12 +172,7 @@ func (a ApiAspect) CreatePerson() revel.Result {
if len(aspects) == 0 {
run.Now(jobs.Dispatcher{
User: a.CurrentUser,
Message: federation.EntityContact{
Author: a.CurrentUser.Person.Author,
Recipient: person.Author,
Sharing: true,
Following: true,
},
Message: membership,
})
}
......@@ -269,12 +263,7 @@ func (a ApiAspect) DeletePerson() revel.Result {
if len(aspects) == 0 {
run.Now(jobs.Dispatcher{
User: a.CurrentUser,
Message: federation.EntityContact{
Author: a.CurrentUser.Person.Author,
Recipient: person.Author,
Sharing: false,
Following: false,
},
Message: membership,
})
}
return a.RenderJSON(membership)
......
......@@ -48,6 +48,13 @@ type ApiError struct {
Error string `json:"error"`
}
func (c ApiHelper) RenderJSONLD(v interface{}) revel.Result {
c.Response.ContentType = "application/ld+json"
c.Response.Out.Header().Add(
"Accept", `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`)
return c.RenderJSON(v)
}
func (c ApiHelper) CatchAll() revel.Result {
return c.ApiError(http.StatusNotImplemented, ERR_NOT_IMPLEMENTED)
}
......
......@@ -24,7 +24,7 @@ import (
"git.feneas.org/ganggo/api/app/helpers"
// XXX only for uuid
uuid "git.feneas.org/ganggo/ganggo/app/helpers"
federation "git.feneas.org/ganggo/federation"
run "github.com/revel/modules/jobs/app/jobs"
"strconv"
"net/http"
)
......@@ -236,32 +236,24 @@ func (c ApiComment) CreatePost() revel.Result {
return c.ApiError(http.StatusInternalServerError, ERR_SERVER)
}
entityComment := federation.EntityComment{
Author: c.CurrentUser.Person.Author,
comment := models.Comment{
Guid: guid,
ParentGuid: post.Guid,
Text: text,
ShareableID: post.ID,
ShareableType: models.ShareablePost,
PersonID: c.CurrentUser.Person.ID,
}
var comment models.Comment
err = comment.Create(&entityComment)
err = comment.Create()
if err != nil {
c.Log.Error(TAG, "db", err, "api", ERR_SERVER)
return c.ApiError(http.StatusInternalServerError, ERR_SERVER)
}
entityComment.CreatedAt.New(comment.CreatedAt)
dispatcher := jobs.Dispatcher{
User: c.CurrentUser,
Model: comment,
Message: entityComment,
}
// XXX
// notify local user about a new comment
//go like.TriggerNotification(parentUser)
// deliver to the network
go dispatcher.Run()
run.Now(jobs.Dispatcher{User: c.CurrentUser, Message: comment})
// XXX
// notify local user about a new comment
//go like.TriggerNotification(parentUser)
return c.RenderJSON(comment)
}
......@@ -313,12 +305,8 @@ func (c ApiComment) Delete(guid string) revel.Result {
}
if comment.Person.ID == c.CurrentUser.Person.ID {
entity := federation.EntityRetraction{
Author: c.CurrentUser.Person.Author,
TargetGuid: comment.Guid,
TargetType: models.ShareableComment,
}
dispatcher := jobs.Dispatcher{Message: entity}
dispatcher := jobs.Dispatcher{
User: c.CurrentUser, Retract: true, Message: comment}
// NOTE relay to other hosts if we own this entity
// should be done before we start deleting db records
// thats why this call is synchron XXX we should
......
......@@ -22,7 +22,7 @@ import (
"git.feneas.org/ganggo/ganggo/app/models"
"git.feneas.org/ganggo/ganggo/app/jobs"
"git.feneas.org/ganggo/ganggo/app/helpers"
federation "git.feneas.org/ganggo/federation"
run "github.com/revel/modules/jobs/app/jobs"
"net/http"
"strconv"
)
......@@ -163,7 +163,6 @@ func (l ApiLike) Create() revel.Result {
var (
postID uint
post models.Post
like models.Like
positive bool
)
......@@ -182,31 +181,24 @@ func (l ApiLike) Create() revel.Result {
return l.ApiError(http.StatusInternalServerError, ERR_SERVER)
}
entityLike := federation.EntityLike{
like := models.Like{
Positive: positive,
ShareableID: post.ID,
PersonID: l.CurrentUser.Person.ID,
Guid: guid,
Author: l.CurrentUser.Person.Author,
ParentType: models.ShareablePost,
ParentGuid: post.Guid,
ShareableType: models.ShareablePost,
}
err = like.Create(&entityLike)
err = like.Create()
if err != nil {
l.Log.Error(TAG, "db", err, "api", ERR_SERVER)
return l.ApiError(http.StatusInternalServerError, ERR_SERVER)
}
dispatcher := jobs.Dispatcher{
User: l.CurrentUser,
Model: like,
Message: entityLike,
}
// XXX
// notify local user about a new comment
//go like.TriggerNotification(parentUser)
// deliver to the network
go dispatcher.Run()
run.Now(jobs.Dispatcher{User: l.CurrentUser, Message: like})
// XXX
// notify local user about a new comment
//go like.TriggerNotification(parentUser)
return l.RenderJSON(like)
}
......@@ -265,12 +257,8 @@ func (l ApiLike) Delete(guid string) revel.Result {
}
if like.PersonID == l.CurrentUser.Person.ID {
entity := federation.EntityRetraction{
Author: l.CurrentUser.Person.Author,
TargetGuid: like.Guid,
TargetType: models.ShareableLike,
}
dispatcher := jobs.Dispatcher{Message: entity}
dispatcher := jobs.Dispatcher{
User: l.CurrentUser, Retract: true, Message: like}
// NOTE relay to other hosts if we own this entity
// should be done before we start deleting db records
// thats why this call is synchron XXX we should
......
......@@ -25,7 +25,7 @@ import (
"git.feneas.org/ganggo/ganggo/app/helpers"
"git.feneas.org/ganggo/ganggo/app/models"
"git.feneas.org/ganggo/ganggo/app/jobs"
federation "git.feneas.org/ganggo/federation"
run "github.com/revel/modules/jobs/app/jobs"
"net/http"
)
......@@ -170,7 +170,6 @@ func (p ApiPost) Index() revel.Result {
*/
func (p ApiPost) Create() revel.Result {
var (
post models.Post
postText, fields string
aspectID uint
)
......@@ -191,27 +190,21 @@ func (p ApiPost) Create() revel.Result {
return p.ApiError(http.StatusInternalServerError, ERR_SERVER)
}
entity := federation.EntityStatusMessage{
Text: postText,
Author: p.CurrentUser.Person.Author,
post := models.Post{
CreatedAt: time.Now(),
Public: aspectID <= 0,
Guid: guid,
ProviderName: "GangGo",
Public: true,
}
entity.CreatedAt.New(time.Now())
// this one is a private message
if aspectID > 0 {
entity.Public = false
Type: models.StatusMessage,
Text: postText,
PersonID: p.CurrentUser.Person.ID,
}
// save post locally
err = post.Create(&entity)
err = post.Create()
if err != nil {
p.Log.Error(TAG, "db", err, "api", ERR_SERVER)
return p.ApiError(http.StatusInternalServerError, ERR_SERVER)
}
if entity.Public == false {
if !post.Public {
// this is required for mapping
// posts to the author
shareable := models.Shareable{
......@@ -236,12 +229,8 @@ func (p ApiPost) Create() revel.Result {
}
}
dispatcher := jobs.Dispatcher{
User: p.CurrentUser,
Message: entity,
Model: post,
}
go dispatcher.Run()
// federate all the things \m/
run.Now(jobs.Dispatcher{User: p.CurrentUser, Message: post})
return p.RenderJSON(
apiHelpers.SelectStructFields(post, fields))
......@@ -361,15 +350,8 @@ func (p ApiPost) Delete(guid string) revel.Result {
}
if post.Person.ID == p.CurrentUser.Person.ID {
entity := federation.EntityRetraction{
Author: p.CurrentUser.Person.Author,
TargetGuid: post.Guid,
TargetType: models.ShareablePost,
}
dispatcher := jobs.Dispatcher{
User: p.CurrentUser,
Message: entity,
}
User: p.CurrentUser, Retract: true, Message: post}
// NOTE relay to other hosts if we own this entity
// should be done before we start deleting db records
// thats why this call is synchron XXX we should
......@@ -455,26 +437,22 @@ func (p ApiPost) Reshare() revel.Result {
return p.ApiError(http.StatusInternalServerError, ERR_SERVER)
}
entity := federation.EntityReshare{
Author: p.CurrentUser.Person.Author,
post := models.Post{
PersonID: p.CurrentUser.Person.ID,
CreatedAt: time.Now(),
Public: true, // reshare is always public
Guid: guid,
RootAuthor: rootPost.Person.Author,
RootGuid: rootPost.Guid,
Type: models.Reshare,
RootPersonID: rootPost.Person.ID,
RootGuid: &rootPost.Guid,
}
entity.CreatedAt.New(time.Now())
var post models.Post
err = post.Create(&entity)
err = post.Create()
if err != nil {
p.Log.Error(TAG, "db", err, "api", ERR_SERVER)
return p.ApiError(http.StatusInternalServerError, ERR_SERVER)
}
dispatcher := jobs.Dispatcher{
User: p.CurrentUser,
Model: post,
Message: entity,
}
go dispatcher.Run()
run.Now(jobs.Dispatcher{User: p.CurrentUser, Message: post})
return p.RenderJSON(post)
}
......@@ -8,8 +8,19 @@ import (
"github.com/revel/revel"
)
var (
API_VERSION string
PROTO string
ADDRESS string
)
func init() {
revel.OnAppStart(func() {
revel.Config.SetSection("ganggo")
API_VERSION = revel.Config.StringDefault("api.version", "v0")
PROTO = revel.Config.StringDefault("proto", "http://")
ADDRESS = revel.Config.StringDefault("address", "localhost")
revel.INFO.Println("Restful API loaded.")
})
}
# Restful API routes
## ActivityPub
* /api/v0/ap/inbox ApiApUser.Inbox
* /api/v0/ap/user/:username/outbox ApiApUser.Outbox
POST /api/v0/ap/user/:username/inbox ApiApUser.Inbox
* /api/v0/ap/user/:username/following ApiApUser.Following
* /api/v0/ap/user/:username/followers ApiApUser.Followers
* /api/v0/ap/user/:username/actor ApiApUser.Actor
## GangGo API
POST /api/v0/oauth/tokens ApiOAuth.Create
DELETE /api/v0/oauth/tokens/:id ApiOAuth.Delete
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment