Compare commits

...
This repository has been archived on 2024-01-04. You can view files and clone it, but cannot push or open issues or pull requests.

141 Commits

Author SHA1 Message Date
Anthony Wang b990484b03
make fmt 2023-03-16 01:34:48 +00:00
Anthony Wang 3b547c695e
Fix some annoying segfaults 2023-03-15 20:14:00 +00:00
Anthony Wang dbc3eb7fa0
Merge remote-tracking branch 'origin/main' into forgejo-federation 2023-03-12 23:19:57 +00:00
Anthony Wang b4640101f3
Merge remote-tracking branch 'origin/main' into forgejo-federation 2023-03-04 00:03:38 +00:00
Anthony Wang dc20c28328
Merge remote-tracking branch 'origin/main' into forgejo-federation 2023-02-20 22:21:24 +00:00
Anthony Wang 07df0a6b1c
Switch back to mainline go-ap
Marius merged my last go-ap patch (https://lists.sr.ht/~mariusor/activitypub-go/%3Cf23190d1-1376-6ad1-63ee-9c0d291d1eb5%40exozy.me%3E#%3C20230218114214.b5hecpabruk66g7r@slate%3E) so we no longer have to use my custom fork
2023-02-18 17:15:14 +00:00
Anthony Wang e61e9fba59
Merge remote-tracking branch 'origin/main' into forgejo-federation 2023-02-11 23:52:36 +00:00
Anthony Wang 1a54d5e897
Merge remote-tracking branch 'origin/main' into forgejo-federation 2023-02-10 00:24:43 +00:00
Anthony Wang e44c986b86
Use ctx instead of db.DefaultContext 2023-02-02 02:09:31 +00:00
Anthony Wang de771dcd76
make fmt 2023-02-01 21:25:12 +00:00
Anthony Wang 8f42f98d9f
Implement paging for person collections 2023-02-01 21:24:46 +00:00
Anthony Wang d8c1f87b32
Start working on pagination for collections 2023-01-30 03:48:46 +00:00
Anthony Wang 2a296a9082
Merge remote-tracking branch 'origin/main' into forgejo-federation 2023-01-30 03:46:00 +00:00
Anthony Wang 250aad9533
make generate-swagger 2023-01-23 23:54:24 +00:00
Anthony Wang 90afd4f329
Delete buggy and unneeded GET outbox 2023-01-23 20:47:31 +00:00
Anthony Wang 5a9fe9adc4
Properly address outgoing activities 2023-01-22 19:49:28 +00:00
Anthony Wang a86a11f874
Merge remote-tracking branch 'origin/main' into forgejo-federation 2023-01-20 18:14:48 +00:00
Anthony Wang 508a848616
Check if Create object is Note to prevent panic 2023-01-20 17:25:19 +00:00
Anthony Wang 359be39430
Send out notes to followers when starring repos 2023-01-19 02:38:02 +00:00
Anthony Wang 9fc10ce83c
Refactor activitypub.RepositoryIRIToRepository to repo_model.GetRepositoryByIRI 2023-01-16 21:20:50 +00:00
Gusted b89242ee66
Refactoring
- Add extra code paths where `ctx` can be passed and used.
- Remove useless `id` path to get comment note.
- Add extra checks in note API.
- Use `ctx.ParamsInt64` to get the ID from the request.
- Handle `GetXXX` more gracefully by returning 404 when appropiate.
2023-01-13 15:53:46 +01:00
Anthony Wang f8a1dba197
Add missing newline, fix wrong copyright comment on a few files 2023-01-12 22:00:32 +00:00
Anthony Wang d03f5bde7e
Remove some files that were accidentally committed 2023-01-12 21:55:17 +00:00
Anthony Wang 72247b8d49
Fix lint errors 2023-01-12 21:48:05 +00:00
Anthony Wang 7853231ea1
Set user.FullName before creating user 2023-01-12 21:38:45 +00:00
Anthony Wang a5daa36573
Return if user does exist, not if does not exist 2023-01-12 21:35:11 +00:00
Anthony Wang bb38b9737f
Replace personIRIToUser with GetUserByIRI, do a WebFinger request to get correct username 2023-01-12 21:28:35 +00:00
Anthony Wang 3aba06d429
Implement Delete activity for deleting users 2023-01-11 20:07:47 +00:00
Anthony Wang c40dd620a3
Merge remote-tracking branch 'origin/main' into forgejo-federation 2023-01-10 18:27:20 +00:00
Anthony Wang eabba0cf19
Add TODO not about fixing issue creation index 2023-01-01 18:46:42 +00:00
Anthony Wang 58d937eb29
Fix swagger note endpoint typo 2023-01-01 18:44:44 +00:00
Anthony Wang 05d9a6dc1d
Remove unneeded ap.Item() 2023-01-01 18:09:58 +00:00
Anthony Wang a238112529
Use GetIRI() instead of OriginalURL for fetching repo IRI 2022-12-31 18:32:22 +00:00
Anthony Wang d475724cc5
Use GetIRI() instead of LoginName to fetch user IRI 2022-12-31 18:29:53 +00:00
Anthony Wang 46e35c61ec
Make Create Actor a Person 2022-12-31 18:28:33 +00:00
Anthony Wang 13b09cef50
Move Create() to activities.go 2022-12-31 18:27:38 +00:00
Anthony Wang be2a3375f3
Set Actor on Create activities 2022-12-31 18:26:46 +00:00
Anthony Wang adb161c6ee
Refactor activities.go 2022-12-31 18:22:11 +00:00
Anthony Wang b4cb2356d8
Clean up IRI processing code 2022-12-31 18:01:12 +00:00
Anthony Wang 258fd151da
Fix lint errors 2022-12-31 17:58:01 +00:00
Anthony Wang 2d7e644b3a
make fmt 2022-12-30 00:20:38 +00:00
Anthony Wang 121be02f37
Merge remote-tracking branch 'origin/main' into forgejo-federation 2022-12-30 00:20:02 +00:00
Anthony Wang be381fe16b
Implement unstarring 2022-12-30 00:07:00 +00:00
Anthony Wang cc4901ccec
Federated starring and code cleanup 2022-12-29 23:13:48 +00:00
Anthony Wang af572f7b2b
Fix typo in activities.go comments 2022-12-26 17:43:31 +00:00
Anthony Wang e8c3886995
Merge remote-tracking branch 'origin/main' into forgejo-federation 2022-12-22 21:12:50 +00:00
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
55 changed files with 3051 additions and 105 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

@ -2445,7 +2445,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.
;;

4
go.mod
View File

@ -35,7 +35,7 @@ require (
github.com/felixge/fgprof v0.9.3
github.com/fsnotify/fsnotify v1.6.0
github.com/gliderlabs/ssh v0.3.5
github.com/go-ap/activitypub v0.0.0-20230218112952-bfb607b04799
github.com/go-ap/activitypub v0.0.0-20230307141717-3566110d71a0
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73
github.com/go-chi/chi/v5 v5.0.8
github.com/go-chi/cors v1.2.1
@ -97,6 +97,7 @@ require (
github.com/tstranex/u2f v1.0.0
github.com/unrolled/render v1.5.0
github.com/urfave/cli v1.22.12
github.com/valyala/fastjson v1.6.4
github.com/xanzy/go-gitlab v0.80.2
github.com/xeipuuv/gojsonschema v1.2.0
github.com/yohcop/openid-go v1.0.0
@ -260,7 +261,6 @@ require (
github.com/toqueteos/webbrowser v1.2.0 // indirect
github.com/ulikunitz/xz v0.5.11 // indirect
github.com/unknwon/com v1.0.1 // indirect
github.com/valyala/fastjson v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect

4
go.sum
View File

@ -352,8 +352,8 @@ 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-20230218112952-bfb607b04799 h1:zVZaYt1h4yWL7uRHvq2StewCu4ObtS+ws9gGgoZJ+2s=
github.com/go-ap/activitypub v0.0.0-20230218112952-bfb607b04799/go.mod h1:1oVD0h0aPT3OEE1ZoSUoym/UGKzxe+e0y8K2AkQ1Hqs=
github.com/go-ap/activitypub v0.0.0-20230307141717-3566110d71a0 h1:ll+jcwBW55vQDUV3jHuua/0wqjTm2GIh/iP1wwjbPSc=
github.com/go-ap/activitypub v0.0.0-20230307141717-3566110d71a0/go.mod h1:1oVD0h0aPT3OEE1ZoSUoym/UGKzxe+e0y8K2AkQ1Hqs=
github.com/go-ap/errors v0.0.0-20221205040414-01c1adfc98ea h1:ywGtLGVjJjMrq4mu35Qmu+NtlhlTk/gTayE6Bb4tQZk=
github.com/go-ap/errors v0.0.0-20221205040414-01c1adfc98ea/go.mod h1:SaTNjEEkp0q+w3pUS1ccyEL/lUrHteORlDq/e21mCc8=
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 h1:GMKIYXyXPGIp+hYiWOhfqK4A023HdgisDT4YGgf99mw=

View File

@ -22,14 +22,15 @@ type Type int
// Note: new type must append to the end of list to maintain compatibility.
const (
NoType Type = iota
Plain // 1
LDAP // 2
SMTP // 3
PAM // 4
DLDAP // 5
OAuth2 // 6
SSPI // 7
NoType Type = iota
Plain // 1
LDAP // 2
SMTP // 3
PAM // 4
DLDAP // 5
OAuth2 // 6
SSPI // 7
Federated // 8
)
// String returns the string name of the LoginType
@ -178,6 +179,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

@ -9,6 +9,7 @@ import (
"context"
"fmt"
"strconv"
"strings"
"unicode/utf8"
"code.gitea.io/gitea/models/db"
@ -21,6 +22,7 @@ import (
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"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"
@ -1249,3 +1251,18 @@ func FixCommentTypeLabelWithOutsideLabels(ctx context.Context) (int64, error) {
func (c *Comment) HasOriginalAuthor() bool {
return c.OriginalAuthor != "" && c.OriginalAuthorID != 0
}
func (c *Comment) GetIRI(ctx context.Context) string {
err := c.LoadIssue(ctx)
if err != nil {
return ""
}
err = c.Issue.LoadRepo(ctx)
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.ID, 10)
}

View File

@ -9,6 +9,7 @@ import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"code.gitea.io/gitea/models/db"
@ -24,6 +25,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"
@ -2498,3 +2500,14 @@ func DeleteOrphanedIssues(ctx context.Context) error {
func (issue *Issue) HasOriginalAuthor() bool {
return issue.OriginalAuthor != "" && issue.OriginalAuthorID != 0
}
func (issue *Issue) GetIRI(ctx context.Context) string {
err := issue.LoadRepo(ctx)
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

@ -5,6 +5,7 @@ package repo
import (
"context"
"errors"
"fmt"
"html/template"
"net"
@ -670,6 +671,28 @@ func GetRepositoryByID(ctx context.Context, id int64) (*Repository, error) {
return repo, nil
}
// GetRepositoryByIRI returns the repository by given IRI if exists.
func GetRepositoryByIRI(ctx context.Context, iri string) (*Repository, error) {
iriSplit := strings.Split(iri, "/")
if len(iriSplit) < 5 {
return nil, errors.New("not a Repository actor IRI")
}
if iriSplit[2] == setting.Domain {
// Local repository
return GetRepositoryByOwnerAndName(ctx, iriSplit[len(iriSplit)-2], iriSplit[len(iriSplit)-1])
}
repo := &Repository{
OriginalURL: iri,
}
has, err := db.GetEngine(ctx).Get(repo)
if err != nil {
return nil, err
} else if !has {
return nil, ErrRepoNotExist{0, 0, "", ""}
}
return repo, err
}
// GetRepositoriesMapByIDs returns the repositories by given id slice.
func GetRepositoriesMapByIDs(ids []int64) (map[int64]*Repository, error) {
repos := make(map[int64]*Repository, len(ids))
@ -772,3 +795,10 @@ func FixNullArchivedRepository(ctx context.Context) (int64, error) {
IsArchived: false,
})
}
func (repo *Repository) GetIRI() string {
if strings.Contains(repo.OwnerName, "@") {
return repo.OriginalURL
}
return setting.AppURL + "api/v1/activitypub/repo/" + repo.OwnerName + "/" + repo.Name
}

View File

@ -100,6 +100,15 @@ func (u *User) AvatarLink(ctx context.Context) string {
return link
}
// AvatarFullLinkWithSize returns the full avatar link with size and http host
func (u *User) AvatarFullLinkWithSize(ctx context.Context, size int) string {
link := u.AvatarLinkWithSize(ctx, 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

@ -7,6 +7,7 @@ package user
import (
"context"
"encoding/hex"
"errors"
"fmt"
"net/url"
"os"
@ -968,6 +969,29 @@ func GetUserByName(ctx context.Context, name string) (*User, error) {
return u, nil
}
// GetUserByIRI returns user by given IRI.
func GetUserByIRI(ctx context.Context, iri string) (*User, error) {
if len(iri) == 0 {
return nil, ErrUserNotExist{0, iri, 0}
}
iriSplit := strings.Split(iri, "/")
if len(iriSplit) < 4 {
return nil, errors.New("not a Person actor IRI")
}
if iriSplit[2] == setting.Domain {
// Local user
return GetUserByName(ctx, iriSplit[len(iriSplit)-1])
}
u := &User{LoginName: iri}
has, err := db.GetEngine(ctx).Get(u)
if err != nil {
return nil, err
} else if !has {
return nil, ErrUserNotExist{0, iri, 0}
}
return u, nil
}
// GetUserEmailsByNames returns a list of e-mails corresponds to names of users
// that have their email notifications set to enabled or onmention.
func GetUserEmailsByNames(ctx context.Context, names []string) []string {
@ -1282,3 +1306,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,98 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
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.JSONWriteItemProp(&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)
}

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

@ -0,0 +1,105 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
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.JSONWriteTimeProp(&b, "created", c.Created)
}
if !c.Committed.IsZero() {
ap.JSONWriteTimeProp(&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,97 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
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)
}

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

@ -0,0 +1,110 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
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.JSONWriteItemProp(&b, "target", p.Target)
}
if p.HashBefore != nil {
ap.JSONWriteItemProp(&b, "hashBefore", p.HashBefore)
}
if p.HashAfter != nil {
ap.JSONWriteItemProp(&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,111 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
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.JSONWriteItemProp(&b, "team", r.Team)
}
if r.Forks != nil {
ap.JSONWriteItemProp(&b, "forks", r.Forks)
}
if r.ForkedFrom != nil {
ap.JSONWriteItemProp(&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,183 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
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)
}
})
}
}

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

@ -0,0 +1,133 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
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.JSONWriteItemCollectionProp(&b, "dependants", t.Dependants)
}
if t.Dependencies != nil {
ap.JSONWriteItemCollectionProp(&b, "dependencies", t.Dependencies)
}
ap.JSONWriteBoolProp(&b, "isResolved", t.IsResolved)
if t.ResolvedBy != nil {
ap.JSONWriteItemProp(&b, "resolvedBy", t.ResolvedBy)
}
if !t.Resolved.IsZero() {
ap.JSONWriteTimeProp(&b, "resolved", t.Resolved)
}
if t.Origin != nil {
ap.JSONWriteItemProp(&b, "origin", t.Origin)
}
if t.Target != nil {
ap.JSONWriteItemProp(&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

@ -22,7 +22,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

@ -92,7 +92,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,92 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package activitypub
import (
"net/http"
"strconv"
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/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) {
resp, err := activitypub.Fetch(ctx.Req.URL.Query().Get("uri"))
if err != nil {
ctx.ServerError("Fetch", err)
return
}
ap.ItemTyperFunc = forgefed.GetItemByType
ap.JSONItemUnmarshal = forgefed.JSONUnmarshalerFn
ap.IsNotEmpty = 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("CreatePerson", err)
return
}
user, err := user_model.GetUserByIRI(ctx, object.GetLink().String())
if err != nil {
ctx.ServerError("GetUserByIRI", err)
return
}
ctx.Redirect(setting.AppURL + user.Name)
case forgefed.RepositoryType:
// Federated repository
err = forgefed.OnRepository(object, func(r *forgefed.Repository) error {
return createRepository(ctx, r)
})
if err != nil {
ctx.ServerError("CreateRepository", err)
return
}
repo, err := repo_model.GetRepositoryByIRI(ctx, object.GetLink().String())
if err != nil {
ctx.ServerError("RepositoryIRIToName", err)
return
}
ctx.Redirect(setting.AppURL + repo.OwnerName + "/" + repo.Name)
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,308 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package activitypub
import (
"context"
"errors"
"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"
"code.gitea.io/gitea/modules/json"
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 {
_, err := user_model.GetUserByIRI(ctx, person.GetLink().String())
if !user_model.IsErrUserNotExist(err) {
// User already exists
return err
}
personIRISplit := strings.Split(person.GetLink().String(), "/")
if len(personIRISplit) < 4 {
return errors.New("not a Person actor IRI")
}
// Get instance by taking the domain of the IRI
instance := personIRISplit[2]
if instance == setting.Domain {
// Local user
return nil
}
// Send a WebFinger request to get the username
resp, err := activitypub.Fetch("https://" + instance + "/.well-known/webfinger?resource=" + person.GetLink().String())
if err != nil {
return err
}
var data activitypub.WebfingerJRD
err = json.Unmarshal(resp, &data)
if err != nil {
return err
}
subjectSplit := strings.Split(data.Subject, ":")
if subjectSplit[0] != "acct" {
return errors.New("subject is not an acct URI")
}
name := subjectSplit[1]
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,
Email: email,
LoginType: auth.Federated,
LoginName: person.GetLink().String(),
EmailNotificationsPreference: user_model.EmailNotificationsDisabled,
}
if person.Name != nil {
user.FullName = person.Name.String()
}
err = user_model.CreateUser(user)
if err != nil {
return err
}
if person.Icon != nil {
// Fetch and save user icon
icon, err := ap.ToObject(person.Icon)
if err != nil {
return err
}
body, err := activitypub.Fetch(icon.URL.GetLink().String())
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
}
// Set public key
return user_model.SetUserSetting(user.ID, user_model.UserActivityPubPubPem, person.PublicKey.PublicKeyPem)
}
// Create a new federated user from a Person IRI
func createPersonFromIRI(ctx context.Context, personIRI ap.IRI) error {
object, err := activitypub.FetchObject(personIRI.String())
if err != nil {
return err
}
return ap.OnActor(object, func(p *ap.Person) error {
return createPerson(ctx, p)
})
}
// Create a new federated repo from a Repository object
func createRepository(ctx context.Context, repository *forgefed.Repository) error {
user, err := user_model.GetUserByIRI(ctx, repository.AttributedTo.GetLink().String())
if user_model.IsErrUserNotExist(err) {
// TODO: This should probably return the created user too
err := createPersonFromIRI(ctx, repository.AttributedTo.GetLink())
if err != nil {
return err
}
user, err = user_model.GetUserByIRI(ctx, repository.AttributedTo.GetLink().String())
if err != nil {
return err
}
} else if err != nil {
return err
}
// Check if repo exists
_, err = repo_model.GetRepositoryByIRI(ctx, repository.GetLink().String())
if !repo_model.IsErrRepoNotExist(err) {
return err
}
repo, err := repo_service.CreateRepository(ctx, 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 := repo_model.GetRepositoryByIRI(ctx, repository.ForkedFrom.GetLink().String())
if err != nil {
return err
}
repo.ForkID = forkedFrom.ID
}
return nil
}
// Create a new federated repo from a Repository IRI
func createRepositoryFromIRI(ctx context.Context, repoIRI ap.IRI) error {
object, err := activitypub.FetchObject(repoIRI.String())
if err != nil {
return err
}
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 {
// TODO: don't call this function here
err := createRepositoryFromIRI(ctx, ticket.Context.GetLink())
if err != nil {
return err
}
// Construct issue
user, err := user_model.GetUserByIRI(ctx, ticket.AttributedTo.GetLink().String())
if err != nil {
return err
}
repo, err := repo_model.GetRepositoryByIRI(ctx, 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, // TODO: 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(ctx, repo, issue, nil, nil, nil)
}
// Create a pull request
func createPullRequest(ctx context.Context, ticket *forgefed.Ticket) error {
// TODO: don't call this function here
err := createRepositoryFromIRI(ctx, ticket.Context.GetLink())
if err != nil {
return err
}
user, err := user_model.GetUserByIRI(ctx, ticket.AttributedTo.GetLink().String())
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(ctx, 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(ctx, 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 {
// Make sure repo exists
user, err := user_model.GetUserByIRI(ctx, note.AttributedTo.GetLink().String())
if err != nil {
return err
}
username, reponame, idx, err := activitypub.TicketIRIToName(note.Context.GetLink())
if err != nil {
return err
}
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, username, reponame)
if err != nil {
return err
}
issue, err := issues_model.GetIssueByIndex(repo.ID, idx)
if err != nil {
return err
}
_, err = issues_model.CreateComment(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,30 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package activitypub
import (
"context"
user_model "code.gitea.io/gitea/models/user"
user_service "code.gitea.io/gitea/services/user"
ap "github.com/go-ap/activitypub"
)
// Process an incoming Delete activity
func delete(ctx context.Context, delete ap.Delete) error {
actorIRI := delete.Actor.GetLink()
objectIRI := delete.Object.GetLink()
// Make sure actor matches the object getting deleted
if actorIRI != objectIRI {
return nil
}
// Object is the user getting deleted
objectUser, err := user_model.GetUserByIRI(ctx, objectIRI.String())
if err != nil {
return err
}
return user_service.DeleteUser(ctx, objectUser, true)
}

View File

@ -0,0 +1,71 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package activitypub
import (
"context"
"errors"
"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 := user_model.GetUserByIRI(ctx, actorIRI.String())
if err != nil {
return err
}
// Object is the user being followed
objectIRI := follow.Object.GetLink()
objectUser, err := user_model.GetUserByIRI(ctx, objectIRI.String())
// 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())}
accept.Object = follow
return activitypub.Send(ctx, objectUser, accept)
}
// Process an incoming Undo follow activity
func unfollow(ctx context.Context, unfollow ap.Undo) error {
// Object contains the follow
follow, ok := unfollow.Object.(*ap.Follow)
if !ok {
return errors.New("could not cast object to follow")
}
// Actor is the user performing the undo follow
actorIRI := follow.Actor.GetLink()
actorUser, err := user_model.GetUserByIRI(ctx, actorIRI.String())
if err != nil {
return err
}
// Object is the user being unfollowed
objectIRI := follow.Object.GetLink()
objectUser, err := user_model.GetUserByIRI(ctx, objectIRI.String())
// 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,73 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package activitypub
import (
"net/http"
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}/{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: noteid
// in: path
// description: ID number of the comment
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/ActivityPub"
// "204":
// "$ref": "#/responses/empty"
// "404":
// "$ref": "#/responses/notFound"
comment, err := issues_model.GetCommentByID(ctx, ctx.ParamsInt64("noteid"))
if err != nil {
if issues_model.IsErrCommentNotExist(err) {
ctx.NotFound(err)
} else {
ctx.Error(http.StatusInternalServerError, "GetCommentByID", err)
}
return
}
// Ensure the comment comes from the specified repository.
if comment.Issue.RepoID != ctx.Repo.Repository.ID {
ctx.Status(http.StatusNotFound)
return
}
// Only allow comments and not events.
if comment.Type != issues_model.CommentTypeComment {
ctx.Status(http.StatusNoContent)
return
}
note, err := activitypub.Note(ctx, comment)
if err != nil {
ctx.ServerError("Note", err)
return
}
response(ctx, note)
}

View File

@ -1,19 +1,22 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package activitypub
import (
"fmt"
"io"
"net/http"
"strings"
"code.gitea.io/gitea/modules/activitypub"
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/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
@ -22,7 +25,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
@ -33,8 +36,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))
@ -51,19 +54,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(ctx)),
URL: ap.IRI(ctx.ContextUser.AvatarFullLinkWithSize(ctx, 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)
@ -71,16 +77,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
@ -89,7 +86,7 @@ func PersonInbox(ctx *context.APIContext) {
// ---
// summary: Send to the inbox
// produces:
// - application/json
// - application/activity+json
// parameters:
// - name: username
// in: path
@ -97,8 +94,173 @@ func PersonInbox(ctx *context.APIContext) {
// type: string
// required: true
// responses:
// "204":
// "202":
// "$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)
err = checkActivityAndKeyID(activity, keyID)
if err != nil {
ctx.ServerError("keyID does not match activity", err)
return
}
// Process activity
switch activity.Type {
case ap.FollowType:
// Following a user
err = follow(ctx, activity)
case ap.UndoType:
// Unfollowing a user
err = unfollow(ctx, activity)
case ap.CreateType:
if activity.Object.GetType() == ap.NoteType {
// 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)
})
}
case ap.DeleteType:
// Deleting a user
err = delete(ctx, activity)
default:
err = fmt.Errorf("unsupported ActivityStreams activity type: %s", activity.GetType())
}
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:
// "501":
// "$ref": "#/responses/empty"
ctx.Status(http.StatusNotImplemented)
}
// 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"
listOptions := utils.GetListOptions(ctx)
users, count, err := user_model.GetUserFollowing(ctx, ctx.ContextUser, ctx.Doer, listOptions)
if err != nil {
ctx.ServerError("GetUserFollowing", err)
return
}
items := make([]string, 0)
for _, user := range users {
items = append(items, user.GetIRI())
}
responseCollection(ctx, ctx.ContextUser.GetIRI()+"/following", listOptions, items, count)
}
// 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"
listOptions := utils.GetListOptions(ctx)
users, count, err := user_model.GetUserFollowers(ctx, ctx.ContextUser, ctx.Doer, listOptions)
if err != nil {
ctx.ServerError("GetUserFollowers", err)
return
}
items := make([]string, 0)
for _, user := range users {
items = append(items, user.GetIRI())
}
responseCollection(ctx, ctx.ContextUser.GetIRI()+"/followers", listOptions, items, count)
}
// 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"
listOptions := utils.GetListOptions(ctx)
repos, count, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{
ListOptions: listOptions,
Actor: ctx.Doer,
Private: ctx.IsSigned,
StarredByID: ctx.ContextUser.ID,
})
if err != nil {
ctx.ServerError("GetUserStarred", err)
return
}
items := make([]string, 0)
for _, repo := range repos {
items = append(items, repo.GetIRI())
}
responseCollection(ctx, ctx.ContextUser.GetIRI()+"/liked", listOptions, items, count)
}

View File

@ -0,0 +1,201 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package activitypub
import (
"fmt"
"io"
"net/http"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/forgefed"
"code.gitea.io/gitea/modules/setting"
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:
// "202":
// "$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
}
ap.ItemTyperFunc = forgefed.GetItemByType
ap.JSONItemUnmarshal = forgefed.JSONUnmarshalerFn
ap.IsNotEmpty = 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)
err = checkActivityAndKeyID(activity, keyID)
if err != nil {
ctx.ServerError("keyID does not match activity", 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:
err = fmt.Errorf("unsupported ActivityStreams object type: %s", activity.Object.GetType())
}
case ap.LikeType:
// Starring a repo
err = star(ctx, activity)
case ap.UndoType:
// Unstarring a repo
err = unstar(ctx, activity)
default:
err = fmt.Errorf("unsupported ActivityStreams activity type: %s", activity.GetType())
}
if err != nil {
ctx.ServerError("Could not process activity", err)
return
}
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:
// "501":
// "$ref": "#/responses/empty"
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

@ -1,4 +1,4 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package activitypub
@ -7,29 +7,27 @@ import (
"crypto"
"crypto/x509"
"encoding/pem"
"errors"
"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"
)
func getPublicKeyFromResponse(b []byte, keyID *url.URL) (p crypto.PublicKey, err error) {
person := ap.PersonNew(ap.IRI(keyID.String()))
func getPublicKeyFromResponse(b []byte, keyID string) (p crypto.PublicKey, err error) {
person := ap.PersonNew(ap.IRI(keyID))
err = person.UnmarshalJSON(b)
if err != nil {
err = fmt.Errorf("ActivityStreams type cannot be converted to one known to have publicKey property: %w", err)
return
}
pubKey := person.PublicKey
if pubKey.ID.String() != keyID.String() {
if pubKey.ID.String() != keyID {
err = fmt.Errorf("cannot find publicKey with id: %s in %s", keyID, string(b))
return
}
@ -43,49 +41,45 @@ 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)
if err != nil {
return
}
ID := v.KeyId()
idIRI, err := url.Parse(ID)
v, ID, err := getKeyID(r)
if err != nil {
return
}
// 2. Fetch the public key of the other actor
b, err := fetch(idIRI)
b, err := activitypub.Fetch(ID)
if err != nil {
return
}
pubKey, err := getPublicKeyFromResponse(b, idIRI)
pubKey, err := getPublicKeyFromResponse(b, ID)
if err != nil {
return
}
// 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
var person ap.Person
err = person.UnmarshalJSON(b)
if err != nil {
return
}
err = createPerson(ctx, &person)
return authenticated, err
}
@ -99,3 +93,29 @@ func ReqHTTPSignature() func(ctx *gitea_context.APIContext) {
}
}
}
// Check if the keyID matches the activity to prevent impersonation
func checkActivityAndKeyID(activity ap.Activity, keyID string) error {
if activity.Actor != nil && keyID != activity.Actor.GetLink().String()+"#main-key" {
return errors.New("actor does not match HTTP signature keyID")
}
if activity.AttributedTo != nil && keyID != activity.AttributedTo.GetLink().String()+"#main-key" {
return errors.New("attributedTo does not match HTTP signature keyID")
}
if activity.Object == nil {
return errors.New("activity does not contain object")
}
if activity.Type == ap.UndoType {
return ap.OnActivity(activity.Object, func(a *ap.Activity) error {
if a.Actor != nil && keyID != a.Actor.GetLink().String()+"#main-key" {
// TODO: This doesn't necessarily mean impersonation since the object might be created by someone else
return errors.New("actor does not match HTTP signature keyID")
}
if a.AttributedTo != nil && keyID != a.AttributedTo.GetLink().String()+"#main-key" {
return errors.New("attributedTo does not match HTTP signature keyID")
}
return nil
})
}
return nil
}

View File

@ -0,0 +1,65 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package activitypub
import (
"fmt"
"net/http"
"code.gitea.io/gitea/models/db"
"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 a ActivityStreams Collection
func responseCollection(ctx *context.APIContext, iri string, listOptions db.ListOptions, items []string, count int64) {
collection := ap.OrderedCollectionNew(ap.IRI(iri))
collection.First = ap.IRI(iri + "?page=1")
collection.TotalItems = uint(count)
if listOptions.Page == 0 {
response(ctx, collection)
return
}
page := ap.OrderedCollectionPageNew(collection)
page.ID = ap.IRI(fmt.Sprintf("%s?page=%d", iri, listOptions.Page))
if listOptions.Page > 1 {
page.Prev = ap.IRI(fmt.Sprintf("%s?page=%d", iri, listOptions.Page-1))
}
if listOptions.Page*listOptions.PageSize < int(count) {
page.Next = ap.IRI(fmt.Sprintf("%s?page=%d", iri, listOptions.Page+1))
}
for _, item := range items {
err := page.OrderedItems.Append(ap.IRI(item))
if err != nil {
ctx.ServerError("Append", err)
}
}
response(ctx, page)
}
// 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,45 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package activitypub
import (
"context"
"errors"
"strings"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
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 := user_model.GetUserByIRI(ctx, like.Actor.GetLink().String())
if err != nil {
return
}
repo, err := repo_model.GetRepositoryByIRI(ctx, like.Object.GetLink().String())
if err != nil || strings.Contains(repo.Name, "@") || repo.IsPrivate {
return
}
return repo_model.StarRepo(user.ID, repo.ID, true)
}
// Process an Undo Like activity to unstar a repository
func unstar(ctx context.Context, unlike ap.Undo) (err error) {
like, ok := unlike.Object.(*ap.Like)
if !ok {
return errors.New("could not cast object to like")
}
user, err := user_model.GetUserByIRI(ctx, like.Actor.GetLink().String())
if err != nil {
return
}
repo, err := repo_model.GetRepositoryByIRI(ctx, like.Object.GetLink().String())
if err != nil || strings.Contains(repo.Name, "@") || repo.IsPrivate {
return
}
return repo_model.StarRepo(user.ID, repo.ID, false)
}

View File

@ -0,0 +1,57 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package activitypub
import (
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"
// "404":
// "$ref": "#/responses/notFound"
issue, err := issues_model.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64("id"))
if err != nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.NotFound()
} else {
ctx.ServerError("GetIssueByIndex", err)
}
return
}
ticket, err := activitypub.Ticket(ctx, issue)
if err != nil {
ctx.ServerError("Ticket", err)
return
}
response(ctx, ticket)
}

View File

@ -702,12 +702,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}/{noteid}", repoAssignment(), activitypub.Note)
})
}
m.Get("/signing-key.gpg", misc.SigningKey)

