Compare commits

...

95 Commits

Author SHA1 Message Date
Anthony Wang a8be3ece4b
Use GetIRI() function instead of manually constructing IRI 2022-11-28 19:22:23 +00:00
Anthony Wang 439f6754ac
Don't send emails to federated users 2022-11-27 22:37:28 +00:00
Anthony Wang c64d3fa195
Implement commenting on issues from Mastodon 2022-11-27 22:30:00 +00:00
Anthony Wang f5a50ce457
Add Note object endpoint 2022-11-27 19:29:03 +00:00
Anthony Wang 3e690fbae2
Federated issue creation 2022-11-27 19:09:10 +00:00
Anthony Wang 77896f1a50
Save issue IRIs when creating them from AS objects 2022-11-27 18:38:20 +00:00
Anthony Wang 1066cfe785
Implement commenting and fix lint errors 2022-11-27 04:18:39 +00:00
Anthony Wang 3f5f626264
Fix AppSubURL -> AppURL typo 2022-11-27 03:51:09 +00:00
Anthony Wang a666eefe8f
Rewrite createPullRequest and add createPersonFromIRI 2022-11-27 02:05:36 +00:00
Anthony Wang c982b67626
Set issue Index, not ID, when creating issues from an AS object 2022-11-27 01:44:02 +00:00
Anthony Wang 2d74e4f555
Delete fork code and move createPullRequest to create.go 2022-11-27 01:40:15 +00:00
Anthony Wang fd4d0e730e
Move AS object processing to routers/api/v1/activitypub, move AP transport and IRI code to services/activitypub
This is to follow https://docs.gitea.io/en-us/guidelines-backend/ and avoid import cycles.
2022-11-27 00:34:24 +00:00
Anthony Wang 41e9a10763
make fmt 2022-11-26 18:30:01 +00:00
Anthony Wang 447650f21c
Remove @ from regex because idk what it was doing there in the first place 2022-11-26 17:36:59 +00:00
Anthony Wang d22dab748f
Add custom NotEmpty function to handle ForgeFed types 2022-11-26 17:34:43 +00:00
Anthony Wang 19af0c9267
Implement loading remote tickets 2022-11-25 18:45:06 +00:00
Anthony Wang 20e8c64317
Fix UnfollowUser addressing 2022-11-12 05:35:30 +00:00
Anthony Wang ca502244a0
Send out undo follow activity in UnfollowUser 2022-11-12 05:13:07 +00:00
Anthony Wang f75ab80b5c
make fmt 2022-11-11 04:01:30 +00:00
Anthony Wang 0cacdc37fb
More Ticket IRI processing to iri.go 2022-11-11 03:58:09 +00:00
Anthony Wang 69c1bdddc7
Merge remote-tracking branch 'upstream/main' 2022-11-08 00:22:55 +00:00
Anthony Wang 5612130bcf
Merge remote-tracking branch 'upstream/main' 2022-10-27 00:28:53 +00:00
Anthony Wang f133e9ca11
Implement sending follow activities
I moved around a lot of files to fix import cycles
2022-10-21 21:06:59 +00:00
Anthony Wang e78dd699de
Merge remote-tracking branch 'upstream/main' 2022-10-18 18:25:01 +00:00
Anthony Wang cbc2a970be
Set isResolved to true for closed issues 2022-09-29 01:59:55 +00:00
Anthony Wang f9d9019720
Fix Comment permission checking 2022-09-23 17:30:44 +00:00
Anthony Wang 379b9a7dce
Serve issues as ForgeFed tickets 2022-09-23 17:25:13 +00:00
Anthony Wang 26f57be49c
Merge remote-tracking branch 'upstream/main' 2022-09-22 23:57:01 +00:00
Anthony Wang 117463ba78
Change Ta180m/activitypub to xy/activitypub 2022-09-16 08:38:55 -05:00
Anthony Wang f7dbbf73f6
Remove /integrations directory 2022-09-15 09:24:13 -05:00
Anthony Wang 47229ea208
Merge remote-tracking branch 'upstream/main' 2022-09-15 09:21:22 -05:00
Anthony Wang 42b1bac7a6
Merge remote-tracking branch 'upstream/main' 2022-09-12 15:19:35 -05:00
Anthony Wang f0269889c0
Fix build errors 2022-09-05 16:37:56 -05:00
Anthony Wang ec1ffd66e3
Merge remote-tracking branch 'upstream/main' 2022-09-05 16:33:07 -05:00
Gusted 2e957e7ebb
Fix repository tests 2022-08-22 19:56:41 +02:00
Gusted 45324e169f
Add todo 2022-08-22 19:50:47 +02:00
Gusted 2373b4177a
Add paginition to Person's outbox 2022-08-22 19:37:04 +02:00
Gusted 5ad0387fbd
Add copyright to test file 2022-08-22 19:37:04 +02:00
Anthony Wang 73284dbf0b
Add authorize_interaction case for Tickets 2022-08-22 12:29:16 -05:00
Anthony Wang c0efdedaa9
Fix typo for JSONUnmarshalerFn description comment 2022-08-22 12:10:02 -05:00
Anthony Wang ee85f7d957
Use the Repository AttributedTo to get owner IRI 2022-08-22 12:09:26 -05:00
Gusted f1e61af242
Fix another linting error 2022-08-22 19:02:01 +02:00
Gusted 1bc8e67e9c
Fix linting errors (errcheck) 2022-08-22 18:59:45 +02:00
Gusted 819e495dc0
Make gofmt happy 2022-08-22 18:48:17 +02:00
Gusted b9dd4a2f5f
Make revive linter happy 2022-08-22 18:31:39 +02:00
Gusted 18809f811d
Make the frontend linter happy 2022-08-22 18:29:08 +02:00
Anthony Wang b3c065ce80
Refactor RepoInbox to use On functions instead of type assertions 2022-08-21 10:22:42 -05:00
Anthony Wang 27cda2fcd4
Implement JSONLoad, To, and On functions for ForgeFed types 2022-08-20 23:07:11 -05:00
Anthony Wang 6a6c6b3481
Merge remote-tracking branch 'upstream/main' 2022-08-19 17:46:12 -05:00
Anthony Wang 6b73c097ed
Download avatar from URL and set it with user_service.UploadAvatar 2022-08-15 12:00:14 -05:00
Anthony Wang d945e6ac72
Start working on Ticket object endpoint implementation 2022-08-15 11:15:21 -05:00
Anthony Wang 0b97c6aa69
Cache remote user public keys 2022-08-15 11:14:48 -05:00
Anthony Wang ecefb6a2d0
Merge remote-tracking branch 'upstream/main' 2022-08-10 15:07:13 -05:00
Anthony Wang fe8ef28bc2
Merge remote-tracking branch 'upstream/main' 2022-08-03 20:48:06 -05:00
Anthony Wang 71b2b4d815
Implement FederatedRepoNew 2022-07-27 14:43:01 -05:00
Anthony Wang c94a891aad
Process Like activities for starring repos 2022-07-27 14:18:30 -05:00
Anthony Wang 8e5621c9c3
Merge remote-tracking branch 'upstream/main' 2022-07-27 10:29:54 -05:00
Anthony Wang d909c97da9
Move models/forgefed to modules/forgefed 2022-07-27 10:25:40 -05:00
Anthony Wang f0cded88bf
Start cleaning up fork.go 2022-07-27 10:24:04 -05:00
Anthony Wang 38a687c60e
Replace GetID() with GetLink()
See https://lists.sr.ht/~mariusor/activitypub-go/%3CNXRUnlucUSX8FL9I57dimPx4dpMKz01JDjKXqeHC8V9Z7pSTnjoZyV8ukearYJOq4IDogmpDLoEK-ScPDKs_egPnFGcAAO4XqHbj2rTUm-E%3D%40proton.me%3E for more details
2022-07-25 15:45:15 -05:00
Anthony Wang 30b431da49
Set ap.ItemTyperFunc to correctly unmarshal JSON 2022-07-25 15:43:20 -05:00
Anthony Wang bffb682117
Fix a bunch of lint errors (still 10 more to fix 🙁) 2022-07-23 22:12:09 -05:00
Anthony Wang ab540d07be
Create new federated users in reqsignature.go 2022-07-23 21:27:20 -05:00
Anthony Wang 5da6b4fd84
Disable authorize_endpoint if federation is disabled 2022-07-23 20:52:12 -05:00
Anthony Wang 763f98b517
Create user at /authorize_interaction?uri= endpoint 2022-07-23 16:34:06 -05:00
Anthony Wang 85abd9cfe0
Fix typo in follow.go 2022-07-21 20:07:17 -05:00
Anthony Wang 5196dcd9a5
Check if httpsig keyID matches actor and attributedTo 2022-07-20 18:57:19 -05:00
Anthony Wang c8a8e1ec91
Check err in Follow() to avoid crash and don't check FederatedUserNew error 2022-07-20 18:16:49 -05:00
Anthony Wang 6e100301cf
Merge remote-tracking branch 'upstream/main' 2022-07-20 14:45:19 -05:00
Anthony Wang 0925235a96
Fix federated following/unfollowing regression 2022-07-20 14:42:39 -05:00
Anthony Wang c100b8e1e0
Apply suggestions from code review 2022-07-17 11:19:48 -05:00
Anthony Wang 08cb2d6d34
Fix typos in FEDERATION.md 2022-07-17 11:09:10 -05:00
Anthony Wang 48deb8e1f5
Fix repo AP outbox path typo 2022-07-16 21:10:28 -05:00
Anthony Wang f1577c2f62
Merge remote-tracking branch 'upstream/main' 2022-07-16 20:35:16 -05:00
Anthony Wang 705706bc00
Generate person outbox for only repo creates and stars 2022-07-16 20:33:28 -05:00
Anthony Wang b491a2ec34
Update FEDERATION.md with a more accurate description of federated issues and PRs 2022-07-16 20:32:41 -05:00
Anthony Wang 1b4cd987b2
Merge remote-tracking branch 'upstream/main' 2022-07-13 22:36:00 -05:00
Anthony Wang 0609d7175c
Add more TODO notes 2022-07-13 22:11:24 -05:00
Anthony Wang 56717396fd
Big refactor: Improve inbox handling logic, move some IRI stuff to iri.go 2022-07-13 22:10:03 -05:00
Anthony Wang a63b2be21b
Merge remote-tracking branch 'upstream/main' 2022-07-13 12:14:35 -05:00
Anthony Wang 63aa270a2e
Initial implementation of federated pull requests 2022-07-13 12:14:14 -05:00
Anthony Wang 1b39e39fc1
Add basic implementation of federated commenting 2022-07-11 18:24:15 -05:00
Anthony Wang 79a59bd75b
Use a replace in go.mod to point to Ta180m/activitypub fork instead of modifying the include everywhere 2022-07-11 17:27:57 -05:00
Anthony Wang d016dbbe70
Switch to using gitea.com/Ta180m/activitypub fork of the go-ap/activitypub module since we need more Write and Load functions exported 2022-07-11 12:45:00 -05:00
Anthony Wang 8b354febf5
Merge remote-tracking branch 'upstream/main' 2022-07-10 11:49:22 -05:00
Anthony Wang 18b4cd32f3
Don't track go.work files 2022-07-07 13:14:01 -05:00
Anthony Wang fa72294f64
Fix build errors and start working on constructing outbox activities for the various action types 2022-07-07 13:11:59 -05:00
Anthony Wang 721b734049
Merge remote-tracking branch 'upstream/main' 2022-07-07 12:58:51 -05:00
Anthony Wang 786ee03f57
Merge remote-tracking branch 'upstream/main' 2022-06-25 21:41:59 -05:00
Anthony Wang e348477c59
Add some additional info about migrations to FEDERATION.md 2022-06-25 21:37:50 -05:00
dachary 30a703c8ef Fix minor wording issue 2022-06-23 04:59:07 +08:00
Anthony Wang 24a462a95d
Delete the old example from FEDERATION.md 2022-06-21 11:34:16 -05:00
Anthony Wang e090c95c17
Write a FEDERATION.md describing future Gitea federation features 2022-06-21 11:34:01 -05:00
Anthony Wang a7f32d3382
Finish initial ForgeFed implementation 2022-06-20 15:38:57 -05:00
Anthony Wang d12fd434ba
Add Person and Repository ActivityPub endpoints 2022-06-19 10:39:22 -05:00
53 changed files with 2893 additions and 82 deletions

47
FEDERATION.md Normal file
View File

@ -0,0 +1,47 @@
# Federation
*This describes Gitea's future federation capabilities, not what it can do currently.*
Gitea is federated using [ActivityPub](https://www.w3.org/TR/activitypub/) and the [ForgeFed extension](https://forgefed.org/) so you can interact with users and repositories from other instances as if they were on your own instance. By using the standardized ActivityPub protocol, users on any fediverse software such as [Mastodon](https://joinmastodon.org/) can follow Gitea users, star repositories, receive activity updates, and comment on issues.
C2S ActivityPub is not supported because Gitea already has an existing API.
## Following
You can use any fediverse software to follow a Gitea user. Gitea will automatically accept follow requests. The usernames of remote users are displayed as `username@instance.com`. To follow a remote user, click follow on their profile page, and a pop-up box will appear for you to type in your instance. You are redirected to your own instance, where the remote user is fetched and rendered, and you can now follow them.
When following a Gitea user, you will receive updates when they star a repo, create, fork, or make a private repo public, or follow a user. If you are using Mastodon or Pleroma, these will show up in your feed.
## Starring
You can star repositories on another instance. The full name of a remote repository is `username@instance.com/reponame`. Similar to following, a pop-up box appears for you to type in your instance, and you are redirected to your own instance, where the remote repository is fetched and rendered.
## Organizations
You can add users from other instances to organizations. An organization has a name and an instance, so its full name would look like `orgname@instance.com`. This indicates that the organization data resides on `instance.com`. To prevent synchronization errors, this data is only synchronized one-way to other instances.
## Collaborators
You can add users from other instances as collaborators. As mentioned previously, a repository has full name `username@instance.com/reponame`, which indicates that the repository data resides on `instance.com`. Each collaborator's instance has a copy of the repository, but to prevent synchronization errors, the copy at `instance.com` is the main copy and it is synchronized one-way to all other instances. When a collaborator tries to modify their copy of the repository, the modification is first sent to the main copy at `instance.com` and then synchronized back to their instance.
## Issues
You can create an issue on a remote repository. Your instance can also render a remote issue that you created so you can edit it or comment on it.
## Forks
When forking a remote repository, the fork is created on your instance, not the remote instance.
## Pull requests
When opening a pull request to a remote repository, the pull request can be rendered on your instance. Federated pull requests use the AGit-flow.
## Comments
You can comment on an issue or pull request using any fediverse software. The issue and existing comments are rendered on your instance.
## Migrations
If you change your username or the name of a repository, Gitea handles this similarly to how Mastodon does. Gitea will send a `Move` activity to your followers and update your actor to point to the new actor and the new actor to point to the old actor.
Changing your instance or a repository's instance is handled in a similar way, but additionally, the data to be migrated between instances.

View File

@ -2308,7 +2308,7 @@ ROUTER = console
;SHARE_USER_STATISTICS = true
;;
;; Maximum federation request and response size (MB)
;MAX_SIZE = 4
;MAX_SIZE = 8
;;
;; WARNING: Changing the settings below can break federation.
;;

8
go.mod
View File

@ -32,7 +32,7 @@ require (
github.com/fsnotify/fsnotify v1.5.4
github.com/gliderlabs/ssh v0.3.5
github.com/go-ap/activitypub v0.0.0-20220917143152-e4e7018838c0
github.com/go-ap/jsonld v0.0.0-20220917142617-76bf51585778
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73
github.com/go-chi/chi/v5 v5.0.7
github.com/go-chi/cors v1.2.1
github.com/go-enry/go-enry/v2 v2.8.3
@ -87,6 +87,7 @@ require (
github.com/tstranex/u2f v1.0.0
github.com/unrolled/render v1.5.0
github.com/urfave/cli v1.22.10
github.com/valyala/fastjson v1.6.3
github.com/xanzy/go-gitlab v0.73.1
github.com/yohcop/openid-go v1.0.0
github.com/yuin/goldmark v1.5.2
@ -168,7 +169,7 @@ require (
github.com/form3tech-oss/jwt-go v3.2.3+incompatible // indirect
github.com/fullstorydev/grpcurl v1.8.1 // indirect
github.com/fxamacker/cbor/v2 v2.4.0 // indirect
github.com/go-ap/errors v0.0.0-20220917143055-4283ea5dae18 // indirect
github.com/go-ap/errors v0.0.0-20221115052505-8aaa26f930b4 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
github.com/go-enry/go-oniguruma v1.2.1 // indirect
github.com/go-git/gcfg v1.5.0 // indirect
@ -264,7 +265,6 @@ require (
github.com/toqueteos/webbrowser v1.2.0 // indirect
github.com/ulikunitz/xz v0.5.10 // indirect
github.com/unknwon/com v1.0.1 // indirect
github.com/valyala/fastjson v1.6.3 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xanzy/ssh-agent v0.3.2 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
@ -302,6 +302,8 @@ replace github.com/shurcooL/vfsgen => github.com/lunny/vfsgen v0.0.0-20220105142
replace github.com/satori/go.uuid v1.2.0 => github.com/gofrs/uuid v4.2.0+incompatible
replace github.com/go-ap/activitypub => gitea.com/xy/activitypub v0.0.0-20221126171442-81405e14ea3b
exclude github.com/gofrs/uuid v3.2.0+incompatible
exclude github.com/gofrs/uuid v4.0.0+incompatible

12
go.sum
View File

@ -96,6 +96,8 @@ gitea.com/lunny/levelqueue v0.4.2-0.20220729054728-f020868cc2f7 h1:Zc3RQWC2xOVgl
gitea.com/lunny/levelqueue v0.4.2-0.20220729054728-f020868cc2f7/go.mod h1:HBqmLbz56JWpfEGG0prskAV97ATNRoj5LDmPicD22hU=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
gitea.com/xy/activitypub v0.0.0-20221126171442-81405e14ea3b h1:z5zmwZVoKEu2c3+lGiLlTDxQZpcKlZoWz4wjCtcyfxU=
gitea.com/xy/activitypub v0.0.0-20221126171442-81405e14ea3b/go.mod h1:1jG7QyKCGx/FO63p/xWO0h9ytVSJmkjcQSYPj6zWpGs=
gitee.com/travelliu/dm v1.8.11192/go.mod h1:DHTzyhCrM843x9VdKVbZ+GKXGRbKM2sJ4LxihRxShkE=
github.com/42wim/sshsig v0.0.0-20211121163825-841cf5bbc121 h1:r3qt8PCHnfjOv9PN3H+XXKmDA1dfFMIN1AislhlA/ps=
github.com/42wim/sshsig v0.0.0-20211121163825-841cf5bbc121/go.mod h1:Ock8XgA7pvULhIaHGAk/cDnRfNrF9Jey81nPcc403iU=
@ -471,12 +473,10 @@ github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/go-ap/activitypub v0.0.0-20220917143152-e4e7018838c0 h1:EUMB0x7u3de/ikGBtXiLxaJbmxgiqiAcM4yjW4whApM=
github.com/go-ap/activitypub v0.0.0-20220917143152-e4e7018838c0/go.mod h1:OX9ajs2vU4UauC/DlghS/8M468Kn79r+y9kB6j7LuGM=
github.com/go-ap/errors v0.0.0-20220917143055-4283ea5dae18 h1:A48SbkWKEciiJMbbcPzaRj9aizPUABzXFvCM3LtGGf8=
github.com/go-ap/errors v0.0.0-20220917143055-4283ea5dae18/go.mod h1:dd3ZgjjloBsKPDpqA2kf2VWhF0A1eKUItOBh0/QcDWI=
github.com/go-ap/jsonld v0.0.0-20220917142617-76bf51585778 h1:0tV3i8tE1NghMC4rXZXfD39KUbkKgIyLTsvOEmMOPCQ=
github.com/go-ap/jsonld v0.0.0-20220917142617-76bf51585778/go.mod h1:jyveZeGw5LaADntW+UEsMjl3IlIwk+DxlYNsbofQkGA=
github.com/go-ap/errors v0.0.0-20221115052505-8aaa26f930b4 h1:oySiT87Q2cd0o5O8er2zyjiRcTQA0KuOgw1N9+RQqG0=
github.com/go-ap/errors v0.0.0-20221115052505-8aaa26f930b4/go.mod h1:SaTNjEEkp0q+w3pUS1ccyEL/lUrHteORlDq/e21mCc8=
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 h1:GMKIYXyXPGIp+hYiWOhfqK4A023HdgisDT4YGgf99mw=
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73/go.mod h1:jyveZeGw5LaADntW+UEsMjl3IlIwk+DxlYNsbofQkGA=
github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A=
github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.0.1/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=

View File

@ -332,6 +332,7 @@ type GetFeedsOptions struct {
RequestedUser *user_model.User // the user we want activity for
RequestedTeam *organization.Team // the team we want activity for
RequestedRepo *repo_model.Repository // the repo we want activity for
RequestedActionType ActionType // the type of activity we want
Actor *user_model.User // the user viewing the activity
IncludePrivate bool // include private actions
OnlyPerformedBy bool // only actions performed by requested user
@ -448,6 +449,10 @@ func activityQueryCondition(opts GetFeedsOptions) (builder.Cond, error) {
}
}
if opts.RequestedActionType != 0 {
cond = cond.And(builder.Eq{"`action`.op_type": opts.RequestedActionType})
}
return cond, nil
}

View File

@ -10,7 +10,7 @@ import (
activities_model "code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/models/db"
issue_model "code.gitea.io/gitea/models/issues"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
@ -31,7 +31,7 @@ func TestAction_GetRepoLink(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
comment := unittest.AssertExistsAndLoadBean(t, &issue_model.Comment{ID: 2})
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2})
action := &activities_model.Action{RepoID: repo.ID, CommentID: comment.ID}
setting.AppSubURL = "/suburl"
expected := path.Join(setting.AppSubURL, owner.Name, repo.Name)

View File

@ -31,6 +31,7 @@ const (
DLDAP // 5
OAuth2 // 6
SSPI // 7
Federated // 8
)
// String returns the string name of the LoginType
@ -179,6 +180,11 @@ func (source *Source) IsSSPI() bool {
return source.Type == SSPI
}
// IsFederated returns true of this source is of the Federated type.
func (source *Source) IsFederated() bool {
return source.Type == Federated
}
// HasTLS returns true of this source supports TLS.
func (source *Source) HasTLS() bool {
hasTLSer, ok := source.Cfg.(HasTLSer)

View File

@ -26,6 +26,7 @@ import (
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/references"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
@ -1549,3 +1550,18 @@ func FixCommentTypeLabelWithOutsideLabels() (int64, error) {
return res.RowsAffected()
}
func (c *Comment) GetIRI() string {
err := c.LoadIssue()
if err != nil {
return ""
}
err = c.Issue.LoadRepo(db.DefaultContext)
if err != nil {
return ""
}
if strings.Contains(c.Issue.Repo.OwnerName, "@") {
return c.OldTitle
}
return setting.AppURL + "api/v1/activitypub/note/" + c.Issue.Repo.OwnerName + "/" + c.Issue.Repo.Name + "/" + strconv.FormatInt(c.Issue.Index, 10) + "/" + strconv.FormatInt(c.ID, 10)
}

View File

@ -27,6 +27,7 @@ import (
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/references"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
@ -2471,3 +2472,14 @@ func DeleteOrphanedIssues() error {
}
return nil
}
func (issue *Issue) GetIRI() string {
err := issue.LoadRepo(db.DefaultContext)
if err != nil {
log.Error(fmt.Sprintf("loadRepo: %v", err))
}
if strings.Contains(issue.Repo.OwnerName, "@") {
return issue.OriginalAuthor
}
return setting.AppURL + "api/v1/activitypub/ticket/" + issue.Repo.OwnerName + "/" + issue.Repo.Name + "/" + strconv.FormatInt(issue.Index, 10)
}

View File

@ -802,3 +802,10 @@ func FixNullArchivedRepository() (int64, error) {
IsArchived: false,
})
}
func (r *Repository) GetIRI() string {
if strings.Contains(r.OwnerName, "@") {
return r.OriginalURL
}
return setting.AppURL + "api/v1/activitypub/repo/" + r.OwnerName + "/" + r.Name
}

View File

@ -105,6 +105,15 @@ func (u *User) AvatarLink() string {
return link
}
// AvatarFullLinkWithSize returns the full avatar link with size and http host
func (u *User) AvatarFullLinkWithSize(size int) string {
link := u.AvatarLinkWithSize(size)
if !strings.HasPrefix(link, "//") && !strings.Contains(link, "://") {
return setting.AppURL + strings.TrimPrefix(link, setting.AppSubURL+"/")
}
return link
}
// IsUploadAvatarChanged returns true if the current user's avatar would be changed with the provided data
func (u *User) IsUploadAvatarChanged(data []byte) bool {
if !u.UseCustomAvatar || len(u.Avatar) == 0 {

View File

@ -1339,3 +1339,10 @@ func GetOrderByName() string {
}
return "name"
}
func (u *User) GetIRI() string {
if u.LoginType == auth.Federated {
return u.LoginName
}
return setting.AppURL + "api/v1/activitypub/user/" + u.Name
}

View File

@ -0,0 +1,99 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package forgefed
import (
"reflect"
"unsafe"
ap "github.com/go-ap/activitypub"
"github.com/valyala/fastjson"
)
const (
BranchType ap.ActivityVocabularyType = "Branch"
)
type Branch struct {
ap.Object
// Ref the unique identifier of the branch within the repo
Ref ap.Item `jsonld:"ref,omitempty"`
}
// BranchNew initializes a Branch type Object
func BranchNew() *Branch {
a := ap.ObjectNew(BranchType)
o := Branch{Object: *a}
return &o
}
func (b Branch) MarshalJSON() ([]byte, error) {
bin, err := b.Object.MarshalJSON()
if len(bin) == 0 || err != nil {
return nil, err
}
bin = bin[:len(bin)-1]
if b.Ref != nil {
ap.JSONWriteItemJSONProp(&bin, "ref", b.Ref)
}
ap.JSONWrite(&bin, '}')
return bin, nil
}
func JSONLoadBranch(val *fastjson.Value, b *Branch) error {
if err := ap.OnObject(&b.Object, func(o *ap.Object) error {
return ap.JSONLoadObject(val, o)
}); err != nil {
return err
}
b.Ref = ap.JSONGetItem(val, "ref")
return nil
}
func (b *Branch) UnmarshalJSON(data []byte) error {
p := fastjson.Parser{}
val, err := p.ParseBytes(data)
if err != nil {
return err
}
return JSONLoadBranch(val, b)
}
// ToBranch tries to convert the it Item to a Branch object.
func ToBranch(it ap.Item) (*Branch, error) {
switch i := it.(type) {
case *Branch:
return i, nil
case Branch:
return &i, nil
case *ap.Object:
return (*Branch)(unsafe.Pointer(i)), nil
case ap.Object:
return (*Branch)(unsafe.Pointer(&i)), nil
default:
// NOTE(marius): this is an ugly way of dealing with the interface conversion error: types from different scopes
typ := reflect.TypeOf(new(Branch))
if i, ok := reflect.ValueOf(it).Convert(typ).Interface().(*Branch); ok {
return i, nil
}
}
return nil, ap.ErrorInvalidType[ap.Object](it)
}
type withBranchFn func(*Branch) error
// OnBranch calls function fn on it Item if it can be asserted to type *Branch
func OnBranch(it ap.Item, fn withBranchFn) error {
if it == nil {
return nil
}
ob, err := ToBranch(it)
if err != nil {
return err
}
return fn(ob)
}

106
modules/forgefed/commit.go Normal file
View File

@ -0,0 +1,106 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package forgefed
import (
"reflect"
"time"
"unsafe"
ap "github.com/go-ap/activitypub"
"github.com/valyala/fastjson"
)
const (
CommitType ap.ActivityVocabularyType = "Commit"
)
type Commit struct {
ap.Object
// Created time at which the commit was written by its author
Created time.Time `jsonld:"created,omitempty"`
// Committed time at which the commit was committed by its committer
Committed time.Time `jsonld:"committed,omitempty"`
}
// CommitNew initializes a Commit type Object
func CommitNew() *Commit {
a := ap.ObjectNew(CommitType)
o := Commit{Object: *a}
return &o
}
func (c Commit) MarshalJSON() ([]byte, error) {
b, err := c.Object.MarshalJSON()
if len(b) == 0 || err != nil {
return nil, err
}
b = b[:len(b)-1]
if !c.Created.IsZero() {
ap.JSONWriteTimeJSONProp(&b, "created", c.Created)
}
if !c.Committed.IsZero() {
ap.JSONWriteTimeJSONProp(&b, "committed", c.Committed)
}
ap.JSONWrite(&b, '}')
return b, nil
}
func JSONLoadCommit(val *fastjson.Value, c *Commit) error {
if err := ap.OnObject(&c.Object, func(o *ap.Object) error {
return ap.JSONLoadObject(val, o)
}); err != nil {
return err
}
c.Created = ap.JSONGetTime(val, "created")
c.Committed = ap.JSONGetTime(val, "committed")
return nil
}
func (c *Commit) UnmarshalJSON(data []byte) error {
p := fastjson.Parser{}
val, err := p.ParseBytes(data)
if err != nil {
return err
}
return JSONLoadCommit(val, c)
}
// ToCommit tries to convert the it Item to a Commit object.
func ToCommit(it ap.Item) (*Commit, error) {
switch i := it.(type) {
case *Commit:
return i, nil
case Commit:
return &i, nil
case *ap.Object:
return (*Commit)(unsafe.Pointer(i)), nil
case ap.Object:
return (*Commit)(unsafe.Pointer(&i)), nil
default:
// NOTE(marius): this is an ugly way of dealing with the interface conversion error: types from different scopes
typ := reflect.TypeOf(new(Commit))
if i, ok := reflect.ValueOf(it).Convert(typ).Interface().(*Commit); ok {
return i, nil
}
}
return nil, ap.ErrorInvalidType[ap.Object](it)
}
type withCommitFn func(*Commit) error
// OnCommit calls function fn on it Item if it can be asserted to type *Commit
func OnCommit(it ap.Item, fn withCommitFn) error {
if it == nil {
return nil
}
ob, err := ToCommit(it)
if err != nil {
return err
}
return fn(ob)
}

View File

@ -0,0 +1,98 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package forgefed
import (
ap "github.com/go-ap/activitypub"
"github.com/valyala/fastjson"
)
const ForgeFedNamespaceURI = "https://forgefed.org/ns"
// GetItemByType instantiates a new ForgeFed object if the type matches
// otherwise it defaults to existing activitypub package typer function.
func GetItemByType(typ ap.ActivityVocabularyType) (ap.Item, error) {
switch typ {
case CommitType:
return CommitNew(), nil
case BranchType:
return BranchNew(), nil
case RepositoryType:
return RepositoryNew(""), nil
case PushType:
return PushNew(), nil
case TicketType:
return TicketNew(), nil
}
return ap.GetItemByType(typ)
}
// JSONUnmarshalerFn is the function that will load the data from a fastjson.Value into an Item
// that the go-ap/activitypub package doesn't know about.
func JSONUnmarshalerFn(typ ap.ActivityVocabularyType, val *fastjson.Value, i ap.Item) error {
switch typ {
case CommitType:
return OnCommit(i, func(c *Commit) error {
return JSONLoadCommit(val, c)
})
case BranchType:
return OnBranch(i, func(b *Branch) error {
return JSONLoadBranch(val, b)
})
case RepositoryType:
return OnRepository(i, func(r *Repository) error {
return JSONLoadRepository(val, r)
})
case PushType:
return OnPush(i, func(p *Push) error {
return JSONLoadPush(val, p)
})
case TicketType:
return OnTicket(i, func(t *Ticket) error {
return JSONLoadTicket(val, t)
})
}
return nil
}
// NotEmpty is the function that checks if an object is empty
func NotEmpty(i ap.Item) bool {
if ap.IsNil(i) {
return false
}
switch i.GetType() {
case CommitType:
c, err := ToCommit(i)
if err != nil {
return false
}
return ap.NotEmpty(c.Object)
case BranchType:
b, err := ToBranch(i)
if err != nil {
return false
}
return ap.NotEmpty(b.Object)
case RepositoryType:
r, err := ToRepository(i)
if err != nil {
return false
}
return ap.NotEmpty(r.Actor)
case PushType:
p, err := ToPush(i)
if err != nil {
return false
}
return ap.NotEmpty(p.Object)
case TicketType:
t, err := ToTicket(i)
if err != nil {
return false
}
return ap.NotEmpty(t.Object)
}
return ap.NotEmpty(i)
}

111
modules/forgefed/push.go Normal file
View File

@ -0,0 +1,111 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package forgefed
import (
"reflect"
"unsafe"
ap "github.com/go-ap/activitypub"
"github.com/valyala/fastjson"
)
const (
PushType ap.ActivityVocabularyType = "Push"
)
type Push struct {
ap.Object
// Target the specific repo history tip onto which the commits were added
Target ap.Item `jsonld:"target,omitempty"`
// HashBefore hash before adding the new commits
HashBefore ap.Item `jsonld:"hashBefore,omitempty"`
// HashAfter hash before adding the new commits
HashAfter ap.Item `jsonld:"hashAfter,omitempty"`
}
// PushNew initializes a Push type Object
func PushNew() *Push {
a := ap.ObjectNew(PushType)
o := Push{Object: *a}
return &o
}
func (p Push) MarshalJSON() ([]byte, error) {
b, err := p.Object.MarshalJSON()
if len(b) == 0 || err != nil {
return nil, err
}
b = b[:len(b)-1]
if p.Target != nil {
ap.JSONWriteItemJSONProp(&b, "target", p.Target)
}
if p.HashBefore != nil {
ap.JSONWriteItemJSONProp(&b, "hashBefore", p.HashBefore)
}
if p.HashAfter != nil {
ap.JSONWriteItemJSONProp(&b, "hashAfter", p.HashAfter)
}
ap.JSONWrite(&b, '}')
return b, nil
}
func JSONLoadPush(val *fastjson.Value, p *Push) error {
if err := ap.OnObject(&p.Object, func(o *ap.Object) error {
return ap.JSONLoadObject(val, o)
}); err != nil {
return err
}
p.Target = ap.JSONGetItem(val, "target")
p.HashBefore = ap.JSONGetItem(val, "hashBefore")
p.HashAfter = ap.JSONGetItem(val, "hashAfter")
return nil
}
func (p *Push) UnmarshalJSON(data []byte) error {
par := fastjson.Parser{}
val, err := par.ParseBytes(data)
if err != nil {
return err
}
return JSONLoadPush(val, p)
}
// ToPush tries to convert the it Item to a Push object.
func ToPush(it ap.Item) (*Push, error) {
switch i := it.(type) {
case *Push:
return i, nil
case Push:
return &i, nil
case *ap.Object:
return (*Push)(unsafe.Pointer(i)), nil
case ap.Object:
return (*Push)(unsafe.Pointer(&i)), nil
default:
// NOTE(marius): this is an ugly way of dealing with the interface conversion error: types from different scopes
typ := reflect.TypeOf(new(Push))
if i, ok := reflect.ValueOf(it).Convert(typ).Interface().(*Push); ok {
return i, nil
}
}
return nil, ap.ErrorInvalidType[ap.Object](it)
}
type withPushFn func(*Push) error
// OnPush calls function fn on it Item if it can be asserted to type *Push
func OnPush(it ap.Item, fn withPushFn) error {
if it == nil {
return nil
}
ob, err := ToPush(it)
if err != nil {
return err
}
return fn(ob)
}

View File

@ -0,0 +1,112 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package forgefed
import (
"reflect"
"unsafe"
ap "github.com/go-ap/activitypub"
"github.com/valyala/fastjson"
)
const (
RepositoryType ap.ActivityVocabularyType = "Repository"
)
type Repository struct {
ap.Actor
// Team Collection of actors who have management/push access to the repository
Team ap.Item `jsonld:"team,omitempty"`
// Forks OrderedCollection of repositories that are forks of this repository
Forks ap.Item `jsonld:"forks,omitempty"`
// ForkedFrom Identifies the repository which this repository was created as a fork
ForkedFrom ap.Item `jsonld:"forkedFrom,omitempty"`
}
// RepositoryNew initializes a Repository type actor
func RepositoryNew(id ap.ID) *Repository {
a := ap.ActorNew(id, RepositoryType)
a.Type = RepositoryType
o := Repository{Actor: *a}
return &o
}
func (r Repository) MarshalJSON() ([]byte, error) {
b, err := r.Actor.MarshalJSON()
if len(b) == 0 || err != nil {
return nil, err
}
b = b[:len(b)-1]
if r.Team != nil {
ap.JSONWriteItemJSONProp(&b, "team", r.Team)
}
if r.Forks != nil {
ap.JSONWriteItemJSONProp(&b, "forks", r.Forks)
}
if r.ForkedFrom != nil {
ap.JSONWriteItemJSONProp(&b, "forkedFrom", r.ForkedFrom)
}
ap.JSONWrite(&b, '}')
return b, nil
}
func JSONLoadRepository(val *fastjson.Value, r *Repository) error {
if err := ap.OnActor(&r.Actor, func(a *ap.Actor) error {
return ap.JSONLoadActor(val, a)
}); err != nil {
return err
}
r.Team = ap.JSONGetItem(val, "team")
r.Forks = ap.JSONGetItem(val, "forks")
r.ForkedFrom = ap.JSONGetItem(val, "forkedFrom")
return nil
}
func (r *Repository) UnmarshalJSON(data []byte) error {
p := fastjson.Parser{}
val, err := p.ParseBytes(data)
if err != nil {
return err
}
return JSONLoadRepository(val, r)
}
// ToRepository tries to convert the it Item to a Repository Actor.
func ToRepository(it ap.Item) (*Repository, error) {
switch i := it.(type) {
case *Repository:
return i, nil
case Repository:
return &i, nil
case *ap.Actor:
return (*Repository)(unsafe.Pointer(i)), nil
case ap.Actor:
return (*Repository)(unsafe.Pointer(&i)), nil
default:
// NOTE(marius): this is an ugly way of dealing with the interface conversion error: types from different scopes
typ := reflect.TypeOf(new(Repository))
if i, ok := reflect.ValueOf(it).Convert(typ).Interface().(*Repository); ok {
return i, nil
}
}
return nil, ap.ErrorInvalidType[ap.Actor](it)
}
type withRepositoryFn func(*Repository) error
// OnRepository calls function fn on it Item if it can be asserted to type *Repository
func OnRepository(it ap.Item, fn withRepositoryFn) error {
if it == nil {
return nil
}
ob, err := ToRepository(it)
if err != nil {
return err
}
return fn(ob)
}

View File

@ -0,0 +1,184 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package forgefed
import (
"fmt"
"reflect"
"testing"
"code.gitea.io/gitea/modules/json"
ap "github.com/go-ap/activitypub"
)
func Test_GetItemByType(t *testing.T) {
type testtt struct {
typ ap.ActivityVocabularyType
want ap.Item
wantErr error
}
tests := map[string]testtt{
"invalid type": {
typ: ap.ActivityVocabularyType("invalidtype"),
wantErr: fmt.Errorf("empty ActivityStreams type"), // TODO(marius): this error message needs to be improved in go-ap/activitypub
},
"Repository": {
typ: RepositoryType,
want: new(Repository),
},
"Person - fall back": {
typ: ap.PersonType,
want: new(ap.Person),
},
"Question - fall back": {
typ: ap.QuestionType,
want: new(ap.Question),
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
maybeRepository, err := GetItemByType(tt.typ)
if !reflect.DeepEqual(tt.wantErr, err) {
t.Errorf("GetItemByType() error = \"%+v\", wantErr = \"%+v\" when getting Item for type %q", tt.wantErr, err, tt.typ)
}
if reflect.TypeOf(tt.want) != reflect.TypeOf(maybeRepository) {
t.Errorf("Invalid type received %T, expected %T", maybeRepository, tt.want)
}
})
}
}
func Test_RepositoryMarshalJSON(t *testing.T) {
type testPair struct {
item Repository
want []byte
wantErr error
}
tests := map[string]testPair{
"empty": {
item: Repository{},
want: nil,
},
"with ID": {
item: Repository{
Actor: ap.Actor{
ID: "https://example.com/1",
},
Team: nil,
},
want: []byte(`{"id":"https://example.com/1"}`),
},
"with Team as IRI": {
item: Repository{
Team: ap.IRI("https://example.com/1"),
Actor: ap.Actor{
ID: "https://example.com/1",
},
},
want: []byte(`{"id":"https://example.com/1","team":"https://example.com/1"}`),
},
"with Team as IRIs": {
item: Repository{
Team: ap.ItemCollection{
ap.IRI("https://example.com/1"),
ap.IRI("https://example.com/2"),
},
Actor: ap.Actor{
ID: "https://example.com/1",
},
},
want: []byte(`{"id":"https://example.com/1","team":["https://example.com/1","https://example.com/2"]}`),
},
"with Team as Object": {
item: Repository{
Team: ap.Object{ID: "https://example.com/1"},
Actor: ap.Actor{
ID: "https://example.com/1",
},
},
want: []byte(`{"id":"https://example.com/1","team":{"id":"https://example.com/1"}}`),
},
"with Team as slice of Objects": {
item: Repository{
Team: ap.ItemCollection{
ap.Object{ID: "https://example.com/1"},
ap.Object{ID: "https://example.com/2"},
},
Actor: ap.Actor{
ID: "https://example.com/1",
},
},
want: []byte(`{"id":"https://example.com/1","team":[{"id":"https://example.com/1"},{"id":"https://example.com/2"}]}`),
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
got, err := tt.item.MarshalJSON()
if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() {
t.Errorf("MarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("MarshalJSON() got = %q, want %q", got, tt.want)
}
})
}
}
func Test_RepositoryUnmarshalJSON(t *testing.T) {
type testPair struct {
data []byte
want *Repository
wantErr error
}
tests := map[string]testPair{
"nil": {
data: nil,
wantErr: fmt.Errorf("cannot parse JSON: %w", fmt.Errorf("cannot parse empty string; unparsed tail: %q", "")),
},
"empty": {
data: []byte{},
wantErr: fmt.Errorf("cannot parse JSON: %w", fmt.Errorf("cannot parse empty string; unparsed tail: %q", "")),
},
"with Type": {
data: []byte(`{"type":"Repository"}`),
want: &Repository{
Actor: ap.Actor{
Type: RepositoryType,
},
},
},
"with Type and ID": {
data: []byte(`{"id":"https://example.com/1","type":"Repository"}`),
want: &Repository{
Actor: ap.Actor{
ID: "https://example.com/1",
Type: RepositoryType,
},
},
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
got := new(Repository)
err := got.UnmarshalJSON(tt.data)
if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() {
t.Errorf("UnmarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr)
return
}
if tt.want != nil && !reflect.DeepEqual(got, tt.want) {
jGot, _ := json.Marshal(got)
jWant, _ := json.Marshal(tt.want)
t.Errorf("UnmarshalJSON() got = %s, want %s", jGot, jWant)
}
})
}
}

134
modules/forgefed/ticket.go Normal file
View File

@ -0,0 +1,134 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package forgefed
import (
"reflect"
"time"
"unsafe"
ap "github.com/go-ap/activitypub"
"github.com/valyala/fastjson"
)
const (
TicketType ap.ActivityVocabularyType = "Ticket"
)
type Ticket struct {
ap.Object
// Dependants Collection of Tickets which depend on this ticket
Dependants ap.ItemCollection `jsonld:"dependants,omitempty"`
// Dependencies Collection of Tickets on which this ticket depends
Dependencies ap.ItemCollection `jsonld:"dependencies,omitempty"`
// IsResolved Whether the work on this ticket is done
IsResolved bool `jsonld:"isResolved,omitempty"`
// ResolvedBy If the work on this ticket is done, who marked the ticket as resolved, or which activity did so
ResolvedBy ap.Item `jsonld:"resolvedBy,omitempty"`
// Resolved When the ticket has been marked as resolved
Resolved time.Time `jsonld:"resolved,omitempty"`
// Origin The head branch if this ticket is a pull request
Origin ap.Item `jsonld:"origin,omitempty"`
// Target The base branch if this ticket is a pull request
Target ap.Item `jsonld:"target,omitempty"`
}
// TicketNew initializes a Ticket type Object
func TicketNew() *Ticket {
a := ap.ObjectNew(TicketType)
o := Ticket{Object: *a}
return &o
}
func (t Ticket) MarshalJSON() ([]byte, error) {
b, err := t.Object.MarshalJSON()
if len(b) == 0 || err != nil {
return nil, err
}
b = b[:len(b)-1]
if t.Dependants != nil {
ap.JSONWriteItemCollectionJSONProp(&b, "dependants", t.Dependants)
}
if t.Dependencies != nil {
ap.JSONWriteItemCollectionJSONProp(&b, "dependencies", t.Dependencies)
}
ap.JSONWriteBoolJSONProp(&b, "isResolved", t.IsResolved)
if t.ResolvedBy != nil {
ap.JSONWriteItemJSONProp(&b, "resolvedBy", t.ResolvedBy)
}
if !t.Resolved.IsZero() {
ap.JSONWriteTimeJSONProp(&b, "resolved", t.Resolved)
}
if t.Origin != nil {
ap.JSONWriteItemJSONProp(&b, "origin", t.Origin)
}
if t.Target != nil {
ap.JSONWriteItemJSONProp(&b, "target", t.Target)
}
ap.JSONWrite(&b, '}')
return b, nil
}
func JSONLoadTicket(val *fastjson.Value, t *Ticket) error {
if err := ap.OnObject(&t.Object, func(o *ap.Object) error {
return ap.JSONLoadObject(val, o)
}); err != nil {
return err
}
t.Dependants = ap.JSONGetItems(val, "dependants")
t.Dependencies = ap.JSONGetItems(val, "dependencies")
t.IsResolved = ap.JSONGetBoolean(val, "isResolved")
t.ResolvedBy = ap.JSONGetItem(val, "resolvedBy")
t.Resolved = ap.JSONGetTime(val, "resolved")
t.Origin = ap.JSONGetItem(val, "origin")
t.Target = ap.JSONGetItem(val, "target")
return nil
}
func (t *Ticket) UnmarshalJSON(data []byte) error {
p := fastjson.Parser{}
val, err := p.ParseBytes(data)
if err != nil {
return err
}
return JSONLoadTicket(val, t)
}
// ToTicket tries to convert the it Item to a Ticket object.
func ToTicket(it ap.Item) (*Ticket, error) {
switch i := it.(type) {
case *Ticket:
return i, nil
case Ticket:
return &i, nil
case *ap.Object:
return (*Ticket)(unsafe.Pointer(i)), nil
case ap.Object:
return (*Ticket)(unsafe.Pointer(&i)), nil
default:
// NOTE(marius): this is an ugly way of dealing with the interface conversion error: types from different scopes
typ := reflect.TypeOf(new(Ticket))
if i, ok := reflect.ValueOf(it).Convert(typ).Interface().(*Ticket); ok {
return i, nil
}
}
return nil, ap.ErrorInvalidType[ap.Object](it)
}
type withTicketFn func(*Ticket) error
// OnTicket calls function fn on it Item if it can be asserted to type *Ticket
func OnTicket(it ap.Item, fn withTicketFn) error {
if it == nil {
return nil
}
ob, err := ToTicket(it)
if err != nil {
return err
}
return fn(ob)
}

View File

@ -23,7 +23,7 @@ var (
}{
Enabled: false,
ShareUserStatistics: true,
MaxSize: 4,
MaxSize: 8,
Algorithms: []string{"rsa-sha256", "rsa-sha512", "ed25519"},
DigestAlgorithm: "SHA-256",
GetHeaders: []string{"(request-target)", "Date"},

View File

@ -93,7 +93,7 @@ func IsValidExternalTrackerURLFormat(uri string) bool {
}
var (
validUsernamePattern = regexp.MustCompile(`^[\da-zA-Z][-.\w]*$`)
validUsernamePattern = regexp.MustCompile(`^[\da-zA-Z][-.\w@]*$`)
invalidUsernamePattern = regexp.MustCompile(`[-._]{2,}|[-._]$`) // No consecutive or trailing non-alphanumeric chars
)

View File

@ -0,0 +1,97 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package activitypub
import (
"net/http"
"net/url"
"strconv"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/forgefed"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/activitypub"
ap "github.com/go-ap/activitypub"
)
// Fetch and load a remote object
func AuthorizeInteraction(ctx *context.Context) {
uri, err := url.Parse(ctx.Req.URL.Query().Get("uri"))
if err != nil {
ctx.ServerError("Parse URI", err)
return
}
resp, err := activitypub.Fetch(uri)
if err != nil {
ctx.ServerError("Fetch", err)
return
}
ap.ItemTyperFunc = forgefed.GetItemByType
ap.JSONItemUnmarshal = forgefed.JSONUnmarshalerFn
ap.NotEmptyChecker = forgefed.NotEmpty
object, err := ap.UnmarshalJSON(resp)
if err != nil {
ctx.ServerError("UnmarshalJSON", err)
return
}
switch object.GetType() {
case ap.PersonType:
// Federated user
person, err := ap.ToActor(object)
if err != nil {
ctx.ServerError("ToActor", err)
return
}
err = createPerson(ctx, person)
if err != nil {
ctx.ServerError("FederatedUserNew", err)
return
}
name, err := activitypub.PersonIRIToName(object.GetLink())
if err != nil {
ctx.ServerError("PersonIRIToName", err)
return
}
ctx.Redirect(setting.AppURL + name)
case forgefed.RepositoryType:
// Federated repository
err = forgefed.OnRepository(object, func(r *forgefed.Repository) error {
return createRepository(ctx, r)
})
if err != nil {
ctx.ServerError("FederatedRepoNew", err)
return
}
username, reponame, err := activitypub.RepositoryIRIToName(object.GetLink())
if err != nil {
ctx.ServerError("RepositoryIRIToName", err)
return
}
ctx.Redirect(setting.AppURL + username + "/" + reponame)
case forgefed.TicketType:
// Federated issue or pull request
err = forgefed.OnTicket(object, func(t *forgefed.Ticket) error {
return createTicket(ctx, t)
})
if err != nil {
ctx.ServerError("ReceiveIssue", err)
return
}
username, reponame, idx, err := activitypub.TicketIRIToName(object.GetLink())
if err != nil {
ctx.ServerError("TicketIRIToName", err)
return
}
ctx.Redirect(setting.AppURL + username + "/" + reponame + "/issues/" + strconv.FormatInt(idx, 10))
default:
ctx.ServerError("Not implemented", err)
return
}
ctx.Status(http.StatusOK)
}

View File

@ -0,0 +1,319 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package activitypub
import (
"context"
"errors"
"net/url"
"strconv"
"strings"
"code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/forgefed"
repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/activitypub"
issue_service "code.gitea.io/gitea/services/issue"
pull_service "code.gitea.io/gitea/services/pull"
repo_service "code.gitea.io/gitea/services/repository"
user_service "code.gitea.io/gitea/services/user"
ap "github.com/go-ap/activitypub"
)
// Create a new federated user from a Person object
func createPerson(ctx context.Context, person *ap.Person) error {
name, err := activitypub.PersonIRIToName(person.GetLink())
if err != nil {
return err
}
exists, err := user_model.IsUserExist(ctx, 0, name)
if err != nil {
return err
}
if exists {
return nil
}
var email string
if person.Location != nil {
email = person.Location.GetLink().String()
} else {
// This might not even work
email = strings.ReplaceAll(name, "@", "+") + "@" + setting.Service.NoReplyAddress
}
if person.PublicKey.PublicKeyPem == "" {
return errors.New("person public key not found")
}
user := &user_model.User{
Name: name,
FullName: person.Name.String(), // May not exist!!
Email: email,
LoginType: auth.Federated,
LoginName: person.GetLink().String(),
EmailNotificationsPreference: user_model.EmailNotificationsDisabled,
}
err = user_model.CreateUser(user)
if err != nil {
return err
}
if person.Icon != nil {
icon, err := ap.ToObject(person.Icon)
if err != nil {
return err
}
iconURL, err := icon.URL.GetLink().URL()
if err != nil {
return err
}
body, err := activitypub.Fetch(iconURL)
if err != nil {
return err
}
err = user_service.UploadAvatar(user, body)
if err != nil {
return err
}
}
err = user_model.SetUserSetting(user.ID, user_model.UserActivityPubPrivPem, "")
if err != nil {
return err
}
return user_model.SetUserSetting(user.ID, user_model.UserActivityPubPubPem, person.PublicKey.PublicKeyPem)
}
func createPersonFromIRI(ctx context.Context, personIRI ap.IRI) error {
ownerURL, err := url.Parse(personIRI.String())
if err != nil {
return err
}
// Fetch person object
resp, err := activitypub.Fetch(ownerURL)
if err != nil {
return err
}
// Parse person object
ap.ItemTyperFunc = forgefed.GetItemByType
ap.JSONItemUnmarshal = forgefed.JSONUnmarshalerFn
ap.NotEmptyChecker = forgefed.NotEmpty
object, err := ap.UnmarshalJSON(resp)
if err != nil {
return err
}
// Create federated user
person, err := ap.ToActor(object)
if err != nil {
return err
}
return createPerson(ctx, person)
}
// Create a new federated repo from a Repository object
func createRepository(ctx context.Context, repository *forgefed.Repository) error {
err := createPersonFromIRI(ctx, repository.AttributedTo.GetLink())
if err != nil {
return err
}
user, err := activitypub.PersonIRIToUser(ctx, repository.AttributedTo.GetLink())
if err != nil {
return err
}
// Check if repo exists
_, err = repo_model.GetRepositoryByOwnerAndNameCtx(ctx, user.Name, repository.Name.String())
if err == nil {
return nil
}
repo, err := repo_service.CreateRepository(user, user, repo_module.CreateRepoOptions{
Name: repository.Name.String(),
OriginalURL: repository.GetLink().String(),
})
if err != nil {
return err
}
if repository.ForkedFrom != nil {
repo.IsFork = true
forkedFrom, err := activitypub.RepositoryIRIToRepository(ctx, repository.ForkedFrom.GetLink())
if err != nil {
return err
}
repo.ForkID = forkedFrom.ID
}
return nil
}
func createRepositoryFromIRI(ctx context.Context, repoIRI ap.IRI) error {
repoURL, err := url.Parse(repoIRI.String())
if err != nil {
return err
}
// Fetch repository object
resp, err := activitypub.Fetch(repoURL)
if err != nil {
return err
}
// Parse repository object
ap.ItemTyperFunc = forgefed.GetItemByType
ap.JSONItemUnmarshal = forgefed.JSONUnmarshalerFn
ap.NotEmptyChecker = forgefed.NotEmpty
object, err := ap.UnmarshalJSON(resp)
if err != nil {
return err
}
// Create federated repo
return forgefed.OnRepository(object, func(r *forgefed.Repository) error {
return createRepository(ctx, r)
})
}
// Create a ticket
func createTicket(ctx context.Context, ticket *forgefed.Ticket) error {
if ticket.Origin != nil && ticket.Target != nil {
return createPullRequest(ctx, ticket)
}
return createIssue(ctx, ticket)
}
// Create an issue
func createIssue(ctx context.Context, ticket *forgefed.Ticket) error {
err := createRepositoryFromIRI(ctx, ticket.Context.GetLink())
if err != nil {
return err
}
// Construct issue
user, err := activitypub.PersonIRIToUser(ctx, ap.IRI(ticket.AttributedTo.GetLink().String()))
if err != nil {
return err
}
repo, err := activitypub.RepositoryIRIToRepository(ctx, ap.IRI(ticket.Context.GetLink().String()))
if err != nil {
return err
}
idx, err := strconv.ParseInt(ticket.Name.String()[1:], 10, 64)
if err != nil {
return err
}
issue := &issues_model.Issue{
Index: idx, // This doesn't seem to work?
RepoID: repo.ID,
Repo: repo,
Title: ticket.Summary.String(),
PosterID: user.ID,
Poster: user,
Content: ticket.Content.String(),
OriginalAuthor: ticket.GetLink().String(), // Create new database field to store IRI?
IsClosed: ticket.IsResolved,
}
return issue_service.NewIssue(repo, issue, nil, nil, nil)
}
// Create a pull request
func createPullRequest(ctx context.Context, ticket *forgefed.Ticket) error {
err := createRepositoryFromIRI(ctx, ticket.Context.GetLink())
if err != nil {
return err
}
user, err := activitypub.PersonIRIToUser(ctx, ticket.AttributedTo.GetLink())
if err != nil {
return err
}
// Extract origin and target repos
originUsername, originReponame, originBranch, err := activitypub.BranchIRIToName(ticket.Origin.GetLink())
if err != nil {
return err
}
originRepo, err := repo_model.GetRepositoryByOwnerAndName(originUsername, originReponame)
if err != nil {
return err
}
targetUsername, targetReponame, targetBranch, err := activitypub.BranchIRIToName(ticket.Target.GetLink())
if err != nil {
return err
}
targetRepo, err := repo_model.GetRepositoryByOwnerAndName(targetUsername, targetReponame)
if err != nil {
return err
}
idx, err := strconv.ParseInt(ticket.Name.String()[1:], 10, 64)
if err != nil {
return err
}
prIssue := &issues_model.Issue{
Index: idx,
RepoID: targetRepo.ID,
Title: ticket.Summary.String(),
PosterID: user.ID,
Poster: user,
IsPull: true,
Content: ticket.Content.String(),
IsClosed: ticket.IsResolved,
}
pr := &issues_model.PullRequest{
HeadRepoID: originRepo.ID,
BaseRepoID: targetRepo.ID,
HeadBranch: originBranch,
BaseBranch: targetBranch,
HeadRepo: originRepo,
BaseRepo: targetRepo,
MergeBase: "",
Type: issues_model.PullRequestGitea,
}
return pull_service.NewPullRequest(ctx, targetRepo, prIssue, []int64{}, []string{}, pr, []int64{})
}
// Create a comment
func createComment(ctx context.Context, note *ap.Note) error {
err := createPersonFromIRI(ctx, note.AttributedTo.GetLink())
if err != nil {
return err
}
user, err := activitypub.PersonIRIToUser(ctx, note.AttributedTo.GetLink())
if err != nil {
return err
}
username, reponame, idx, err := activitypub.TicketIRIToName(note.Context.GetLink())
if err != nil {
return err
}
repo, err := repo_model.GetRepositoryByOwnerAndNameCtx(ctx, username, reponame)
if err != nil {
return err
}
issue, err := issues_model.GetIssueByIndex(repo.ID, idx)
if err != nil {
return err
}
_, err = issues_model.CreateCommentCtx(ctx, &issues_model.CreateCommentOptions{
Doer: user,
Repo: repo,
Issue: issue,
OldTitle: note.GetLink().String(),
Content: note.Content.String(),
})
return err
}

View File

@ -0,0 +1,71 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package activitypub
import (
"context"
"strings"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/services/activitypub"
ap "github.com/go-ap/activitypub"
)
// Process an incoming Follow activity
func follow(ctx context.Context, follow ap.Follow) error {
// Actor is the user performing the follow
actorIRI := follow.Actor.GetLink()
actorUser, err := activitypub.PersonIRIToUser(ctx, actorIRI)
if err != nil {
return err
}
// Object is the user being followed
objectIRI := follow.Object.GetLink()
objectUser, err := activitypub.PersonIRIToUser(ctx, objectIRI)
// Must be a local user
if err != nil || strings.Contains(objectUser.Name, "@") {
return err
}
err = user_model.FollowUser(actorUser.ID, objectUser.ID)
if err != nil {
return err
}
// Send back an Accept activity
accept := ap.AcceptNew(objectIRI, follow)
accept.Actor = ap.Person{ID: objectIRI}
accept.To = ap.ItemCollection{ap.IRI(actorIRI.String() + "/inbox")}
accept.Object = follow
return activitypub.Send(objectUser, accept)
}
// Process an incoming Undo follow activity
func unfollow(ctx context.Context, unfollow ap.Undo) error {
// Object contains the follow
follow, err := ap.To[ap.Follow](unfollow.Object)
if err != nil {
return err
}
// Actor is the user performing the undo follow
actorIRI := follow.Actor.GetLink()
actorUser, err := activitypub.PersonIRIToUser(ctx, actorIRI)
if err != nil {
return err
}
// Object is the user being unfollowed
objectIRI := follow.Object.GetLink()
objectUser, err := activitypub.PersonIRIToUser(ctx, objectIRI)
// Must be a local user
if err != nil || strings.Contains(objectUser.Name, "@") {
return err
}
return user_model.UnfollowUser(actorUser.ID, objectUser.ID)
}

View File

@ -0,0 +1,64 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package activitypub
import (
"strconv"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/services/activitypub"
)
// Note function returns the Note object for a comment to an issue or PR
func Note(ctx *context.APIContext) {
// swagger:operation GET /activitypub/note/{username}/{reponame}/{id}/{noteid} activitypub activitypubNote
// ---
// summary: Returns the Note object for a comment to an issue or PR
// produces
// - application/activity+json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// - name: reponame
// in: path
// description: name of the repo
// type: string
// required: true
// - name: id
// in: path
// description: ID number of the issue or PR
// type: string
// required: true
// - name: noteid
// in: path
// description: ID number of the comment
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/ActivityPub"
index, err := strconv.ParseInt(ctx.Params("noteid"), 10, 64)
if err != nil {
ctx.ServerError("ParseInt", err)
return
}
// TODO: index can be spoofed!!!
comment, err := issues_model.GetCommentByID(ctx, index)
if err != nil {
ctx.ServerError("GetCommentByID", err)
return
}
note, err := activitypub.Note(comment)
if err != nil {
ctx.ServerError("Note", err)
return
}
response(ctx, note)
}

View File

@ -5,16 +5,24 @@
package activitypub
import (
"fmt"
"io"
"net/http"
"strings"
"code.gitea.io/gitea/modules/activitypub"
"code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/convert"
"code.gitea.io/gitea/modules/forgefed"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/routers/api/v1/utils"
"code.gitea.io/gitea/services/activitypub"
ap "github.com/go-ap/activitypub"
"github.com/go-ap/jsonld"
)
// Person function returns the Person actor for a user
@ -23,7 +31,7 @@ func Person(ctx *context.APIContext) {
// ---
// summary: Returns the Person actor for a user
// produces:
// - application/json
// - application/activity+json
// parameters:
// - name: username
// in: path
@ -34,8 +42,8 @@ func Person(ctx *context.APIContext) {
// "200":
// "$ref": "#/responses/ActivityPub"
link := strings.TrimSuffix(setting.AppURL, "/") + "/api/v1/activitypub/user/" + ctx.ContextUser.Name
person := ap.PersonNew(ap.IRI(link))
iri := ctx.ContextUser.GetIRI()
person := ap.PersonNew(ap.IRI(iri))
person.Name = ap.NaturalLanguageValuesNew()
err := person.Name.Set("en", ap.Content(ctx.ContextUser.FullName))
@ -52,19 +60,22 @@ func Person(ctx *context.APIContext) {
}
person.URL = ap.IRI(ctx.ContextUser.HTMLURL())
person.Location = ap.IRI(ctx.ContextUser.GetEmail())
person.Icon = ap.Image{
Type: ap.ImageType,
MediaType: "image/png",
URL: ap.IRI(ctx.ContextUser.AvatarLink()),
URL: ap.IRI(ctx.ContextUser.AvatarFullLinkWithSize(2048)),
}
person.Inbox = ap.IRI(link + "/inbox")
person.Outbox = ap.IRI(link + "/outbox")
person.PublicKey.ID = ap.IRI(link + "#main-key")
person.PublicKey.Owner = ap.IRI(link)
person.Inbox = ap.IRI(iri + "/inbox")
person.Outbox = ap.IRI(iri + "/outbox")
person.Following = ap.IRI(iri + "/following")
person.Followers = ap.IRI(iri + "/followers")
person.Liked = ap.IRI(iri + "/liked")
person.PublicKey.ID = ap.IRI(iri + "#main-key")
person.PublicKey.Owner = ap.IRI(iri)
publicKeyPem, err := activitypub.GetPublicKey(ctx.ContextUser)
if err != nil {
ctx.ServerError("GetPublicKey", err)
@ -72,16 +83,7 @@ func Person(ctx *context.APIContext) {
}
person.PublicKey.PublicKeyPem = publicKeyPem
binary, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(person)
if err != nil {
ctx.ServerError("MarshalJSON", err)
return
}
ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType)
ctx.Resp.WriteHeader(http.StatusOK)
if _, err = ctx.Resp.Write(binary); err != nil {
log.Error("write to resp err: %v", err)
}
response(ctx, person)
}
// PersonInbox function handles the incoming data for a user inbox
@ -90,7 +92,7 @@ func PersonInbox(ctx *context.APIContext) {
// ---
// summary: Send to the inbox
// produces:
// - application/json
// - application/activity+json
// parameters:
// - name: username
// in: path
@ -101,5 +103,263 @@ func PersonInbox(ctx *context.APIContext) {
// "204":
// "$ref": "#/responses/empty"
body, err := io.ReadAll(io.LimitReader(ctx.Req.Body, setting.Federation.MaxSize))
if err != nil {
ctx.ServerError("Error reading request body", err)
return
}
var activity ap.Activity
err = activity.UnmarshalJSON(body)
if err != nil {
ctx.ServerError("UnmarshalJSON", err)
return
}
// Make sure keyID matches the user doing the activity
_, keyID, _ := getKeyID(ctx.Req)
if activity.Actor != nil && !strings.HasPrefix(keyID, activity.Actor.GetLink().String()) {
ctx.ServerError("Actor does not match HTTP signature keyID", nil)
return
}
if activity.AttributedTo != nil && !strings.HasPrefix(keyID, activity.AttributedTo.GetLink().String()) {
ctx.ServerError("AttributedTo does not match HTTP signature keyID", nil)
return
}
// TODO: Check activity.Object actor and attributedTo
// Process activity
switch activity.Type {
case ap.FollowType:
err = follow(ctx, activity)
case ap.UndoType:
err = unfollow(ctx, activity)
case ap.CreateType:
// TODO: this is kinda a hack
err = ap.OnObject(activity.Object, func(n *ap.Note) error {
noteIRI := n.InReplyTo.GetLink().String()
noteIRISplit := strings.Split(noteIRI, "/")
n.Context = ap.IRI(strings.TrimSuffix(noteIRI, "/"+noteIRISplit[len(noteIRISplit)-1]))
return createComment(ctx, n)
})
default:
log.Info("Incoming unsupported ActivityStreams type: %s", activity.GetType())
ctx.PlainText(http.StatusNotImplemented, "ActivityStreams type not supported")
return
}
if err != nil {
ctx.ServerError("Could not process activity", err)
return
}
ctx.Status(http.StatusNoContent)
}
// PersonOutbox function returns the user's Outbox OrderedCollection
func PersonOutbox(ctx *context.APIContext) {
// swagger:operation GET /activitypub/user/{username}/outbox activitypub activitypubPersonOutbox
// ---
// summary: Returns the Outbox OrderedCollection
// produces:
// - application/activity+json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/ActivityPub"
iri := ctx.ContextUser.GetIRI()
orderedCollection := ap.OrderedCollectionNew(ap.IRI(iri + "/outbox"))
orderedCollection.First = ap.IRI(iri + "/outbox?page=1")
outbox := ap.OrderedCollectionPageNew(orderedCollection)
outbox.First = ap.IRI(iri + "/outbox?page=1")
feed, err := activities.GetFeeds(ctx, activities.GetFeedsOptions{
RequestedUser: ctx.ContextUser,
RequestedActionType: activities.ActionCreateRepo,
Actor: ctx.Doer,
IncludePrivate: false,
IncludeDeleted: false,
ListOptions: utils.GetListOptions(ctx),
})
// Only specify next if this amount of feed corresponds to the calculated limit.
if len(feed) == convert.ToCorrectPageSize(ctx.FormInt("limit")) {
outbox.Next = ap.IRI(fmt.Sprintf("%s/outbox?page=%d", iri, ctx.FormInt("page")+1))
}
// Only specify previous page when there is one.
if ctx.FormInt("page") > 1 {
outbox.Prev = ap.IRI(fmt.Sprintf("%s/outbox?page=%d", iri, ctx.FormInt("page")-1))
}
if err != nil {
ctx.ServerError("Couldn't fetch feed", err)
return
}
for _, action := range feed {
// Created a repo
object := ap.Note{Type: ap.NoteType, Content: ap.NaturalLanguageValuesNew()}
_ = object.Content.Set("en", ap.Content(action.GetRepoName()))
create := ap.Create{Type: ap.CreateType, Object: object}
err := outbox.OrderedItems.Append(create)
if err != nil {
ctx.ServerError("OrderedItems.Append", err)
return
}
}
// TODO: Remove this code and implement an ActionStarRepo type, so `GetFeeds`
// can handle this with correct pagination and ordering.
stars, err := repo_model.GetStarredRepos(ctx.ContextUser.ID, false, db.ListOptions{Page: 1, PageSize: 1000000})
if err != nil {
ctx.ServerError("Couldn't fetch stars", err)
return
}
for _, star := range stars {
object := ap.Note{Type: ap.NoteType, Content: ap.NaturalLanguageValuesNew()}
_ = object.Content.Set("en", ap.Content("Starred "+star.Name))
create := ap.Create{Type: ap.CreateType, Object: object}
err := outbox.OrderedItems.Append(create)
if err != nil {
ctx.ServerError("OrderedItems.Append", err)
return
}
}
outbox.TotalItems = uint(len(outbox.OrderedItems))
response(ctx, outbox)
}
// PersonFollowing function returns the user's Following Collection
func PersonFollowing(ctx *context.APIContext) {
// swagger:operation GET /activitypub/user/{username}/following activitypub activitypubPersonFollowing
// ---
// summary: Returns the Following Collection
// produces:
// - application/activity+json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/ActivityPub"
iri := ctx.ContextUser.GetIRI()
users, _, err := user_model.GetUserFollowing(ctx, ctx.ContextUser, ctx.Doer, utils.GetListOptions(ctx))
if err != nil {
ctx.ServerError("GetUserFollowing", err)
return
}
following := ap.OrderedCollectionNew(ap.IRI(iri + "/following"))
following.TotalItems = uint(len(users))
for _, user := range users {
person := ap.PersonNew(ap.IRI(user.GetIRI()))
err := following.OrderedItems.Append(person)
if err != nil {
ctx.ServerError("OrderedItems.Append", err)
return
}
}
response(ctx, following)
}
// PersonFollowers function returns the user's Followers Collection
func PersonFollowers(ctx *context.APIContext) {
// swagger:operation GET /activitypub/user/{username}/followers activitypub activitypubPersonFollowers
// ---
// summary: Returns the Followers Collection
// produces:
// - application/activity+json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/ActivityPub"
iri := ctx.ContextUser.GetIRI()
users, _, err := user_model.GetUserFollowers(ctx, ctx.ContextUser, ctx.Doer, utils.GetListOptions(ctx))
if err != nil {
ctx.ServerError("GetUserFollowers", err)
return
}
followers := ap.OrderedCollectionNew(ap.IRI(iri + "/followers"))
followers.TotalItems = uint(len(users))
for _, user := range users {
person := ap.PersonNew(ap.IRI(user.GetIRI()))
err := followers.OrderedItems.Append(person)
if err != nil {
ctx.ServerError("OrderedItems.Append", err)
return
}
}
response(ctx, followers)
}
// PersonLiked function returns the user's Liked Collection
func PersonLiked(ctx *context.APIContext) {
// swagger:operation GET /activitypub/user/{username}/followers activitypub activitypubPersonLiked
// ---
// summary: Returns the Liked Collection
// produces:
// - application/activity+json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/ActivityPub"
iri := ctx.ContextUser.GetIRI()
repos, count, err := repo_model.SearchRepository(&repo_model.SearchRepoOptions{
Actor: ctx.Doer,
Private: ctx.IsSigned,
StarredByID: ctx.ContextUser.ID,
})
if err != nil {
ctx.ServerError("GetUserStarred", err)
return
}
liked := ap.OrderedCollectionNew(ap.IRI(iri + "/liked"))
liked.TotalItems = uint(count)
for _, repo := range repos {
repo := forgefed.RepositoryNew(ap.IRI(repo.GetIRI()))
err := liked.OrderedItems.Append(repo)
if err != nil {
ctx.ServerError("OrderedItems.Append", err)
return
}
}
response(ctx, liked)
}

View File

@ -0,0 +1,209 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package activitypub
import (
"io"
"net/http"
"strings"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/forgefed"
"code.gitea.io/gitea/modules/log"
ap "github.com/go-ap/activitypub"
)
// Repo function returns the Repository actor of a repo
func Repo(ctx *context.APIContext) {
// swagger:operation GET /activitypub/repo/{username}/{reponame} activitypub activitypubRepo
// ---
// summary: Returns the repository
// produces:
// - application/activity+json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// - name: reponame
// in: path
// description: name of the repository
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/ActivityPub"
iri := ctx.Repo.Repository.GetIRI()
repo := forgefed.RepositoryNew(ap.IRI(iri))
repo.Name = ap.NaturalLanguageValuesNew()
err := repo.Name.Set("en", ap.Content(ctx.Repo.Repository.Name))
if err != nil {
ctx.ServerError("Set Name", err)
return
}
repo.AttributedTo = ap.IRI(ctx.Repo.Owner.GetIRI())
repo.Summary = ap.NaturalLanguageValuesNew()
err = repo.Summary.Set("en", ap.Content(ctx.Repo.Repository.Description))
if err != nil {
ctx.ServerError("Set Description", err)
return
}
repo.Inbox = ap.IRI(iri + "/inbox")
repo.Outbox = ap.IRI(iri + "/outbox")
repo.Followers = ap.IRI(iri + "/followers")
repo.Team = ap.IRI(iri + "/team")
response(ctx, repo)
}
// RepoInbox function handles the incoming data for a repo inbox
func RepoInbox(ctx *context.APIContext) {
// swagger:operation POST /activitypub/repo/{username}/{reponame}/inbox activitypub activitypubRepoInbox
// ---
// summary: Send to the inbox
// produces:
// - application/activity+json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// - name: reponame
// in: path
// description: name of the repository
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
body, err := io.ReadAll(ctx.Req.Body)
if err != nil {
ctx.ServerError("Error reading request body", err)
return
}
ap.ItemTyperFunc = forgefed.GetItemByType
ap.JSONItemUnmarshal = forgefed.JSONUnmarshalerFn
ap.NotEmptyChecker = forgefed.NotEmpty
var activity ap.Activity
err = activity.UnmarshalJSON(body)
if err != nil {
ctx.ServerError("UnmarshalJSON", err)
return
}
// Make sure keyID matches the user doing the activity
_, keyID, _ := getKeyID(ctx.Req)
if activity.Actor != nil && !strings.HasPrefix(keyID, activity.Actor.GetLink().String()) {
ctx.ServerError("Actor does not match HTTP signature keyID", nil)
return
}
if activity.AttributedTo != nil && !strings.HasPrefix(keyID, activity.AttributedTo.GetLink().String()) {
ctx.ServerError("AttributedTo does not match HTTP signature keyID", nil)
return
}
if activity.Object == nil {
ctx.ServerError("Activity does not contain object", err)
return
}
// Process activity
switch activity.Type {
case ap.CreateType:
switch activity.Object.GetType() {
case forgefed.RepositoryType:
// Fork created by remote instance
err = forgefed.OnRepository(activity.Object, func(r *forgefed.Repository) error {
return createRepository(ctx, r)
})
case forgefed.TicketType:
// New issue or pull request
err = forgefed.OnTicket(activity.Object, func(t *forgefed.Ticket) error {
return createTicket(ctx, t)
})
case ap.NoteType:
// New comment
err = ap.On(activity.Object, func(n *ap.Note) error {
return createComment(ctx, n)
})
default:
log.Info("Incoming unsupported ActivityStreams object type: %s", activity.Object.GetType())
ctx.PlainText(http.StatusNotImplemented, "ActivityStreams object type not supported")
return
}
case ap.LikeType:
err = star(ctx, activity)
default:
ctx.PlainText(http.StatusNotImplemented, "ActivityStreams type not supported")
return
}
if err != nil {
ctx.ServerError("Error when processing", err)
}
ctx.Status(http.StatusNoContent)
}
// RepoOutbox function returns the repo's Outbox OrderedCollection
func RepoOutbox(ctx *context.APIContext) {
// swagger:operation GET /activitypub/repo/{username}/{reponame}/outbox activitypub activitypubRepoOutbox
// ---
// summary: Returns the outbox
// produces:
// - application/activity+json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// - name: reponame
// in: path
// description: name of the repository
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/ActivityPub"
// TODO
ctx.Status(http.StatusNotImplemented)
}
// RepoFollowers function returns the repo's Followers OrderedCollection
func RepoFollowers(ctx *context.APIContext) {
// swagger:operation GET /activitypub/repo/{username}/{reponame}/followers activitypub activitypubRepoFollowers
// ---
// summary: Returns the followers collection
// produces:
// - application/activity+json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// - name: reponame
// in: path
// description: name of the repository
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/ActivityPub"
// TODO
ctx.Status(http.StatusNotImplemented)
}

View File

@ -9,14 +9,12 @@ import (
"crypto/x509"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/url"
"code.gitea.io/gitea/modules/activitypub"
gitea_context "code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/activitypub"
ap "github.com/go-ap/activitypub"
"github.com/go-fed/httpsig"
@ -44,39 +42,28 @@ func getPublicKeyFromResponse(b []byte, keyID *url.URL) (p crypto.PublicKey, err
return p, err
}
func fetch(iri *url.URL) (b []byte, err error) {
req := httplib.NewRequest(iri.String(), http.MethodGet)
req.Header("Accept", activitypub.ActivityStreamsContentType)
req.Header("User-Agent", "Gitea/"+setting.AppVer)
resp, err := req.Response()
func getKeyID(r *http.Request) (httpsig.Verifier, string, error) {
v, err := httpsig.NewVerifier(r)
if err != nil {
return
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
err = fmt.Errorf("url IRI fetch [%s] failed with status (%d): %s", iri, resp.StatusCode, resp.Status)
return
}
b, err = io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize))
return b, err
return v, v.KeyId(), nil
}
func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, err error) {
r := ctx.Req
// 1. Figure out what key we need to verify
v, err := httpsig.NewVerifier(r)
v, ID, err := getKeyID(r)
if err != nil {
return
}
ID := v.KeyId()
idIRI, err := url.Parse(ID)
if err != nil {
return
}
// 2. Fetch the public key of the other actor
b, err := fetch(idIRI)
b, err := activitypub.Fetch(idIRI)
if err != nil {
return
}
@ -87,6 +74,19 @@ func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, er
// 3. Verify the other actor's key
algo := httpsig.Algorithm(setting.Federation.Algorithms[0])
authenticated = v.Verify(pubKey, algo) == nil
if !authenticated {
return
}
// 4. Create a federated user for the actor
// TODO: This is a very bad place for creating federated users
// We end up creating way more users than necessary!
var person ap.Person
err = person.UnmarshalJSON(b)
if err != nil {
return
}
err = createPerson(ctx, &person)
return authenticated, err
}

View File

@ -0,0 +1,36 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package activitypub
import (
"net/http"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/forgefed"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/services/activitypub"
ap "github.com/go-ap/activitypub"
"github.com/go-ap/jsonld"
)
// Respond with an ActivityStreams object
func response(ctx *context.APIContext, v interface{}) {
binary, err := jsonld.WithContext(
jsonld.IRI(ap.ActivityBaseURI),
jsonld.IRI(ap.SecurityContextURI),
jsonld.IRI(forgefed.ForgeFedNamespaceURI),
).Marshal(v)
if err != nil {
ctx.ServerError("Marshal", err)
return
}
ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType)
ctx.Resp.WriteHeader(http.StatusOK)
if _, err = ctx.Resp.Write(binary); err != nil {
log.Error("write to resp err: %v", err)
}
}

View File

@ -0,0 +1,28 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package activitypub
import (
"context"
"strings"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/services/activitypub"
ap "github.com/go-ap/activitypub"
)
// Process a Like activity to star a repository
func star(ctx context.Context, like ap.Like) (err error) {
user, err := activitypub.PersonIRIToUser(ctx, like.Actor.GetLink())
if err != nil {
return
}
repo, err := activitypub.RepositoryIRIToRepository(ctx, like.Object.GetLink())
if err != nil || strings.Contains(repo.Name, "@") || repo.IsPrivate {
return
}
return repo_model.StarRepo(user.ID, repo.ID, true)
}

View File

@ -0,0 +1,58 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package activitypub
import (
"strconv"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/services/activitypub"
)
// Ticket function returns the Ticket object for an issue or PR
func Ticket(ctx *context.APIContext) {
// swagger:operation GET /activitypub/ticket/{username}/{reponame}/{id} activitypub forgefedTicket
// ---
// summary: Returns the Ticket object for an issue or PR
// produces:
// - application/activity+json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// - name: reponame
// in: path
// description: name of the repo
// type: string
// required: true
// - name: id
// in: path
// description: ID number of the issue or PR
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/ActivityPub"
index, err := strconv.ParseInt(ctx.Params("id"), 10, 64)
if err != nil {
ctx.ServerError("ParseInt", err)
return
}
issue, err := issues_model.GetIssueByIndex(ctx.Repo.Repository.ID, index)
if err != nil {
ctx.ServerError("GetIssueByIndex", err)
return
}
ticket, err := activitypub.Ticket(issue)
if err != nil {
ctx.ServerError("Ticket", err)
return
}
response(ctx, ticket)
}

View File

@ -644,12 +644,25 @@ func Routes(ctx gocontext.Context) *web.Route {
}
m.Get("/version", misc.Version)
if setting.Federation.Enabled {
m.Get("/authorize_interaction", activitypub.AuthorizeInteraction)
m.Get("/nodeinfo", misc.NodeInfo)
m.Group("/activitypub", func() {
m.Group("/user/{username}", func() {
m.Get("", activitypub.Person)
m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox)
m.Get("/outbox", activitypub.PersonOutbox)
m.Get("/following", activitypub.PersonFollowing)
m.Get("/followers", activitypub.PersonFollowers)
m.Get("/liked", activitypub.PersonLiked)
}, context_service.UserAssignmentAPI())
m.Group("/repo/{username}/{reponame}", func() {
m.Get("", activitypub.Repo)
m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.RepoInbox)
m.Get("/outbox", activitypub.RepoOutbox)
m.Get("/followers", activitypub.RepoFollowers)
}, repoAssignment())
m.Get("/ticket/{username}/{reponame}/{id}", repoAssignment(), activitypub.Ticket)
m.Get("/note/{username}/{reponame}/{id}/{noteid}", repoAssignment(), activitypub.Note)
})
}
m.Get("/signing-key.gpg", misc.SigningKey)

View File

@ -13,6 +13,7 @@ import (
"code.gitea.io/gitea/modules/convert"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/routers/api/v1/utils"
user_service "code.gitea.io/gitea/services/user"
)
func responseAPIUsers(ctx *context.APIContext, users []*user_model.User) {
@ -219,7 +220,7 @@ func Follow(ctx *context.APIContext) {
// "204":
// "$ref": "#/responses/empty"
if err := user_model.FollowUser(ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
if err := user_service.FollowUser(ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
ctx.Error(http.StatusInternalServerError, "FollowUser", err)
return
}
@ -241,7 +242,7 @@ func Unfollow(ctx *context.APIContext) {
// "204":
// "$ref": "#/responses/empty"
if err := user_model.UnfollowUser(ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
if err := user_service.UnfollowUser(ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
ctx.Error(http.StatusInternalServerError, "UnfollowUser", err)
return
}

View File

@ -23,6 +23,7 @@ import (
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/web/feed"
"code.gitea.io/gitea/routers/web/org"
user_service "code.gitea.io/gitea/services/user"
)
// Profile render user's profile page
@ -302,9 +303,9 @@ func Action(ctx *context.Context) {
var err error
switch ctx.FormString("action") {
case "follow":
err = user_model.FollowUser(ctx.Doer.ID, ctx.ContextUser.ID)
err = user_service.FollowUser(ctx.Doer.ID, ctx.ContextUser.ID)
case "unfollow":
err = user_model.UnfollowUser(ctx.Doer.ID, ctx.ContextUser.ID)
err = user_service.UnfollowUser(ctx.Doer.ID, ctx.ContextUser.ID)
}
if err != nil {

View File

@ -29,6 +29,7 @@ type webfingerLink struct {
Rel string `json:"rel,omitempty"`
Type string `json:"type,omitempty"`
Href string `json:"href,omitempty"`
Template string `json:"template,omitempty"`
Titles map[string]string `json:"titles,omitempty"`
Properties map[string]interface{} `json:"properties,omitempty"`
}
@ -107,6 +108,10 @@ func WebfingerQuery(ctx *context.Context) {
Type: "application/activity+json",
Href: appURL.String() + "api/v1/activitypub/user/" + url.PathEscape(u.Name),
},
{
Rel: "http://ostatus.org/schema/1.0/subscribe",
Template: appURL.String() + "api/v1/authorize_interaction?uri={uri}",
},
}
ctx.Resp.Header().Add("Access-Control-Allow-Origin", "*")

View File

@ -0,0 +1,32 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package activitypub
import (
user_model "code.gitea.io/gitea/models/user"
ap "github.com/go-ap/activitypub"
)
// Create and send Follow activity
func Follow(actorUser, followUser *user_model.User) *ap.Follow {
object := ap.PersonNew(ap.IRI(followUser.LoginName))
follow := ap.FollowNew("", object)
follow.Type = ap.FollowType
follow.Actor = ap.PersonNew(ap.IRI(actorUser.GetIRI()))
follow.To = ap.ItemCollection{ap.Item(ap.IRI(followUser.LoginName + "/inbox"))}
return follow
}
// Create and send Undo Follow activity
func Unfollow(actorUser, followUser *user_model.User) *ap.Undo {
object := ap.PersonNew(ap.IRI(followUser.LoginName))
follow := ap.FollowNew("", object)
follow.Actor = ap.PersonNew(ap.IRI(actorUser.GetIRI()))
unfollow := ap.UndoNew("", follow)
unfollow.Type = ap.UndoType
unfollow.To = ap.ItemCollection{ap.Item(ap.IRI(followUser.LoginName + "/inbox"))}
return unfollow
}

View File

@ -0,0 +1,17 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package activitypub
import (
ap "github.com/go-ap/activitypub"
)
func Create(to string, object ap.ObjectOrLink) *ap.Create {
return &ap.Create{
Type: ap.CreateType,
Object: object,
To: ap.ItemCollection{ap.Item(ap.IRI(to))},
}
}

121
services/activitypub/iri.go Normal file
View File

@ -0,0 +1,121 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package activitypub
import (
"context"
"errors"
"strconv"
"strings"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
ap "github.com/go-ap/activitypub"
)
// Returns the username corresponding to a Person actor IRI
func PersonIRIToName(personIRI ap.IRI) (string, error) {
personIRISplit := strings.Split(personIRI.String(), "/")
if len(personIRISplit) < 4 {
return "", errors.New("not a Person actor IRI")
}
instance := personIRISplit[2]
name := personIRISplit[len(personIRISplit)-1]
if instance == setting.Domain {
// Local user
return name, nil
}
// Remote user
// Get name in username@instance.com format
return name + "@" + instance, nil
}
// Returns the user corresponding to a Person actor IRI
func PersonIRIToUser(ctx context.Context, personIRI ap.IRI) (*user_model.User, error) {
name, err := PersonIRIToName(personIRI)
if err != nil {
return nil, err
}
user, err := user_model.GetUserByName(ctx, name)
if err != nil && !strings.Contains(name, "@") {
return user, err
}
return user_model.GetUserByName(ctx, name)
}
// Returns the owner and name corresponding to a Repository actor IRI
func RepositoryIRIToName(repoIRI ap.IRI) (string, string, error) {
repoIRISplit := strings.Split(repoIRI.String(), "/")
if len(repoIRISplit) < 5 {
return "", "", errors.New("not a Repository actor IRI")
}
instance := repoIRISplit[2]
username := repoIRISplit[len(repoIRISplit)-2]
reponame := repoIRISplit[len(repoIRISplit)-1]
if instance == setting.Domain {
// Local repo
return username, reponame, nil
}
// Remote repo
return username + "@" + instance, reponame, nil
}
// Returns the repository corresponding to a Repository actor IRI
func RepositoryIRIToRepository(ctx context.Context, repoIRI ap.IRI) (*repo_model.Repository, error) {
username, reponame, err := RepositoryIRIToName(repoIRI)
if err != nil {
return nil, err
}
// TODO: create remote repo if not exists
return repo_model.GetRepositoryByOwnerAndName(username, reponame)
}
// Returns the owner, repo name, and idx of a Ticket object IRI
func TicketIRIToName(ticketIRI ap.IRI) (string, string, int64, error) {
ticketIRISplit := strings.Split(ticketIRI.String(), "/")
if len(ticketIRISplit) < 5 {
return "", "", 0, errors.New("not a Ticket object IRI")
}
instance := ticketIRISplit[2]
username := ticketIRISplit[len(ticketIRISplit)-3]
reponame := ticketIRISplit[len(ticketIRISplit)-2]
idx, err := strconv.ParseInt(ticketIRISplit[len(ticketIRISplit)-1], 10, 64)
if err != nil {
return "", "", 0, err
}
if instance == setting.Domain {
// Local repo
return username, reponame, idx, nil
}
// Remote repo
return username + "@" + instance, reponame, idx, nil
}
// Returns the owner, repo name, and idx of a Branch object IRI
func BranchIRIToName(ticketIRI ap.IRI) (string, string, string, error) {
ticketIRISplit := strings.Split(ticketIRI.String(), "/")
if len(ticketIRISplit) < 5 {
return "", "", "", errors.New("not a Branch object IRI")
}
instance := ticketIRISplit[2]
username := ticketIRISplit[len(ticketIRISplit)-3]
reponame := ticketIRISplit[len(ticketIRISplit)-2]
branch := ticketIRISplit[len(ticketIRISplit)-1]
if instance == setting.Domain {
// Local repo
return username, reponame, branch, nil
}
// Remote repo
return username + "@" + instance, reponame, branch, nil
}

View File

@ -0,0 +1,85 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package activitypub
import (
"strconv"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/forgefed"
ap "github.com/go-ap/activitypub"
)
// Construct a Note object from a comment
func Note(comment *issues_model.Comment) (*ap.Note, error) {
err := comment.LoadPoster()
if err != nil {
return nil, err
}
err = comment.LoadIssue()
if err != nil {
return nil, err
}
note := ap.Note{
Type: ap.NoteType,
ID: ap.IRI(comment.GetIRI()),
AttributedTo: ap.IRI(comment.Poster.GetIRI()),
Context: ap.IRI(comment.Issue.GetIRI()),
To: ap.ItemCollection{ap.IRI("https://www.w3.org/ns/activitystreams#Public")},
}
note.Content = ap.NaturalLanguageValuesNew()
err = note.Content.Set("en", ap.Content(comment.Content))
if err != nil {
return nil, err
}
return &note, nil
}
// Construct a Ticket object from an issue
func Ticket(issue *issues_model.Issue) (*forgefed.Ticket, error) {
iri := issue.GetIRI()
ticket := forgefed.TicketNew()
ticket.Type = forgefed.TicketType
ticket.ID = ap.IRI(iri)
// Setting a NaturalLanguageValue to a number causes go-ap's JSON parsing to do weird things
// Workaround: set it to #1 instead of 1
ticket.Name = ap.NaturalLanguageValuesNew()
err := ticket.Name.Set("en", ap.Content("#"+strconv.FormatInt(issue.Index, 10)))
if err != nil {
return nil, err
}
err = issue.LoadRepo(db.DefaultContext)
if err != nil {
return nil, err
}
ticket.Context = ap.IRI(issue.Repo.GetIRI())
err = issue.LoadPoster()
if err != nil {
return nil, err
}
ticket.AttributedTo = ap.IRI(issue.Poster.GetIRI())
ticket.Summary = ap.NaturalLanguageValuesNew()
err = ticket.Summary.Set("en", ap.Content(issue.Title))
if err != nil {
return nil, err
}
ticket.Content = ap.NaturalLanguageValuesNew()
err = ticket.Content.Set("en", ap.Content(issue.Content))
if err != nil {
return nil, err
}
if issue.IsClosed {
ticket.IsResolved = true
}
return ticket, nil
}

View File

@ -0,0 +1,60 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package activitypub
import (
"fmt"
"io"
"net/http"
"net/url"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/forgefed"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
ap "github.com/go-ap/activitypub"
"github.com/go-ap/jsonld"
)
// Fetch a remote ActivityStreams object
func Fetch(iri *url.URL) (b []byte, err error) {
req := httplib.NewRequest(iri.String(), http.MethodGet)
req.Header("Accept", ActivityStreamsContentType)
req.Header("User-Agent", "Gitea/"+setting.AppVer)
resp, err := req.Response()
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
err = fmt.Errorf("url IRI fetch [%s] failed with status (%d): %s", iri, resp.StatusCode, resp.Status)
return
}
b, err = io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize))
return b, err
}
// Send an activity
func Send(user *user_model.User, activity *ap.Activity) error {
binary, err := jsonld.WithContext(
jsonld.IRI(ap.ActivityBaseURI),
jsonld.IRI(ap.SecurityContextURI),
jsonld.IRI(forgefed.ForgeFedNamespaceURI),
).Marshal(activity)
if err != nil {
return err
}
for _, to := range activity.To {
client, _ := NewClient(user, user.GetIRI()+"#main-key")
resp, _ := client.Post(binary, to.GetLink().String())
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize))
log.Trace("Response from sending activity", string(respBody))
}
return err
}

View File

@ -5,12 +5,15 @@
package comments
import (
"strings"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/notification"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/services/activitypub"
)
// CreateIssueComment creates a plain issue comment.
@ -27,6 +30,19 @@ func CreateIssueComment(doer *user_model.User, repo *repo_model.Repository, issu
return nil, err
}
if strings.Contains(repo.OwnerName, "@") {
// Federated comment
note, err := activitypub.Note(comment)
if err != nil {
return nil, err
}
create := activitypub.Create(repo.OriginalURL+"/inbox", note)
err = activitypub.Send(doer, create)
if err != nil {
return nil, err
}
}
mentions, err := issues_model.FindAndUpdateIssueMentions(db.DefaultContext, issue, doer, comment.Content)
if err != nil {
return nil, err

View File

@ -6,6 +6,7 @@ package issue
import (
"fmt"
"strings"
activities_model "code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/models/db"
@ -19,6 +20,7 @@ import (
"code.gitea.io/gitea/modules/notification"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/activitypub"
)
// NewIssue creates new issue with labels for repository.
@ -27,6 +29,19 @@ func NewIssue(repo *repo_model.Repository, issue *issues_model.Issue, labelIDs [
return err
}
if strings.Contains(repo.OwnerName, "@") {
// Federated issue
ticket, err := activitypub.Ticket(issue)
if err != nil {
return err
}
create := activitypub.Create(repo.OriginalURL+"/inbox", ticket)
err = activitypub.Send(issue.Poster, create)
if err != nil {
return err
}
}
for _, assigneeID := range assigneeIDs {
if err := AddAssigneeIfNotAssigned(issue, issue.Poster, assigneeID); err != nil {
return err

View File

@ -0,0 +1,46 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package repository
import (
"context"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/activitypub"
"code.gitea.io/gitea/services/migrations"
ap "github.com/go-ap/activitypub"
)
func CreateFork(ctx context.Context, instance, username, reponame, destUsername string) error {
// TODO: Clean this up
// Migrate repository code
user, err := user_model.GetUserByName(ctx, destUsername)
if err != nil {
return err
}
_, err = migrations.MigrateRepository(ctx, user, destUsername, migrations.MigrateOptions{
CloneAddr: "https://" + instance + "/" + username + "/" + reponame + ".git",
RepoName: reponame,
}, nil)
if err != nil {
return err
}
// TODO: Make the migrated repo a fork
// Send a Create activity to the instance we are forking from
create := ap.Create{Type: ap.CreateType}
create.To = ap.ItemCollection{ap.IRI("https://" + instance + "/api/v1/activitypub/repo/" + username + "/" + reponame + "/inbox")}
repo := ap.IRI(setting.AppURL + "api/v1/activitypub/repo/" + destUsername + "/" + reponame)
// repo := forgefed.RepositoryNew(ap.IRI(setting.AppURL + "api/v1/activitypub/repo/" + destUsername + "/" + reponame))
// repo.ForkedFrom = forgefed.RepositoryNew(ap.IRI())
create.Object = repo
return activitypub.Send(user, &create)
}

View File

@ -14,6 +14,7 @@ import (
"code.gitea.io/gitea/models"
asymkey_model "code.gitea.io/gitea/models/asymkey"
"code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/organization"
packages_model "code.gitea.io/gitea/models/packages"
@ -26,6 +27,7 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/activitypub"
"code.gitea.io/gitea/services/packages"
)
@ -280,3 +282,53 @@ func DeleteAvatar(u *user_model.User) error {
}
return nil
}
// FollowUser marks someone be another's follower.
func FollowUser(userID, followID int64) (err error) {
if userID == followID || user_model.IsFollowing(userID, followID) {
return nil
}
followUser, err := user_model.GetUserByID(followID)
if err != nil {
return
}
if followUser.LoginType == auth.Federated {
// Following remote user
actorUser, err := user_model.GetUserByID(userID)
if err != nil {
return err
}
err = activitypub.Send(actorUser, activitypub.Follow(actorUser, followUser))
if err != nil {
return err
}
}
return user_model.FollowUser(userID, followID)
}
// UnfollowUser unmarks someone as another's follower.
func UnfollowUser(userID, followID int64) (err error) {
if userID == followID || !user_model.IsFollowing(userID, followID) {
return nil
}
followUser, err := user_model.GetUserByID(followID)
if err != nil {
return
}
if followUser.LoginType == auth.Federated {
// Unfollowing remote user
actorUser, err := user_model.GetUserByID(userID)
if err != nil {
return err
}
err = activitypub.Send(actorUser, activitypub.Unfollow(actorUser, followUser))
if err != nil {
return err
}
}
return user_model.UnfollowUser(userID, followID)
}

View File

@ -23,10 +23,182 @@
},
"basePath": "{{AppSubUrl | JSEscape | Safe}}/api/v1",
"paths": {
"/activitypub/repo/{username}/{reponame}": {
"get": {
"produces": [
"application/activity+json"
],
"tags": [
"activitypub"
],
"summary": "Returns the repository",
"operationId": "activitypubRepo",
"parameters": [
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repository",
"name": "reponame",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/ActivityPub"
}
}
}
},
"/activitypub/repo/{username}/{reponame}/followers": {
"get": {
"produces": [
"application/activity+json"
],
"tags": [
"activitypub"
],
"summary": "Returns the followers collection",
"operationId": "activitypubRepoFollowers",
"parameters": [
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repository",
"name": "reponame",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/ActivityPub"
}
}
}
},
"/activitypub/repo/{username}/{reponame}/inbox": {
"post": {
"produces": [
"application/activity+json"
],
"tags": [
"activitypub"
],
"summary": "Send to the inbox",
"operationId": "activitypubRepoInbox",
"parameters": [
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repository",
"name": "reponame",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"$ref": "#/responses/empty"
}
}
}
},
"/activitypub/repo/{username}/{reponame}/outbox": {
"get": {
"produces": [
"application/activity+json"
],
"tags": [
"activitypub"
],
"summary": "Returns the outbox",
"operationId": "activitypubRepoOutbox",
"parameters": [
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repository",
"name": "reponame",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/ActivityPub"
}
}
}
},
"/activitypub/ticket/{username}/{reponame}/{id}": {
"get": {
"produces": [
"application/activity+json"
],
"tags": [
"activitypub"
],
"summary": "Returns the Ticket object for an issue or PR",
"operationId": "forgefedTicket",
"parameters": [
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "reponame",
"in": "path",
"required": true
},
{
"type": "string",
"description": "ID number of the issue or PR",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/ActivityPub"
}
}
}
},
"/activitypub/user/{username}": {
"get": {
"produces": [
"application/json"
"application/activity+json"
],
"tags": [
"activitypub"
@ -49,10 +221,62 @@
}
}
},
"/activitypub/user/{username}/followers": {
"get": {
"produces": [
"application/activity+json"
],
"tags": [
"activitypub"
],
"summary": "Returns the Liked Collection",
"operationId": "activitypubPersonLiked",
"parameters": [
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/ActivityPub"
}
}
}
},
"/activitypub/user/{username}/following": {
"get": {
"produces": [
"application/activity+json"
],
"tags": [
"activitypub"
],
"summary": "Returns the Following Collection",
"operationId": "activitypubPersonFollowing",
"parameters": [
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/ActivityPub"
}
}
}
},
"/activitypub/user/{username}/inbox": {
"post": {
"produces": [
"application/json"
"application/activity+json"
],
"tags": [
"activitypub"
@ -75,6 +299,32 @@
}
}
},
"/activitypub/user/{username}/outbox": {
"get": {
"produces": [
"application/activity+json"
],
"tags": [
"activitypub"
],
"summary": "Returns the Outbox OrderedCollection",
"operationId": "activitypubPersonOutbox",
"parameters": [
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/ActivityPub"
}
}
}
},
"/admin/cron": {
"get": {
"produces": [

View File

@ -13,9 +13,9 @@ import (
"testing"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/activitypub"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/routers"
"code.gitea.io/gitea/services/activitypub"
ap "github.com/go-ap/activitypub"
"github.com/stretchr/testify/assert"
@ -42,10 +42,10 @@ func TestActivityPubPerson(t *testing.T) {
assert.Equal(t, ap.PersonType, person.Type)
assert.Equal(t, username, person.PreferredUsername.String())
keyID := person.GetID().String()
keyID := person.GetLink().String()
assert.Regexp(t, fmt.Sprintf("activitypub/user/%s$", username), keyID)
assert.Regexp(t, fmt.Sprintf("activitypub/user/%s/outbox$", username), person.Outbox.GetID().String())
assert.Regexp(t, fmt.Sprintf("activitypub/user/%s/inbox$", username), person.Inbox.GetID().String())
assert.Regexp(t, fmt.Sprintf("activitypub/user/%s/outbox$", username), person.Outbox.GetLink().String())
assert.Regexp(t, fmt.Sprintf("activitypub/user/%s/inbox$", username), person.Inbox.GetLink().String())
pubKey := person.PublicKey
assert.NotNil(t, pubKey)