View File

@ -651,7 +651,7 @@ func CreateIssue(ctx *context.APIContext) {
form.Labels = make([]int64, 0)
}
if err := issue_service.NewIssue(ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs); err != nil {
if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs); err != nil {
if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err)
return

View File

@ -12,6 +12,7 @@ import (
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/routers/api/v1/utils"
"code.gitea.io/gitea/services/convert"
user_service "code.gitea.io/gitea/services/user"
)
func responseAPIUsers(ctx *context.APIContext, users []*user_model.User) {
@ -218,7 +219,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, ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
ctx.Error(http.StatusInternalServerError, "FollowUser", err)
return
}
@ -240,7 +241,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, ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
ctx.Error(http.StatusInternalServerError, "UnfollowUser", err)
return
}

View File

@ -16,6 +16,7 @@ import (
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/routers/api/v1/utils"
"code.gitea.io/gitea/services/convert"
repo_service "code.gitea.io/gitea/services/repository"
)
// getStarredRepos returns the repos that the user with the specified userID has
@ -151,7 +152,7 @@ func Star(ctx *context.APIContext) {
// "204":
// "$ref": "#/responses/empty"
err := repo_model.StarRepo(ctx.Doer.ID, ctx.Repo.Repository.ID, true)
err := repo_service.StarRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, true)
if err != nil {
ctx.Error(http.StatusInternalServerError, "StarRepo", err)
return
@ -179,7 +180,7 @@ func Unstar(ctx *context.APIContext) {
// "204":
// "$ref": "#/responses/empty"
err := repo_model.StarRepo(ctx.Doer.ID, ctx.Repo.Repository.ID, false)
err := repo_service.StarRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, false)
if err != nil {
ctx.Error(http.StatusInternalServerError, "StarRepo", err)
return

View File

@ -1133,7 +1133,7 @@ func NewIssuePost(ctx *context.Context) {
Ref: form.Ref,
}
if err := issue_service.NewIssue(repo, issue, labelIDs, attachments, assigneeIDs); err != nil {
if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs); err != nil {
if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error())
return

View File

@ -287,9 +287,9 @@ func Action(ctx *context.Context) {
case "unwatch":
err = repo_model.WatchRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, false)
case "star":
err = repo_model.StarRepo(ctx.Doer.ID, ctx.Repo.Repository.ID, true)
err = repo_service.StarRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, true)
case "unstar":
err = repo_model.StarRepo(ctx.Doer.ID, ctx.Repo.Repository.ID, false)
err = repo_service.StarRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, false)
case "accept_transfer":
err = acceptOrRejectRepoTransfer(ctx, true)
case "reject_transfer":

View File

@ -22,6 +22,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
@ -318,9 +319,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, ctx.Doer.ID, ctx.ContextUser.ID)
case "unfollow":
err = user_model.UnfollowUser(ctx.Doer.ID, ctx.ContextUser.ID)
err = user_service.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
}
if err != nil {

View File

@ -13,25 +13,11 @@ import (
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/activitypub"
)
// https://datatracker.ietf.org/doc/html/draft-ietf-appsawg-webfinger-14#section-4.4
type webfingerJRD struct {
Subject string `json:"subject,omitempty"`
Aliases []string `json:"aliases,omitempty"`
Properties map[string]interface{} `json:"properties,omitempty"`
Links []*webfingerLink `json:"links,omitempty"`
}
type webfingerLink struct {
Rel string `json:"rel,omitempty"`
Type string `json:"type,omitempty"`
Href string `json:"href,omitempty"`
Titles map[string]string `json:"titles,omitempty"`
Properties map[string]interface{} `json:"properties,omitempty"`
}
// WebfingerQuery returns information about a resource
// https://datatracker.ietf.org/doc/html/rfc7565
func WebfingerQuery(ctx *context.Context) {
@ -64,6 +50,8 @@ func WebfingerQuery(ctx *context.Context) {
if u != nil && u.KeepEmailPrivate {
err = user_model.ErrUserNotExist{}
}
case "https":
u, err = user_model.GetUserByIRI(ctx, ctx.FormString("resource"))
default:
ctx.Error(http.StatusBadRequest)
return
@ -91,7 +79,7 @@ func WebfingerQuery(ctx *context.Context) {
aliases = append(aliases, fmt.Sprintf("mailto:%s", u.Email))
}
links := []*webfingerLink{
links := []*activitypub.WebfingerLink{
{
Rel: "http://webfinger.net/rel/profile-page",
Type: "text/html",
@ -106,10 +94,14 @@ 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", "*")
ctx.JSON(http.StatusOK, &webfingerJRD{
ctx.JSON(http.StatusOK, &activitypub.WebfingerJRD{
Subject: fmt.Sprintf("acct:%s@%s", url.QueryEscape(u.Name), appURL.Host),
Aliases: aliases,
Links: links,

View File

@ -0,0 +1,62 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package activitypub
import (
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/forgefed"
ap "github.com/go-ap/activitypub"
)
// Create Follow activity
func Follow(actorUser, followUser *user_model.User) *ap.Follow {
return &ap.Follow{
Type: ap.FollowType,
Actor: ap.PersonNew(ap.IRI(actorUser.GetIRI())),
Object: ap.PersonNew(ap.IRI(followUser.GetIRI())),
To: ap.ItemCollection{ap.IRI(followUser.GetIRI())},
}
}
// Create Undo Follow activity
func Unfollow(actorUser, followUser *user_model.User) *ap.Undo {
return &ap.Undo{
Type: ap.UndoType,
Actor: ap.PersonNew(ap.IRI(actorUser.GetIRI())),
Object: Follow(actorUser, followUser),
To: ap.ItemCollection{ap.IRI(followUser.GetIRI())},
}
}
// Create Like activity
func Star(user *user_model.User, repo *repo_model.Repository) *ap.Like {
return &ap.Like{
Type: ap.LikeType,
Actor: ap.PersonNew(ap.IRI(user.GetIRI())),
Object: forgefed.RepositoryNew(ap.IRI(repo.GetIRI())),
To: ap.ItemCollection{ap.IRI(repo.GetIRI())},
}
}
// Create Undo Like activity
func Unstar(user *user_model.User, repo *repo_model.Repository) *ap.Undo {
return &ap.Undo{
Type: ap.UndoType,
Actor: ap.PersonNew(ap.IRI(user.GetIRI())),
Object: Star(user, repo),
To: ap.ItemCollection{ap.IRI(repo.GetIRI())},
}
}
// Create Create activity
func Create(user *user_model.User, object ap.ObjectOrLink, to string) *ap.Create {
return &ap.Create{
Type: ap.CreateType,
Actor: ap.PersonNew(ap.IRI(user.GetIRI())),
Object: object,
To: ap.ItemCollection{ap.IRI(to)},
}
}

View File

@ -0,0 +1,55 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package activitypub
import (
"errors"
"strconv"
"strings"
"code.gitea.io/gitea/modules/setting"
ap "github.com/go-ap/activitypub"
)
// 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,84 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package activitypub
import (
"context"
"strconv"
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(ctx context.Context, comment *issues_model.Comment) (*ap.Note, error) {
err := comment.LoadPoster(ctx)
if err != nil {
return nil, err
}
err = comment.LoadIssue(ctx)
if err != nil {
return nil, err
}
note := ap.Note{
Type: ap.NoteType,
ID: ap.IRI(comment.GetIRI(ctx)),
AttributedTo: ap.IRI(comment.Poster.GetIRI()),
Context: ap.IRI(comment.Issue.GetIRI(ctx)),
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(ctx context.Context, issue *issues_model.Issue) (*forgefed.Ticket, error) {
iri := issue.GetIRI(ctx)
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(ctx)
if err != nil {
return nil, err
}
ticket.Context = ap.IRI(issue.Repo.GetIRI())
err = issue.LoadPoster(ctx)
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,101 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package activitypub
import (
"context"
"fmt"
"io"
"net/http"
"code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
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 URL as binary
func Fetch(iri string) (b []byte, err error) {
req := httplib.NewRequest(iri, 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
}
// Fetch a remote ActivityStreams object as an object
func FetchObject(iri string) (ap.ObjectOrLink, error) {
resp, err := Fetch(iri)
if err != nil {
return nil, err
}
ap.ItemTyperFunc = forgefed.GetItemByType
ap.JSONItemUnmarshal = forgefed.JSONUnmarshalerFn
ap.IsNotEmpty = forgefed.NotEmpty
return ap.UnmarshalJSON(resp)
}
// Send an activity
func Send(ctx context.Context, 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
}
// Construt list of recipients
recipients := []string{}
for _, to := range activity.To {
if to.GetLink().String() == user.GetIRI()+"/followers" {
followers, count, err := user_model.GetUserFollowers(ctx, user, user, db.ListOptions{})
if err != nil {
return err
}
for i := int64(0); i < count; i++ {
if followers[i].LoginType == auth.Federated {
recipients = append(recipients, followers[i].GetIRI())
}
}
} else {
recipients = append(recipients, to.GetLink().String())
}
}
// Send out activity to recipients
for _, recipient := range recipients {
client, err := NewClient(user, user.GetIRI()+"#main-key")
if err != nil {
return err
}
resp, err := client.Post(binary, recipient)
if err != nil {
return err
}
respBody, err := io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize))
if err != nil {
return err
}
log.Trace("Response from sending activity", string(respBody))
}
return nil
}

View File

@ -0,0 +1,20 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package activitypub
type WebfingerJRD struct {
Subject string `json:"subject,omitempty"`
Aliases []string `json:"aliases,omitempty"`
Properties map[string]interface{} `json:"properties,omitempty"`
Links []*WebfingerLink `json:"links,omitempty"`
}
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"`
}

View File

@ -6,6 +6,7 @@ package issue
import (
"context"
"fmt"
"strings"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
@ -13,6 +14,7 @@ import (
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"
)
// CreateComment creates comment of issue or commit.
@ -78,6 +80,19 @@ func CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_m
return nil, err
}
if strings.Contains(repo.OwnerName, "@") {
// Federated comment
note, err := activitypub.Note(ctx, comment)
if err != nil {
return nil, err
}
create := activitypub.Create(doer, note, repo.GetIRI())
err = activitypub.Send(ctx, doer, create)
if err != nil {
return nil, err
}
}
mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, doer, comment.Content)
if err != nil {
return nil, err

View File

@ -4,7 +4,9 @@
package issue
import (
"context"
"fmt"
"strings"
activities_model "code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/models/db"
@ -17,14 +19,32 @@ import (
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/notification"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/services/activitypub"
)
// NewIssue creates new issue with labels for repository.
func NewIssue(repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, assigneeIDs []int64) error {
func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, assigneeIDs []int64) error {
if err := issues_model.NewIssue(repo, issue, labelIDs, uuids); err != nil {
return err
}
if strings.Contains(repo.OwnerName, "@") {
// Federated issue
ticket, err := activitypub.Ticket(db.DefaultContext, issue)
if err != nil {
return err
}
err = issue.LoadPoster(db.DefaultContext)
if err != nil {
return err
}
create := activitypub.Create(issue.Poster, ticket, repo.GetIRI())
err = activitypub.Send(ctx, 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,45 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
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)}
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(ctx, user, &create)
}

View File

@ -0,0 +1,71 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repository
import (
"context"
"code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/services/activitypub"
ap "github.com/go-ap/activitypub"
)
// StarRepo or unstar repository.
func StarRepo(ctx context.Context, userID, repoID int64, star bool) error {
repo, err := repo_model.GetRepositoryByID(ctx, repoID)
if err != nil {
return err
}
err = repo.LoadOwner(ctx)
if err != nil {
return err
}
if repo.Owner.LoginType == auth.Federated {
// Federated repo
user, err := user_model.GetUserByID(ctx, userID)
if err != nil {
return err
}
var activity *ap.Activity
if star {
activity = activitypub.Star(user, repo)
} else {
activity = activitypub.Unstar(user, repo)
}
err = activitypub.Send(ctx, user, activity)
if err != nil {
return err
}
}
err = repo_model.StarRepo(userID, repoID, star)
if err != nil {
return err
}
user, err := user_model.GetUserByID(ctx, userID)
if err != nil {
return err
}
note := ap.Note{
Type: ap.NoteType,
ID: ap.IRI(repo.GetIRI()), // TODO: serve the note at an API endpoint
AttributedTo: ap.IRI(user.GetIRI()),
To: ap.ItemCollection{ap.IRI("https://www.w3.org/ns/activitystreams#Public")},
}
note.Content = ap.NaturalLanguageValuesNew()
err = note.Content.Set("en", ap.Content(user.Name+" starred <a href=\""+repo.HTMLURL()+"\">"+repo.FullName()+"</a>"))
if err != nil {
return err
}
create := ap.Create{
Type: ap.CreateType,
Actor: ap.PersonNew(ap.IRI(user.GetIRI())),
Object: note,
To: ap.ItemCollection{ap.IRI(user.GetIRI() + "/followers")},
}
return activitypub.Send(ctx, user, &create)
}

View File

@ -12,6 +12,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"
@ -24,6 +25,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"
)
@ -274,3 +276,53 @@ func DeleteAvatar(u *user_model.User) error {
}
return nil
}
// FollowUser marks someone be another's follower.
func FollowUser(ctx context.Context, userID, followID int64) (err error) {
if userID == followID || user_model.IsFollowing(userID, followID) {
return nil
}
followUser, err := user_model.GetUserByID(ctx, followID)
if err != nil {
return
}
if followUser.LoginType == auth.Federated {
// Following remote user
actorUser, err := user_model.GetUserByID(ctx, userID)
if err != nil {
return err
}
err = activitypub.Send(ctx, 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(ctx context.Context, userID, followID int64) (err error) {
if userID == followID || !user_model.IsFollowing(userID, followID) {
return nil
}
followUser, err := user_model.GetUserByID(ctx, followID)
if err != nil {
return
}
if followUser.LoginType == auth.Federated {
// Unfollowing remote user
actorUser, err := user_model.GetUserByID(ctx, userID)
if err != nil {
return err
}
err = activitypub.Send(ctx, actorUser, activitypub.Unfollow(actorUser, followUser))
if err != nil {
return err
}
}
return user_model.UnfollowUser(userID, followID)
}

View File

@ -23,10 +23,231 @@
},
"basePath": "{{AppSubUrl | JSEscape | Safe}}/api/v1",
"paths": {
"/activitypub/note/{username}/{reponame}/{noteid}": {
"get": {
"produces": [
"application/activity+json"
],
"tags": [
"activitypub"
],
"summary": "Returns the Note object for a comment to an issue or PR",
"operationId": "activitypubNote",
"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 comment",
"name": "noteid",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/ActivityPub"
},
"204": {
"$ref": "#/responses/empty"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/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": {
"202": {
"$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": {
"501": {
"$ref": "#/responses/empty"
}
}
}
},
"/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"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/activitypub/user/{username}": {
"get": {
"produces": [
"application/json"
"application/activity+json"
],
"tags": [
"activitypub"
@ -49,10 +270,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"
@ -69,7 +342,33 @@
}
],
"responses": {
"204": {
"202": {
"$ref": "#/responses/empty"
}
}
}
},
"/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": {
"501": {
"$ref": "#/responses/empty"
}
}

View File

@ -12,9 +12,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"
@ -41,10 +41,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)