Compare commits

...

69 Commits

Author SHA1 Message Date
Derrick Hammer 853de2cb64
feat: add update pull request 2024-02-12 16:54:59 -05:00
Derrick Hammer 946f57fe4e
fix: need to convert responses to github format 2024-02-12 16:42:28 -05:00
Derrick Hammer c345f1c72a
feat: add convertCorePullRequest 2024-02-12 16:42:00 -05:00
Derrick Hammer e33587b406
feat: add functions to convert a release 2024-02-12 16:39:51 -05:00
Derrick Hammer bf9e062644
feat: add pull request create 2024-02-12 16:25:43 -05:00
Derrick Hammer 63e9d93f9b
feat: add repo create release 2024-02-12 16:12:19 -05:00
Derrick Hammer a1bc451d3e
feat: add endpoint to generate a long-lived token to use inside ci actions 2024-02-12 15:48:31 -05:00
Derrick Hammer 340240575f
fix: need to use ghComment 2024-02-12 04:42:34 -05:00
Derrick Hammer 8640627536
fix: wrong comment update path 2024-02-12 04:41:31 -05:00
Derrick Hammer 6aed2bfaae
fix: prefix webhook action in header bot not the payload 2024-02-12 04:14:46 -05:00
Derrick Hammer 7453fa48ee
fix: typo 2024-02-12 03:50:55 -05:00
Derrick Hammer 6ca3e8945c
feat: add list issue comments 2024-02-12 03:49:51 -05:00
Derrick Hammer dc9a0acb8e
fix: add parseJsonBody helper and update comment methods to parse json 2024-02-12 03:15:23 -05:00
Derrick Hammer 5345523eec
feat: add create and update comment api's 2024-02-12 02:50:23 -05:00
Derrick Hammer f975cf38e0
fix: need to override claim validation to convert string exp to unix 2024-02-12 02:19:09 -05:00
Derrick Hammer 276b43ddd0
fix: strip out token 2024-02-12 02:03:21 -05:00
Derrick Hammer 6eab62d128
refactor: need to duplicate and make "core" copies of functions when we have to convert from a core version of a struct 2024-02-12 01:57:43 -05:00
Derrick Hammer d132946e40
feat: add /repos/{owner}/{repo}/git/trees/{tree_sha endpoint 2024-02-12 01:48:49 -05:00
Derrick Hammer c607400951
refactor: use sdk version of structs where possible to avoid conflicts 2024-02-12 01:46:41 -05:00
Derrick Hammer aa52bd2e59
fix: wrong signing method 2024-02-12 01:06:01 -05:00
Derrick Hammer 41b25abaaf
fix: use installation_id not app_id 2024-02-12 01:02:09 -05:00
Derrick Hammer 1c1f78559d
fix: need to subclass StandardClaims to make Issuer any 2024-02-12 01:01:58 -05:00
Derrick Hammer 608cfad501
fix: string/number hacks 2024-02-12 00:47:29 -05:00
Derrick Hammer 669a6eaf6d
fix: need to cast appId as string,then parse to int 2024-02-12 00:43:04 -05:00
Derrick Hammer 846e5df758
fix: put under the /api/v3 route space 2024-02-12 00:40:35 -05:00
Derrick Hammer 3565331a58
fix: need to trim upper and lower case 2024-02-12 00:40:09 -05:00
Derrick Hammer fb752f1f3a
feat: add routes for app install tokens 2024-02-12 00:33:56 -05:00
Derrick Hammer 346849cc8c
refactor: ensure only a given install can access its access tokens with the app-install-get-access-token route 2024-02-12 00:18:52 -05:00
Derrick Hammer d962eb5304
refactor: put api under both /api and /api/v3 2024-02-12 00:01:22 -05:00
Derrick Hammer 41532495bc
feat: implement github jwt verification via middleware 2024-02-11 23:55:02 -05:00
Derrick Hammer d83df43411
fix: encoding wrong version 2024-02-11 23:25:43 -05:00
Derrick Hammer e907aa7e99
fix: must use json marshal to convert to bytes, then unmarshal to a map 2024-02-11 23:23:32 -05:00
Derrick Hammer ae9b036cb5
fix: need to convert struct to a map to inject installation id 2024-02-11 23:07:14 -05:00
Derrick Hammer d5ac5e4b3c
fix: needs to be a pointer receiver 2024-02-11 22:37:32 -05:00
Derrick Hammer e7bfd0c6c9
fix: ensure o.refresher can never be nil 2024-02-11 22:33:08 -05:00
Derrick Hammer a439ca8002
fix: only run keep alive if loading token is a success 2024-02-11 22:32:22 -05:00
Derrick Hammer 4faa7c9b88
fix: bad url 2024-02-11 16:37:13 -05:00
Derrick Hammer 8dd6e83fae
refactor: make config stateful 2024-02-11 16:33:56 -05:00
Derrick Hammer bbe7e8e053
refactor: run config in a lifecycle 2024-02-11 16:32:19 -05:00
Derrick Hammer 34898771ab
refactor: make oauth DI managed 2024-02-11 16:30:03 -05:00
Derrick Hammer 6d20106bb5
refactor: viper can't save a struct, got to do it ourselves 2024-02-11 16:27:24 -05:00
Derrick Hammer 0cf1b8827a
refactor: just try to parse and check expire without verification 2024-02-11 16:26:54 -05:00
Derrick Hammer 4b429e6d59
refactor: use our exchange 2024-02-11 15:00:13 -05:00
Derrick Hammer 055f502114
chore: RouterParams not needed 2024-02-11 14:59:07 -05:00
Derrick Hammer 150c5c6cb2
feat: add token monitoring and refresh 2024-02-11 14:58:04 -05:00
Derrick Hammer 5036d0bca4
feat: add SaveConfig 2024-02-11 14:50:53 -05:00
Derrick Hammer 0365862777
feat: initial rest api support with /repos/{owner}/{repo}/pulls/{pull_number}/files 2024-02-11 14:17:19 -05:00
Derrick Hammer ea4e4aa00a
fix: support both auth token and oauth token 2024-02-11 14:12:48 -05:00
Derrick Hammer 8abe1e7981
feat: add convertCommitFile 2024-02-11 14:03:03 -05:00
Derrick Hammer 0c78013f38
fix: Owner missing from convertRepo 2024-02-11 06:32:38 -05:00
Derrick Hammer e95732d479
fix: need to prefix with pull_request. 2024-02-11 06:25:50 -05:00
Derrick Hammer 12c71a26bf
fix: use Header.Set 2024-02-11 06:20:18 -05:00
Derrick Hammer 8167255a0a
fix: we need to encode the new version, not the original 2024-02-11 05:38:36 -05:00
Derrick Hammer 865c639557
fix: add toNormalizedJson to match github output 2024-02-11 05:35:15 -05:00
Derrick Hammer 300b074f86
fix: skip a nil label 2024-02-11 05:23:51 -05:00
Derrick Hammer bf74d45404
fix: if RequestedReviewers or Labels are empty, add dummy nil 2024-02-11 05:22:35 -05:00
Derrick Hammer 7cfcbad86d
fix: add missing data in PullRequestEvent 2024-02-11 05:08:41 -05:00
Derrick Hammer e1d8e122ce
feat: add convertLabel 2024-02-11 04:56:30 -05:00
Derrick Hammer 4afb7eb108
refactor: add nil checks on all converts 2024-02-11 04:50:01 -05:00
Derrick Hammer 0093a15645
fix: add missing convertChanges 2024-02-11 04:47:58 -05:00
Derrick Hammer d97dd79e59
refactor: move convert functions to dedicated file, create convertPullRequestEvent and translatePrAction 2024-02-11 04:42:11 -05:00
Derrick Hammer 64319ee7d0
fix: add missing call to webhook manager 2024-02-11 04:42:10 -05:00
Derrick Hammer 90fbbf94e1
refactor: verifyWebhookDataMiddleware just need to store the wehbook data in context 2024-02-11 04:25:52 -05:00
Derrick Hammer 36a661bd71
feat: add webhooks manager and switch to using dedicated pull_request webhook endpoint. Also add iniyial pull_request support 2024-02-11 04:03:00 -05:00
Derrick Hammer 9262bb0555
feat: initial dummy webhook support. use gitea VerifyWebhookSignatureMiddleware instead. 2024-02-11 04:01:35 -05:00
Derrick Hammer 26f497f062
fix: missing embedding fx.In 2024-02-11 04:01:34 -05:00
Derrick Hammer e8af456476
feat: add middleware to verify gitea webhooks 2024-02-11 04:01:34 -05:00
Derrick Hammer 5ac86cc75c
refactor: split route setup to each set of routes 2024-02-11 04:01:34 -05:00
Derrick Hammer 4456b54550
feat: server bones and initial github apps setup support 2024-02-11 04:01:27 -05:00
23 changed files with 2741 additions and 1 deletions

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2024 LumeWeb
Copyright (c) 2024 Hammer Technologies LLC
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

82
api/app.go Normal file
View File

@ -0,0 +1,82 @@
package api
import (
"crypto/hmac"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"fmt"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"go.uber.org/zap"
"gorm.io/gorm"
)
type manifest struct {
Name string `json:"name"`
Url string `json:"url"`
HookAttributes hookAttributes `json:"hook_attributes"`
Public bool `json:"public"`
RedirectURL string `json:"redirect_url"`
Version string `json:"version"`
DefaultPermissions permissions `json:"default_permissions"`
}
type hookAttributes struct {
URL string `json:"url"`
}
type permissions struct {
Issues string `json:"issues"`
Metadata string `json:"metadata"`
PullRequests string `json:"pull_requests"`
}
type settingsApi struct {
config *config.Config
db *gorm.DB
logger *zap.Logger
}
type appSecrets struct {
PrivateKey string `json:"private_key"`
Code string `json:"code"`
}
func newApp() appSecrets {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(err)
}
// Marshal the private key into PKCS#1 ASN.1 DER encoded form
pkcs1Bytes := x509.MarshalPKCS1PrivateKey(privateKey)
// Create a PEM block with the private key
privateKeyPEM := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: pkcs1Bytes,
}
return appSecrets{
PrivateKey: string(pem.EncodeToMemory(privateKeyPEM)),
Code: generateTempCode(),
}
}
func generateTempCode() string {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
panic(err)
}
return hex.EncodeToString(bytes)
}
func generatePayloadSignature(payload []byte, secret string) string {
hasher := hmac.New(sha256.New, []byte(secret))
hasher.Write(payload)
sha := hex.EncodeToString(hasher.Sum(nil))
return fmt.Sprintf("sha256=%s", sha)
}

521
api/convert.go Normal file
View File

@ -0,0 +1,521 @@
package api
import (
"code.gitea.io/gitea/modules/structs"
gitea "code.gitea.io/sdk/gitea"
"fmt"
"github.com/google/go-github/v59/github"
"time"
)
func stringPtr(s string) *string {
return &s
}
func numberPtr(n int) *int {
return &n
}
func intPtr(n int) *int {
return &n
}
func int64Ptr(n int64) *int64 {
return &n
}
func boolPtr(b bool) *bool {
return &b
}
func timePtr(t time.Time) *github.Timestamp {
timestamp := github.Timestamp{Time: t}
return &timestamp
}
func timePtrIfNotNil(t *time.Time) *github.Timestamp {
if t == nil {
return nil
}
return timePtr(*t)
}
func convertUser(user *gitea.User) *github.User {
if user == nil {
return &github.User{}
}
return &github.User{
Login: stringPtr(user.UserName),
ID: int64Ptr(user.ID),
}
}
func convertCoreUser(user *structs.User) *github.User {
if user == nil {
return &github.User{}
}
return &github.User{
Login: stringPtr(user.UserName),
ID: int64Ptr(user.ID),
}
}
func convertUsers(users []*gitea.User) []*github.User {
var ghUsers []*github.User
for _, user := range users {
ghUsers = append(ghUsers, convertUser(user))
}
return ghUsers
}
func convertCoreUsers(users []*structs.User) []*github.User {
var ghUsers []*github.User
for _, user := range users {
ghUsers = append(ghUsers, convertCoreUser(user))
}
return ghUsers
}
func convertMilestone(milestone *gitea.Milestone) *github.Milestone {
if milestone == nil {
return &github.Milestone{}
}
return &github.Milestone{
Title: stringPtr(milestone.Title),
}
}
func convertCoreMilestone(milestone *structs.Milestone) *github.Milestone {
if milestone == nil {
return &github.Milestone{}
}
return &github.Milestone{
Title: stringPtr(milestone.Title),
}
}
func convertLabels(labels []*gitea.Label) []*github.Label {
if labels == nil {
return make([]*github.Label, 0)
}
var ghLabels []*github.Label
for _, label := range labels {
if label == nil {
continue
}
ghLabels = append(ghLabels, &github.Label{
Name: stringPtr(label.Name),
})
}
return ghLabels
}
func convertCoreLabels(labels []*structs.Label) []*github.Label {
if labels == nil {
return make([]*github.Label, 0)
}
var ghLabels []*github.Label
for _, label := range labels {
if label == nil {
continue
}
ghLabels = append(ghLabels, &github.Label{
Name: stringPtr(label.Name),
})
}
return ghLabels
}
func convertPRBranch(branch *gitea.PRBranchInfo) *github.PullRequestBranch {
if branch == nil {
return &github.PullRequestBranch{}
}
return &github.PullRequestBranch{
Label: stringPtr(branch.Name),
Ref: stringPtr(branch.Ref),
SHA: stringPtr(branch.Sha),
Repo: convertRepo(branch.Repository),
}
}
func convertCorePRBranch(branch *structs.PRBranchInfo) *github.PullRequestBranch {
if branch == nil {
return &github.PullRequestBranch{}
}
return &github.PullRequestBranch{
Label: stringPtr(branch.Name),
Ref: stringPtr(branch.Ref),
SHA: stringPtr(branch.Sha),
Repo: convertCoreRepo(branch.Repository),
}
}
func convertRepo(repo *gitea.Repository) *github.Repository {
if repo == nil {
return &github.Repository{}
}
return &github.Repository{
ID: int64Ptr(repo.ID),
Name: stringPtr(repo.Name),
Owner: convertUser(repo.Owner),
FullName: stringPtr(repo.FullName),
Description: stringPtr(repo.Description),
Homepage: stringPtr(repo.Website),
HTMLURL: stringPtr(repo.HTMLURL),
CloneURL: stringPtr(repo.CloneURL),
GitURL: stringPtr(repo.CloneURL),
SSHURL: stringPtr(repo.SSHURL),
DefaultBranch: stringPtr(repo.DefaultBranch),
CreatedAt: timePtr(repo.Created),
UpdatedAt: timePtr(repo.Updated),
Private: boolPtr(repo.Private),
Fork: boolPtr(repo.Fork),
Size: intPtr(repo.Size),
StargazersCount: intPtr(repo.Stars),
SubscribersCount: intPtr(repo.Watchers),
ForksCount: intPtr(repo.Forks),
Watchers: intPtr(repo.Watchers),
WatchersCount: intPtr(repo.Stars),
OpenIssuesCount: intPtr(repo.OpenIssues),
Archived: boolPtr(repo.Archived),
}
}
func convertCoreRepo(repo *structs.Repository) *github.Repository {
if repo == nil {
return &github.Repository{}
}
return &github.Repository{
ID: int64Ptr(repo.ID),
Name: stringPtr(repo.Name),
Owner: convertCoreUser(repo.Owner),
FullName: stringPtr(repo.FullName),
Description: stringPtr(repo.Description),
Homepage: stringPtr(repo.Website),
HTMLURL: stringPtr(repo.HTMLURL),
CloneURL: stringPtr(repo.CloneURL),
GitURL: stringPtr(repo.CloneURL),
SSHURL: stringPtr(repo.SSHURL),
DefaultBranch: stringPtr(repo.DefaultBranch),
CreatedAt: timePtr(repo.Created),
UpdatedAt: timePtr(repo.Updated),
Private: boolPtr(repo.Private),
Fork: boolPtr(repo.Fork),
Size: intPtr(repo.Size),
StargazersCount: intPtr(repo.Stars),
SubscribersCount: intPtr(repo.Watchers),
ForksCount: intPtr(repo.Forks),
Watchers: intPtr(repo.Watchers),
WatchersCount: intPtr(repo.Stars),
OpenIssuesCount: intPtr(repo.OpenIssues),
Archived: boolPtr(repo.Archived),
}
}
func convertPullRequest(request *gitea.PullRequest) *github.PullRequest {
if request == nil {
return &github.PullRequest{}
}
pr := &github.PullRequest{
ID: int64Ptr(request.ID),
Number: intPtr(int(request.Index)),
State: stringPtr(string(request.State)),
Title: stringPtr(request.Title),
Body: stringPtr(request.Body),
CreatedAt: timePtr(*request.Created),
UpdatedAt: timePtr(*request.Updated),
ClosedAt: timePtrIfNotNil(request.Closed),
MergedAt: timePtrIfNotNil(request.Merged),
Merged: boolPtr(request.HasMerged),
Mergeable: boolPtr(request.Mergeable),
MergeCommitSHA: request.MergedCommitID,
URL: stringPtr(request.URL),
HTMLURL: stringPtr(request.HTMLURL),
DiffURL: stringPtr(request.DiffURL),
PatchURL: stringPtr(request.PatchURL),
Comments: intPtr(request.Comments),
Assignee: convertUser(request.Assignee),
Assignees: convertUsers(request.Assignees),
Milestone: convertMilestone(request.Milestone),
Labels: convertLabels(request.Labels),
}
// Convert PR branch info
if request.Head != nil {
pr.Head = convertPRBranch(request.Head)
}
if request.Base != nil {
pr.Base = convertPRBranch(request.Base)
}
return pr
}
func convertCorePullRequest(request *structs.PullRequest) *github.PullRequest {
if request == nil {
return &github.PullRequest{}
}
pr := &github.PullRequest{
ID: int64Ptr(request.ID),
Number: intPtr(int(request.Index)),
State: stringPtr(string(request.State)),
Title: stringPtr(request.Title),
Body: stringPtr(request.Body),
CreatedAt: timePtr(*request.Created),
UpdatedAt: timePtr(*request.Updated),
ClosedAt: timePtrIfNotNil(request.Closed),
MergedAt: timePtrIfNotNil(request.Merged),
Merged: boolPtr(request.HasMerged),
Mergeable: boolPtr(request.Mergeable),
MergeCommitSHA: request.MergedCommitID,
URL: stringPtr(request.URL),
HTMLURL: stringPtr(request.HTMLURL),
DiffURL: stringPtr(request.DiffURL),
PatchURL: stringPtr(request.PatchURL),
Comments: intPtr(request.Comments),
Assignee: convertCoreUser(request.Assignee),
Assignees: convertCoreUsers(request.Assignees),
Milestone: convertCoreMilestone(request.Milestone),
Labels: convertCoreLabels(request.Labels),
}
// Convert PR branch info
if request.Head != nil {
pr.Head = convertCorePRBranch(request.Head)
}
if request.Base != nil {
pr.Base = convertCorePRBranch(request.Base)
}
return pr
}
func convertChanges(changes *structs.ChangesPayload) *github.EditChange {
if changes == nil {
return &github.EditChange{}
}
return &github.EditChange{
Title: &github.EditTitle{
From: stringPtr(changes.Title.From),
},
Body: &github.EditBody{
From: stringPtr(changes.Body.From),
},
Base: &github.EditBase{
Ref: &github.EditRef{
From: stringPtr(changes.Ref.From),
},
},
}
}
func convertLabel(label *gitea.Label) *github.Label {
if label == nil {
return &github.Label{}
}
return &github.Label{
ID: int64Ptr(label.ID),
Name: stringPtr(label.Name),
Color: stringPtr(label.Color),
Description: stringPtr(label.Description),
URL: stringPtr(label.URL),
}
}
func convertCoreLabel(label *structs.Label) *github.Label {
if label == nil {
return &github.Label{}
}
return &github.Label{
ID: int64Ptr(label.ID),
Name: stringPtr(label.Name),
Color: stringPtr(label.Color),
Description: stringPtr(label.Description),
URL: stringPtr(label.URL),
}
}
func convertCommitFile(file *gitea.ChangedFile) *github.CommitFile {
if file == nil {
return &github.CommitFile{}
}
return &github.CommitFile{
Filename: stringPtr(file.Filename),
PreviousFilename: stringPtr(file.PreviousFilename),
Additions: intPtr(file.Additions),
Deletions: intPtr(file.Deletions),
Changes: intPtr(file.Changes),
Status: stringPtr(file.Status),
BlobURL: stringPtr(file.ContentsURL),
RawURL: stringPtr(file.RawURL),
}
}
func convertGitTree(tree *gitea.GitTreeResponse) *github.Tree {
if tree == nil {
return &github.Tree{}
}
return &github.Tree{
SHA: stringPtr(tree.SHA),
Truncated: boolPtr(tree.Truncated),
Entries: convertGitEntries(tree.Entries),
}
}
func convertGitEntries(entries []gitea.GitEntry) []*github.TreeEntry {
if entries == nil {
return make([]*github.TreeEntry, 0)
}
var ghEntries []*github.TreeEntry
for _, entry := range entries {
ghEntries = append(ghEntries, convertGitEntry(&entry))
}
return ghEntries
}
func convertGitEntry(s *gitea.GitEntry) *github.TreeEntry {
if s == nil {
return &github.TreeEntry{}
}
return &github.TreeEntry{
Path: stringPtr(s.Path),
Mode: stringPtr(s.Mode),
Type: stringPtr(s.Type),
Size: intPtr(int(s.Size)),
SHA: stringPtr(s.SHA),
URL: stringPtr(s.URL),
}
}
func convertIssueComment(comment *gitea.Comment, reactions []*gitea.Reaction) *github.IssueComment {
if comment == nil {
return &github.IssueComment{}
}
return &github.IssueComment{
ID: int64Ptr(comment.ID),
Body: stringPtr(comment.Body),
CreatedAt: timePtr(comment.Created),
UpdatedAt: timePtr(comment.Updated),
User: convertUser(comment.Poster),
Reactions: convertReactions(reactions),
}
}
func convertReactions(reactions []*gitea.Reaction) *github.Reactions {
if reactions == nil {
return &github.Reactions{}
}
return &github.Reactions{
TotalCount: intPtr(len(reactions)),
PlusOne: intPtr(countReaction(reactions, "+1")),
MinusOne: intPtr(countReaction(reactions, "-1")),
Laugh: intPtr(countReaction(reactions, "laugh")),
Confused: intPtr(countReaction(reactions, "confused")),
Heart: intPtr(countReaction(reactions, "heart")),
Rocket: intPtr(countReaction(reactions, "rocket")),
Eyes: intPtr(countReaction(reactions, "eyes")),
}
}
func countReaction(reactions []*gitea.Reaction, reactionType string) int {
count := 0
for _, reaction := range reactions {
if reaction.Reaction == reactionType {
count++
}
}
return count
}
func translatePrAction(action structs.HookIssueAction, prefix bool) string {
translatedAction := ""
switch action {
case structs.HookIssueOpened:
translatedAction = "opened"
case structs.HookIssueClosed:
translatedAction = "closed"
case structs.HookIssueReOpened:
translatedAction = "reopened"
case structs.HookIssueEdited:
translatedAction = "edited"
case structs.HookIssueAssigned:
translatedAction = "assigned"
case structs.HookIssueUnassigned:
translatedAction = "unassigned"
case structs.HookIssueLabelUpdated:
// GitHub does not have a direct "label_updated" event; use "labeled" as the closest action
translatedAction = "labeled" // Assuming you handle the update as adding a label
case structs.HookIssueLabelCleared:
// GitHub does not have a direct "label_cleared" event; use "unlabeled" as the closest action
translatedAction = "unlabeled" // Assuming you handle the clearing as removing a label
case structs.HookIssueSynchronized:
translatedAction = "synchronize"
case structs.HookIssueMilestoned:
translatedAction = "milestoned"
case structs.HookIssueDemilestoned:
translatedAction = "demilestoned"
case structs.HookIssueReviewed:
// GitHub does not have a direct "reviewed" event for PRs; this might be closest to a review submitted
translatedAction = "review_submitted" // This is not a direct GitHub event, consider how best to map this action
case structs.HookIssueReviewRequested:
translatedAction = "review_requested"
case structs.HookIssueReviewRequestRemoved:
translatedAction = "review_request_removed"
default:
// Fallback for any unhandled actions
translatedAction = "unknown_action"
}
if prefix {
translatedAction = fmt.Sprintf("pull_request.%s", translatedAction)
}
return translatedAction
}
func convertRelease(release *gitea.Release) *github.RepositoryRelease {
if release == nil {
return &github.RepositoryRelease{}
}
return &github.RepositoryRelease{
ID: int64Ptr(release.ID),
TagName: stringPtr(release.TagName),
TargetCommitish: stringPtr(release.Target),
Name: stringPtr(release.Title),
Body: stringPtr(release.Note),
Draft: boolPtr(release.IsDraft),
Prerelease: boolPtr(release.IsPrerelease),
CreatedAt: timePtr(release.CreatedAt),
PublishedAt: timePtr(release.PublishedAt),
Assets: convertReleaseAttachments(release.Attachments),
URL: stringPtr(release.URL),
ZipballURL: stringPtr(release.ZipURL),
TarballURL: stringPtr(release.TarURL),
HTMLURL: stringPtr(release.HTMLURL),
}
}
func convertReleaseAttachments(attachments []*gitea.Attachment) []*github.ReleaseAsset {
if attachments == nil {
return make([]*github.ReleaseAsset, 0)
}
var ghAttachments []*github.ReleaseAsset
for _, attachment := range attachments {
ghAttachments = append(ghAttachments, convertReleaseAttachment(attachment))
}
return ghAttachments
}
func convertReleaseAttachment(attachment *gitea.Attachment) *github.ReleaseAsset {
if attachment == nil {
return &github.ReleaseAsset{}
}
return &github.ReleaseAsset{
ID: int64Ptr(attachment.ID),
Name: stringPtr(attachment.Name),
Size: intPtr(int(attachment.Size)),
DownloadCount: intPtr(int(attachment.DownloadCount)),
BrowserDownloadURL: stringPtr(attachment.DownloadURL),
}
}

28
api/convert_event.go Normal file
View File

@ -0,0 +1,28 @@
package api
import (
"code.gitea.io/gitea/modules/structs"
"github.com/google/go-github/v59/github"
)
func convertPullRequestEvent(event *structs.PullRequestPayload) *github.PullRequestEvent {
if len(event.PullRequest.RequestedReviewers) == 0 {
event.PullRequest.RequestedReviewers = append(event.PullRequest.RequestedReviewers, nil)
}
if len(event.PullRequest.Labels) == 0 {
event.PullRequest.Labels = append(event.PullRequest.Labels, nil)
}
return &github.PullRequestEvent{
Action: stringPtr(translatePrAction(event.Action, false)),
PullRequest: convertCorePullRequest(event.PullRequest),
Repo: convertCoreRepo(event.Repository),
Assignee: convertCoreUser(event.PullRequest.Assignee),
Number: intPtr(int(event.Index)),
Changes: convertChanges(event.Changes),
RequestedReviewer: convertCoreUser(event.PullRequest.RequestedReviewers[0]),
Sender: convertCoreUser(event.Sender),
Label: convertCoreLabel(event.PullRequest.Labels[0]),
}
}

43
api/cookie.go Normal file
View File

@ -0,0 +1,43 @@
package api
import (
"net/http"
"time"
)
func deleteCookie(w http.ResponseWriter, name string) {
cookie := http.Cookie{
Name: name,
Path: "/",
MaxAge: -1,
}
http.SetCookie(w, &cookie)
}
func setAuthCookie(jwt string, domain string, w http.ResponseWriter) {
setCookie(w, AuthCookieName, domain, jwt, int(time.Hour.Seconds()), http.SameSiteNoneMode)
}
func setCookie(w http.ResponseWriter, name string, domain string, value string, maxAge int, sameSite http.SameSite) {
cookie := http.Cookie{
Name: name,
Domain: domain,
Value: value,
Path: "/",
HttpOnly: true,
MaxAge: maxAge,
Secure: true,
SameSite: sameSite,
}
http.SetCookie(w, &cookie)
}
func getCookie(r *http.Request, name string) string {
cookie, err := r.Cookie(name)
if err != nil {
return ""
}
return cookie.Value
}

291
api/middleware.go Normal file
View File

@ -0,0 +1,291 @@
package api
import (
"code.gitea.io/sdk/gitea"
"context"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/db/model"
"github.com/golang-jwt/jwt"
"github.com/gorilla/mux"
"go.uber.org/zap"
"gorm.io/gorm"
"io"
"net/http"
"strconv"
"strings"
"time"
)
const AUTHED_CONTEXT_KEY = "authed"
const REDIRECT_AFTER_AUTH = "redirect-after-auth"
const WEBHOOK_CONTEXT_KEY = "webhook"
const AuthCookieName = "auth-token"
var _ = jwt.Claims(&standardClaims{})
type standardClaims struct {
Issuer any `json:"iss,omitempty"`
ExpiresAt any `json:"exp,omitempty"`
jwt.StandardClaims
}
func (s *standardClaims) Valid() error {
if timeStr, ok := s.ExpiresAt.(string); ok {
t, err := time.Parse(time.RFC3339Nano, timeStr)
if err != nil {
return err
}
unixTimestamp := t.Unix()
s.ExpiresAt = unixTimestamp
}
return s.StandardClaims.Valid()
}
func findAuthToken(r *http.Request) string {
authHeader := parseAuthTokenHeader(r.Header)
if authHeader != "" {
return authHeader
}
cookie := getCookie(r, AuthCookieName)
if cookie != "" {
return cookie
}
return r.FormValue(AuthCookieName)
}
func giteaOauthVerifyMiddleware(cfg *config.Config) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := findAuthToken(r)
if token == "" {
addAuthStatusToRequestServ(false, r, w, next)
return
}
client, err := getClient(ClientParams{
Config: cfg,
AuthToken: token,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _, err = client.AdminListUsers(gitea.AdminListUsersOptions{})
if err != nil {
addAuthStatusToRequestServ(false, r, w, next)
return
}
addAuthStatusToRequestServ(true, r, w, next)
})
}
}
func githubRestVerifyMiddleware(db *gorm.DB) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := findAuthToken(r)
if token != "" {
parseToken, _, err := new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{})
if err != nil {
http.Error(w, "Invalid JWT", http.StatusUnauthorized)
return
}
claims, ok := parseToken.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, "Invalid JWT", http.StatusUnauthorized)
return
}
var appId string
switch v := claims["iss"].(type) {
case string:
appId = v
case float64:
appId = strconv.FormatFloat(v, 'f', -1, 64)
default:
http.Error(w, "Invalid JWT", http.StatusUnauthorized)
return
}
appIdInt, err := strconv.Atoi(appId)
if err != nil {
http.Error(w, "Invalid JWT", http.StatusUnauthorized)
return
}
appRecord := &model.Apps{}
appRecord.ID = uint(appIdInt)
if err := db.First(appRecord).Error; err != nil {
http.Error(w, "Invalid JWT", http.StatusUnauthorized)
return
}
block, _ := pem.Decode([]byte(appRecord.PrivateKey))
if block == nil {
// Handle error
http.Error(w, "Invalid Private Key", http.StatusInternalServerError)
return
}
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
// Handle error
http.Error(w, "Failed to parse Private Key", http.StatusInternalServerError)
return
}
publicKey := &privateKey.PublicKey
parseToken, err = jwt.ParseWithClaims(token, &standardClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
// Return the RSA public key
return publicKey, nil
})
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if mux.CurrentRoute(r).GetName() == "app-install-get-access-token" {
installId := mux.Vars(r)["installation_id"]
installIdInt, err := strconv.Atoi(installId)
if err != nil {
http.Error(w, "Invalid Install", http.StatusUnauthorized)
return
}
if appIdInt != installIdInt {
http.Error(w, "Invalid Install", http.StatusUnauthorized)
return
}
}
}
addAuthStatusToRequestServ(true, r, w, next)
})
}
}
func storeWebhookDataMiddleware(logger *zap.Logger) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var webhook map[string]interface{}
body, err := io.ReadAll(r.Body)
if err != nil {
logger.Error("Failed to read request body", zap.Error(err))
w.WriteHeader(http.StatusInternalServerError)
return
}
err = json.Unmarshal(body, &webhook)
if err != nil {
logger.Error("Failed to unmarshal webhook", zap.Error(err))
w.WriteHeader(http.StatusInternalServerError)
return
}
if len(webhook) == 0 {
logger.Error("Webhook data is empty")
w.WriteHeader(http.StatusBadRequest)
return
}
ctx := context.WithValue(r.Context(), WEBHOOK_CONTEXT_KEY, body)
r = r.WithContext(ctx)
r.Body = io.NopCloser(strings.NewReader(string(body)))
next.ServeHTTP(w, r)
})
}
}
func requireAuthMiddleware(cfg *config.Config) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
status := getAuthedStatusFromRequest(r)
if !status {
setCookie(w, REDIRECT_AFTER_AUTH, cfg.Domain, r.Referer(), 0, http.SameSiteLaxMode)
http.Redirect(w, r, "/setup", http.StatusFound)
return
}
next.ServeHTTP(w, r)
})
}
}
func githubRestRequireAuthMiddleware(cfg *config.Config) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
status := getAuthedStatusFromRequest(r)
if !status {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
}
func loggingMiddleware(logger *zap.Logger) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Do stuff here
logger.Debug("Request", zap.String("method", r.Method), zap.String("url", r.RequestURI))
// Call the next handler, which can be another middleware in the chain, or the final handler.
next.ServeHTTP(w, r)
})
}
}
func addAuthStatusToRequestServ(status bool, r *http.Request, w http.ResponseWriter, next http.Handler) {
ctx := context.WithValue(r.Context(), AUTHED_CONTEXT_KEY, status)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
}
func parseAuthTokenHeader(headers http.Header) string {
authHeader := headers.Get("Authorization")
if authHeader == "" {
return ""
}
authHeader = strings.TrimPrefix(authHeader, "Bearer ")
authHeader = strings.TrimPrefix(authHeader, "bearer ")
authHeader = strings.TrimPrefix(authHeader, "Token ")
authHeader = strings.TrimPrefix(authHeader, "token ")
return authHeader
}
func getAuthedStatusFromRequest(r *http.Request) bool {
authed, ok := r.Context().Value(AUTHED_CONTEXT_KEY).(bool)
if !ok {
return false
}
return authed
}

187
api/oauth.go Normal file
View File

@ -0,0 +1,187 @@
package api
import (
"code.gitea.io/sdk/gitea"
"context"
"fmt"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"github.com/golang-jwt/jwt"
"go.uber.org/fx"
"go.uber.org/zap"
"golang.org/x/oauth2"
"time"
)
type Oauth struct {
cfg *config.Config
logger *zap.Logger
token *oauth2.Token
refresher oauth2.TokenSource
keepAliveRunning bool
oauthCfg *oauth2.Config
}
func NewOauth(lc fx.Lifecycle, cfg *config.Config, logger *zap.Logger) *Oauth {
oa := &Oauth{cfg: cfg, logger: logger}
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
oa.config()
return nil
},
})
return oa
}
func (o Oauth) config() *oauth2.Config {
if o.oauthCfg == nil {
o.oauthCfg = &oauth2.Config{
ClientID: o.cfg.Oauth.ClientId,
ClientSecret: o.cfg.Oauth.ClientSecret,
Scopes: []string{"admin"},
RedirectURL: fmt.Sprintf("https://%s/setup/callback", o.cfg.Domain),
Endpoint: oauth2.Endpoint{
TokenURL: fmt.Sprintf("%s/login/oauth/access_token", o.cfg.GiteaUrl),
AuthURL: fmt.Sprintf("%s/login/oauth/authorize", o.cfg.GiteaUrl),
},
}
}
if o.loadToken(o.oauthCfg) {
o.keepAlive()
}
return o.oauthCfg
}
func (o Oauth) authUrl() string {
return o.config().AuthCodeURL("state")
}
func (o *Oauth) loadToken(config *oauth2.Config) bool {
token := &oauth2.Token{}
if o.cfg.Oauth.Token != "" {
o.token = &oauth2.Token{AccessToken: o.cfg.Oauth.Token}
}
if o.cfg.Oauth.RefreshToken != "" {
o.token.RefreshToken = o.cfg.Oauth.RefreshToken
}
if o.token != nil {
valid := false
parseToken, _, err := new(jwt.Parser).ParseUnverified(o.cfg.Oauth.Token, jwt.MapClaims{})
if err != nil {
o.logger.Error("Error parsing token", zap.Error(err))
} else {
// Assert the token's claims to the desired type (MapClaims in this case)
if claims, ok := parseToken.Claims.(jwt.MapClaims); ok {
if exp, ok := claims["exp"].(float64); ok {
expirationTime := time.Unix(int64(exp), 0)
if time.Now().Before(expirationTime) {
valid = true
o.token.Expiry = expirationTime
}
}
}
if valid {
token = o.token
} else {
o.logger.Info("Token is expired, ignoring")
return false
}
}
}
o.refresher = config.TokenSource(context.Background(), token)
return true
}
func (o Oauth) keepAlive() {
if o.cfg.Oauth.Token == "" || o.cfg.Oauth.RefreshToken == "" {
o.logger.Error("No token or refresh token provided.")
return
}
if o.refresher == nil {
o.logger.Error("No refresher provided.")
return
}
if o.keepAliveRunning {
return
}
ticker := time.NewTicker(30 * time.Minute)
o.keepAliveRunning = true
go func() {
for {
select {
case <-ticker.C:
if !o.isTokenValid() {
if err := o.refreshToken(); err != nil {
o.logger.Error("Error refreshing token", zap.Error(err))
}
}
}
}
}()
}
func (o *Oauth) isTokenValid() bool {
if o.token == nil {
return false
}
return o.token.Valid()
}
func (o *Oauth) refreshToken() error {
o.logger.Info("Refreshing token...")
token, err := o.refresher.Token()
if err != nil {
return err
}
o.token = token
return nil
}
func (o *Oauth) exchange(code string) (*oauth2.Token, error) {
cfg := o.config()
token, err := o.config().Exchange(context.Background(), code)
if err != nil {
return nil, err
}
o.cfg.Oauth.Token = token.AccessToken
o.cfg.Oauth.RefreshToken = token.RefreshToken
err = config.SaveConfig(o.cfg)
if err != nil {
return nil, err
}
o.loadToken(cfg)
o.keepAlive()
return token, nil
}
func (o *Oauth) client() *gitea.Client {
client, err := getClient(ClientParams{
Config: o.cfg,
})
if err != nil {
o.logger.Fatal("Error creating gitea client", zap.Error(err))
}
return client
}

91
api/route_manifests.go Normal file
View File

@ -0,0 +1,91 @@
package api
import (
"encoding/json"
"fmt"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/db/model"
"github.com/gorilla/mux"
"go.uber.org/zap"
"gorm.io/gorm"
"net/http"
)
type manifests struct {
config *config.Config
db *gorm.DB
logger *zap.Logger
}
type createdApp struct {
Id uint `json:"id"`
PEM string `json:"pem"`
WebhookSecret string `json:"webhook_secret"`
ClientId string `json:"client_id"`
ClientSecret string `json:"client_secret"`
HTMLUrl string `json:"html_url"`
}
func newManifests(config *config.Config, db *gorm.DB, logger *zap.Logger) *manifests {
return &manifests{
config: config,
db: db,
logger: logger,
}
}
func (m *manifests) handlerConversion(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
code := vars["code"]
if len(code) == 0 {
http.Error(w, "No code provided", http.StatusBadRequest)
return
}
appRecord := &model.Apps{Code: code}
if err := m.db.First(appRecord).Error; err != nil {
m.logger.Error("App not found", zap.Error(err))
http.Error(w, "App not found", http.StatusNotFound)
return
}
app := createdApp{
Id: appRecord.ID,
PEM: appRecord.PrivateKey,
ClientId: "",
ClientSecret: "",
WebhookSecret: appRecord.WebhookSecret,
HTMLUrl: fmt.Sprintf("https://%s/apps/%s", m.config.Domain, appRecord.Name),
}
appRecord.Code = generateTempCode()
tx := m.db.Save(appRecord)
if tx.Error != nil {
m.logger.Error("Error updating app", zap.Error(tx.Error))
http.Error(w, "Error updating app", http.StatusInternalServerError)
return
}
appData, err := json.Marshal(app)
if err != nil {
m.logger.Error("Error marshalling app", zap.Error(err))
http.Error(w, "Error marshalling app", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
w.Write(appData)
}
func setupManifestsRoutes(params RouteParams) {
r := params.R
manifestApi := newManifests(params.Config, params.Db, params.Logger)
manifestsRouter := r.PathPrefix("/api/v3/app-manifests").Subrouter()
manifestsRouter.HandleFunc("/{code}/conversions", manifestApi.handlerConversion).Methods("POST")
}

11
api/router.go Normal file
View File

@ -0,0 +1,11 @@
package api
import (
"github.com/gorilla/mux"
)
func NewRouter() *mux.Router {
r := mux.NewRouter()
return r
}

62
api/routes.go Normal file
View File

@ -0,0 +1,62 @@
package api
import (
"code.gitea.io/sdk/gitea"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"github.com/gorilla/mux"
"go.uber.org/fx"
"go.uber.org/zap"
"gorm.io/gorm"
)
type RouteParams struct {
fx.In
Config *config.Config
Db *gorm.DB
Logger *zap.Logger
R *mux.Router
WebhookManager *WebhookManager
Oauth *Oauth
}
func SetupRoutes(params RouteParams) {
logger := params.Logger
r := params.R
r.Use(loggingMiddleware(logger))
setupApiRoutes(params)
setupSettingsRoutes(params)
setupManifestsRoutes(params)
setupAppRoutes(params)
setupAppInstallRoutes(params)
setupWebhookRoutes(params)
setupRestRoutes(params)
}
type ClientParams struct {
Config *config.Config
AuthToken string
}
func getClient(params ClientParams) (*gitea.Client, error) {
options := make([]gitea.ClientOption, 0)
authToken := ""
if len(params.AuthToken) > 0 {
authToken = params.AuthToken
}
if len(authToken) == 0 {
authToken = params.Config.Oauth.Token
}
options = append(options, gitea.SetToken(authToken))
client, err := gitea.NewClient(params.Config.GiteaUrl, options...)
if err != nil {
return nil, err
}
return client, nil
}

117
api/routes_app.go Normal file
View File

@ -0,0 +1,117 @@
package api
import (
"crypto/x509"
"encoding/json"
"encoding/pem"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/db/model"
"github.com/golang-jwt/jwt"
"github.com/gorilla/mux"
"go.uber.org/zap"
"gorm.io/gorm"
"net/http"
"time"
)
type appApi struct {
config *config.Config
db *gorm.DB
logger *zap.Logger
}
func newAppApi(cfg *config.Config, db *gorm.DB, logger *zap.Logger) *appApi {
return &appApi{config: cfg, db: db, logger: logger}
}
func (a *appApi) handlerNewAppInstall(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("App installations are not needed on this proxy. All webhooks are broadcasted to all registered apps."))
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "text/plain")
}
func (a *appApi) handlerAppGetAccessToken(w http.ResponseWriter, r *http.Request) {
now := time.Now().Add(-30 * time.Second)
expirationTime := now.Add(100 * 365 * 24 * time.Hour)
appName := mux.Vars(r)["app"]
appRecord := &model.Apps{}
appRecord.Name = appName
if err := a.db.First(appRecord).Error; err != nil {
http.Error(w, "Failed to find app", http.StatusNotFound)
a.logger.Error("Failed to find app", zap.Error(err))
return
}
block, _ := pem.Decode([]byte(appRecord.PrivateKey))
if block == nil {
http.Error(w, "Failed to parse PEM block containing the key", http.StatusInternalServerError)
a.logger.Error("Failed to parse PEM block containing the key")
return
}
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
http.Error(w, "Failed to parse DER encoded private key", http.StatusInternalServerError)
a.logger.Error("Failed to parse DER encoded private key", zap.Error(err))
}
claims := jwt.MapClaims{
"iss": appRecord.ID,
"iat": now.Unix(),
"exp": expirationTime,
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
signedToken, err := token.SignedString(privateKey)
if err != nil {
http.Error(w, "Failed to sign token", http.StatusInternalServerError)
a.logger.Error("Failed to sign token", zap.Error(err))
return
}
out := struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}{
Token: signedToken,
ExpiresAt: expirationTime,
}
a.respond(w, http.StatusCreated, out)
}
func (r appApi) respond(w http.ResponseWriter, status int, data interface{}) {
jsonData, err := json.Marshal(data)
if err != nil {
http.Error(w, "Failed to marshal response", http.StatusInternalServerError)
r.logger.Error("Failed to marshal response", zap.Error(err))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if data != nil {
_, _ = w.Write(jsonData)
}
}
func setupAppRoutes(params RouteParams) {
logger := params.Logger
cfg := params.Config
db := params.Db
r := params.R
appApi := newAppApi(cfg, db, logger)
appRouter := r.PathPrefix("/apps").Subrouter()
appRouter.Use(giteaOauthVerifyMiddleware(cfg))
appRouter.Use(requireAuthMiddleware(cfg))
appRouter.HandleFunc("/{app}/installations/new", appApi.handlerNewAppInstall).Methods("GET")
appRouter.HandleFunc("/{app}/access_token", appApi.handlerAppGetAccessToken).Methods("GET")
}

View File

@ -0,0 +1,119 @@
package api
import (
"crypto/x509"
"encoding/json"
"encoding/pem"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/db/model"
"github.com/golang-jwt/jwt"
"github.com/gorilla/mux"
"go.uber.org/zap"
"gorm.io/gorm"
"net/http"
"strconv"
"time"
)
type appInstallApi struct {
config *config.Config
logger *zap.Logger
db *gorm.DB
}
func newAppInstallApi(cfg *config.Config, db *gorm.DB, logger *zap.Logger) *appInstallApi {
return &appInstallApi{config: cfg, logger: logger, db: db}
}
func (a *appInstallApi) handlerAppGetAccessToken(w http.ResponseWriter, r *http.Request) {
now := time.Now().Add(-30 * time.Second)
expirationTime := now.Add(10 * time.Minute)
appId := mux.Vars(r)["installation_id"]
appIdInt, err := strconv.ParseInt(appId, 10, 64)
if err != nil {
http.Error(w, "Failed to parse app id", http.StatusBadRequest)
a.logger.Error("Failed to parse app id", zap.Error(err))
return
}
appRecord := &model.Apps{}
appRecord.ID = uint(appIdInt)
if err := a.db.First(appRecord).Error; err != nil {
http.Error(w, "Failed to find app", http.StatusNotFound)
a.logger.Error("Failed to find app", zap.Error(err))
return
}
block, _ := pem.Decode([]byte(appRecord.PrivateKey))
if block == nil {
http.Error(w, "Failed to parse PEM block containing the key", http.StatusInternalServerError)
a.logger.Error("Failed to parse PEM block containing the key")
return
}
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
http.Error(w, "Failed to parse DER encoded private key", http.StatusInternalServerError)
a.logger.Error("Failed to parse DER encoded private key", zap.Error(err))
}
claims := jwt.MapClaims{
"iss": appId,
"iat": now.Unix(),
"exp": expirationTime,
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
signedToken, err := token.SignedString(privateKey)
if err != nil {
http.Error(w, "Failed to sign token", http.StatusInternalServerError)
a.logger.Error("Failed to sign token", zap.Error(err))
return
}
out := struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}{
Token: signedToken,
ExpiresAt: expirationTime,
}
a.respond(w, http.StatusCreated, out)
}
func (r appInstallApi) respond(w http.ResponseWriter, status int, data interface{}) {
jsonData, err := json.Marshal(data)
if err != nil {
http.Error(w, "Failed to marshal response", http.StatusInternalServerError)
r.logger.Error("Failed to marshal response", zap.Error(err))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if data != nil {
_, _ = w.Write(jsonData)
}
}
func setupAppInstallRoutes(params RouteParams) {
logger := params.Logger
cfg := params.Config
db := params.Db
r := params.R
appInstallApi := newAppInstallApi(cfg, db, logger)
appRouter := r.PathPrefix("/api/v3/app").Subrouter()
appRouter.Use(githubRestVerifyMiddleware(params.Db))
appRouter.Use(githubRestRequireAuthMiddleware(params.Config))
appRouter.HandleFunc("/installations/{installation_id}/access_tokens", appInstallApi.handlerAppGetAccessToken).Methods("POST").Name("app-install-get-access-token")
}

473
api/routes_rest_api.go Normal file
View File

@ -0,0 +1,473 @@
package api
import (
gitea "code.gitea.io/sdk/gitea"
"encoding/json"
"fmt"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"github.com/google/go-github/v59/github"
"github.com/gorilla/mux"
"go.uber.org/zap"
"net/http"
"strconv"
)
type restApi struct {
config *config.Config
logger *zap.Logger
}
func newRestApi(cfg *config.Config, logger *zap.Logger) *restApi {
return &restApi{config: cfg, logger: logger}
}
func (r restApi) handlerGetPullRequestFiles(w http.ResponseWriter, request *http.Request) {
vars := mux.Vars(request)
owner := vars["owner"]
repo := vars["repo"]
pullNumber := vars["pull_number"]
client := r.getClientOrError(w)
if client == nil {
return
}
parsedPullNumber, err := strconv.ParseInt(pullNumber, 10, 64)
if err != nil {
http.Error(w, "Failed to parse pull number", http.StatusBadRequest)
r.logger.Error("Failed to parse pull number", zap.Error(err))
return
}
files, r2, err := client.ListPullRequestFiles(owner, repo, parsedPullNumber, gitea.ListPullRequestFilesOptions{
ListOptions: r.getPagingOptions(request),
})
if err != nil {
http.Error(w, "Failed to get pull request files", http.StatusInternalServerError)
r.logger.Error("Failed to get pull request files", zap.Error(err))
return
}
githubFiles := make([]*github.CommitFile, len(files))
for i, file := range files {
githubFiles[i] = convertCommitFile(file)
}
r.sendPagingHeaders(w, r2)
r.respond(w, http.StatusOK, githubFiles)
}
func (r restApi) getPagingOptions(request *http.Request) gitea.ListOptions {
page, _ := strconv.Atoi(request.URL.Query().Get("page"))
perPage, _ := strconv.Atoi(request.URL.Query().Get("per_page"))
return gitea.ListOptions{
Page: page,
PageSize: perPage,
}
}
func (r restApi) sendPagingHeaders(w http.ResponseWriter, apiResponse *gitea.Response) {
links := []string{}
baseURL := fmt.Sprintf("https://%s", r.config.Domain)
joinStrings := func(elements []string, separator string) string {
if len(elements) == 0 {
return ""
}
result := elements[0]
for i := 1; i < len(elements); i++ {
result += separator + elements[i]
}
return result
}
joinLinks := func(links []string) string {
return fmt.Sprintf("%s", joinStrings(links, ", "))
}
if apiResponse.FirstPage > 0 {
links = append(links, fmt.Sprintf(`<%s?page=%d>; rel="first"`, baseURL, apiResponse.FirstPage))
}
if apiResponse.PrevPage > 0 {
links = append(links, fmt.Sprintf(`<%s?page=%d>; rel="prev"`, baseURL, apiResponse.PrevPage))
}
if apiResponse.NextPage > 0 {
links = append(links, fmt.Sprintf(`<%s?page=%d>; rel="next"`, baseURL, apiResponse.NextPage))
}
if apiResponse.LastPage > 0 {
links = append(links, fmt.Sprintf(`<%s?page=%d>; rel="last"`, baseURL, apiResponse.LastPage))
}
if len(links) > 0 {
w.Header().Add("Link", fmt.Sprintf("%s", joinLinks(links)))
}
}
func (r restApi) respond(w http.ResponseWriter, status int, data interface{}) {
jsonData, err := json.Marshal(data)
if err != nil {
http.Error(w, "Failed to marshal response", http.StatusInternalServerError)
r.logger.Error("Failed to marshal response", zap.Error(err))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if data != nil {
_, _ = w.Write(jsonData)
}
}
func (r restApi) getClientOrError(w http.ResponseWriter) *gitea.Client {
client, err := getClient(ClientParams{
Config: r.config,
})
if err != nil {
http.Error(w, "Failed to get Gitea client", http.StatusInternalServerError)
r.logger.Error("Failed to get Gitea client", zap.Error(err))
return nil
}
return client
}
func (r restApi) handlerGetTree(w http.ResponseWriter, request *http.Request) {
vars := mux.Vars(request)
owner := vars["owner"]
repo := vars["repo"]
treeSha := vars["tree_sha"]
recursive := false
if request.URL.Query().Has("recursive") {
recursive = true
}
client := r.getClientOrError(w)
if client == nil {
return
}
tree, r2, err := client.GetTrees(owner, repo, treeSha, recursive)
if err != nil {
http.Error(w, "Failed to get tree", http.StatusInternalServerError)
r.logger.Error("Failed to get tree", zap.Error(err))
return
}
treeResponse := convertGitTree(tree)
r.sendPagingHeaders(w, r2)
r.respond(w, http.StatusOK, treeResponse)
}
func (r restApi) handleGetIssueComments(w http.ResponseWriter, request *http.Request) {
vars := mux.Vars(request)
owner := vars["owner"]
repo := vars["repo"]
issueNumber := vars["issue_number"]
client := r.getClientOrError(w)
if client == nil {
return
}
issueNumberInt, err := strconv.Atoi(issueNumber)
if err != nil {
http.Error(w, "Failed to parse issue number", http.StatusBadRequest)
r.logger.Error("Failed to parse issue number", zap.Error(err))
return
}
comments, r2, err := client.ListIssueComments(owner, repo, int64(issueNumberInt), gitea.ListIssueCommentOptions{ListOptions: r.getPagingOptions(request)})
if err != nil {
http.Error(w, "Failed to get issue comments", http.StatusInternalServerError)
r.logger.Error("Failed to get issue comments", zap.Error(err))
return
}
ghComments := make([]*github.IssueComment, len(comments))
for i, comment := range comments {
reactions, _, _ := client.GetIssueCommentReactions(owner, repo, comment.ID)
ghComments[i] = convertIssueComment(comment, reactions)
}
r.sendPagingHeaders(w, r2)
r.respond(w, http.StatusOK, ghComments)
}
func (r restApi) handlerCreateIssueComment(w http.ResponseWriter, request *http.Request) {
vars := mux.Vars(request)
owner := vars["owner"]
repo := vars["repo"]
issueNumber := vars["issue_number"]
client := r.getClientOrError(w)
if client == nil {
return
}
ghComment := github.IssueComment{}
if err := r.parseJsonBody(request, &ghComment); err != nil {
http.Error(w, "Failed to parse request body", http.StatusBadRequest)
r.logger.Error("Failed to parse request body", zap.Error(err))
}
issueNumberInt, err := strconv.Atoi(issueNumber)
if err != nil {
http.Error(w, "Failed to parse issue number", http.StatusBadRequest)
r.logger.Error("Failed to parse issue number", zap.Error(err))
return
}
comment := gitea.CreateIssueCommentOption{
Body: ghComment.GetBody(),
}
commentResponse, _, err := client.CreateIssueComment(owner, repo, int64(issueNumberInt), comment)
if err != nil {
http.Error(w, "Failed to create comment", http.StatusInternalServerError)
r.logger.Error("Failed to create comment", zap.Error(err))
return
}
newGhComment := convertIssueComment(commentResponse, nil)
r.respond(w, http.StatusCreated, newGhComment)
}
func (r restApi) handlerUpdateIssueComment(w http.ResponseWriter, request *http.Request) {
vars := mux.Vars(request)
owner := vars["owner"]
repo := vars["repo"]
commentID := vars["comment_id"]
client := r.getClientOrError(w)
if client == nil {
return
}
ghComment := github.IssueComment{}
if err := r.parseJsonBody(request, &ghComment); err != nil {
http.Error(w, "Failed to parse request body", http.StatusBadRequest)
r.logger.Error("Failed to parse request body", zap.Error(err))
}
commentIDInt, err := strconv.Atoi(commentID)
if err != nil {
http.Error(w, "Failed to parse comment ID", http.StatusBadRequest)
r.logger.Error("Failed to parse comment ID", zap.Error(err))
return
}
comment := gitea.EditIssueCommentOption{
Body: ghComment.GetBody(),
}
commentResponse, _, err := client.EditIssueComment(owner, repo, int64(commentIDInt), comment)
if err != nil {
http.Error(w, "Failed to update comment", http.StatusInternalServerError)
r.logger.Error("Failed to update comment", zap.Error(err))
return
}
newGhComment := convertIssueComment(commentResponse, nil)
r.respond(w, http.StatusOK, newGhComment)
}
func (r restApi) handlerCreateRelease(w http.ResponseWriter, request *http.Request) {
vars := mux.Vars(request)
owner := vars["owner"]
repo := vars["repo"]
client := r.getClientOrError(w)
if client == nil {
return
}
ghRelease := github.RepositoryRelease{}
if err := r.parseJsonBody(request, &ghRelease); err != nil {
http.Error(w, "Failed to parse request body", http.StatusBadRequest)
r.logger.Error("Failed to parse request body", zap.Error(err))
}
release := gitea.CreateReleaseOption{
TagName: ghRelease.GetTagName(),
Target: ghRelease.GetTargetCommitish(),
Title: ghRelease.GetName(),
Note: ghRelease.GetBody(),
IsDraft: ghRelease.GetDraft(),
IsPrerelease: ghRelease.GetPrerelease(),
}
releaseResponse, r2, err := client.CreateRelease(owner, repo, release)
if err != nil {
http.Error(w, "Failed to create release", http.StatusInternalServerError)
r.logger.Error("Failed to create release", zap.Error(err))
return
}
newGhRelease := convertRelease(releaseResponse)
r.sendPagingHeaders(w, r2)
r.respond(w, http.StatusCreated, newGhRelease)
}
func (r restApi) handlerCreatePullRequest(w http.ResponseWriter, request *http.Request) {
vars := mux.Vars(request)
owner := vars["owner"]
repo := vars["repo"]
client := r.getClientOrError(w)
if client == nil {
return
}
ghPullRequest := github.NewPullRequest{}
if err := r.parseJsonBody(request, &ghPullRequest); err != nil {
http.Error(w, "Failed to parse request body", http.StatusBadRequest)
r.logger.Error("Failed to parse request body", zap.Error(err))
}
pullRequest := gitea.CreatePullRequestOption{
Title: ghPullRequest.GetTitle(),
Head: ghPullRequest.GetHead(),
Base: ghPullRequest.GetBase(),
Body: ghPullRequest.GetBody(),
}
pullRequestResponse, r2, err := client.CreatePullRequest(owner, repo, pullRequest)
if err != nil {
http.Error(w, "Failed to create pull request", http.StatusInternalServerError)
r.logger.Error("Failed to create pull request", zap.Error(err))
return
}
newGhPullRequest := convertPullRequest(pullRequestResponse)
r.sendPagingHeaders(w, r2)
r.respond(w, http.StatusCreated, newGhPullRequest)
}
func (r restApi) handlerUpdatePullRequest(w http.ResponseWriter, request *http.Request) {
vars := mux.Vars(request)
owner := vars["owner"]
repo := vars["repo"]
pullNumber := vars["pull_number"]
parsedPullNumber, err := strconv.ParseInt(pullNumber, 10, 64)
if err != nil {
http.Error(w, "Failed to parse pull number", http.StatusBadRequest)
r.logger.Error("Failed to parse pull number", zap.Error(err))
return
}
client := r.getClientOrError(w)
if client == nil {
return
}
type pullRequestUpdate struct {
Title *string `json:"title,omitempty"`
Body *string `json:"body,omitempty"`
State *string `json:"state,omitempty"`
Base *string `json:"base,omitempty"`
MaintainerCanModify *bool `json:"maintainer_can_modify,omitempty"`
}
ghPullRequest := pullRequestUpdate{}
pullRequest := gitea.EditPullRequestOption{}
if err := r.parseJsonBody(request, &ghPullRequest); err != nil {
http.Error(w, "Failed to parse request body", http.StatusBadRequest)
r.logger.Error("Failed to parse request body", zap.Error(err))
}
if ghPullRequest.State != nil {
if *ghPullRequest.State != "open" && *ghPullRequest.State != "closed" {
http.Error(w, "Invalid state", http.StatusBadRequest)
return
}
state := *ghPullRequest.State
stateType := gitea.StateType(state)
pullRequest.State = &stateType
}
if ghPullRequest.Title != nil {
pullRequest.Title = *ghPullRequest.Title
}
if ghPullRequest.Body != nil {
pullRequest.Body = *ghPullRequest.Body
}
pullRequestResponse, r2, err := client.EditPullRequest(owner, repo, parsedPullNumber, pullRequest)
if err != nil {
http.Error(w, "Failed to update pull request", http.StatusInternalServerError)
r.logger.Error("Failed to update pull request", zap.Error(err))
return
}
newGhPullRequest := convertPullRequest(pullRequestResponse)
r.sendPagingHeaders(w, r2)
r.respond(w, http.StatusOK, newGhPullRequest)
}
func (r restApi) parseJsonBody(request *http.Request, obj interface{}) error {
if obj == nil {
obj = make(map[string]interface{})
}
decoder := json.NewDecoder(request.Body)
return decoder.Decode(obj)
}
func setupRestRoutes(params RouteParams) {
logger := params.Logger
cfg := params.Config
r := params.R
restApi := newRestApi(cfg, logger)
setupRoutes := func(r *mux.Router) {
r.HandleFunc("/repos/{owner}/{repo}/pulls/{pull_number}/files", restApi.handlerGetPullRequestFiles).Methods("GET")
r.HandleFunc("/repos/{owner}/{repo}/git/trees/{tree_sha}", restApi.handlerGetTree).Methods("GET")
// Comment routes
r.HandleFunc("/repos/{owner}/{repo}/issues/{issue_number}/comments", restApi.handleGetIssueComments).Methods("GET")
r.HandleFunc("/repos/{owner}/{repo}/issues/{issue_number}/comments", restApi.handlerCreateIssueComment).Methods("POST")
r.HandleFunc("/repos/{owner}/{repo}/issues/comments/{comment_id}", restApi.handlerUpdateIssueComment).Methods("PATCH")
// Repo Release routes
r.HandleFunc("/repos/{owner}/{repo}/releases", restApi.handlerCreateRelease).Methods("POST")
// Pull Request routes
r.HandleFunc("/repos/{owner}/{repo}/pulls", restApi.handlerCreatePullRequest).Methods("POST")
r.HandleFunc("/repos/{owner}/{repo}/pulls/{pull_number}", restApi.handlerUpdatePullRequest).Methods("PATCH")
}
restRouter := r.PathPrefix("/api").Subrouter()
restRouter.Use(githubRestVerifyMiddleware(params.Db))
restRouter.Use(githubRestRequireAuthMiddleware(params.Config))
setupRoutes(restRouter)
setupRoutes(restRouter.PathPrefix("/v3").Subrouter())
}

83
api/routes_settings.go Normal file
View File

@ -0,0 +1,83 @@
package api
import (
"encoding/json"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/db/model"
"go.uber.org/zap"
"gorm.io/gorm"
"net/http"
"net/url"
)
func newSettingsApi(cfg *config.Config, db *gorm.DB, logger *zap.Logger) *settingsApi {
return &settingsApi{config: cfg, db: db, logger: logger}
}
func (s settingsApi) handlerNewApp(w http.ResponseWriter, r *http.Request) {
manifestData := r.FormValue("manifest")
var manifestObj manifest
err := json.Unmarshal([]byte(manifestData), &manifestObj)
if err != nil {
http.Error(w, "Failed to parse manifest", http.StatusBadRequest)
return
}
appData := newApp()
appRecord := &model.Apps{
Name: manifestObj.Name,
Url: manifestObj.Url,
WebhookUrl: manifestObj.HookAttributes.URL,
Code: generateTempCode(),
WebhookSecret: generateTempCode(),
PrivateKey: appData.PrivateKey,
}
tx := s.db.Create(appRecord)
if tx.Error != nil {
s.logger.Error("Error creating app", zap.Error(tx.Error))
http.Error(w, "Error creating app", http.StatusInternalServerError)
return
}
if len(manifestObj.RedirectURL) == 0 {
s.logger.Error("Redirect URL is required")
http.Error(w, "Redirect URL is required", http.StatusBadRequest)
return
}
redirectUrl, err := url.Parse(manifestObj.RedirectURL)
if err != nil {
s.logger.Error("Error parsing redirect URL", zap.Error(err))
http.Error(w, "Error parsing redirect URL", http.StatusInternalServerError)
return
}
query := redirectUrl.Query()
query.Add("code", appRecord.Code)
if r.URL.Query().Get("state") != "" {
query.Add("state", r.URL.Query().Get("state"))
}
redirectUrl.RawQuery = query.Encode()
http.Redirect(w, r, redirectUrl.String(), http.StatusFound)
}
func setupSettingsRoutes(params RouteParams) {
r := params.R
settingsRouter := r.PathPrefix("/settings").Subrouter()
settingsRouter.Use(giteaOauthVerifyMiddleware(params.Config))
settingsRouter.Use(requireAuthMiddleware(params.Config))
settingsApi := newSettingsApi(params.Config, params.Db, params.Logger)
settingsRouter.HandleFunc("/apps/new", settingsApi.handlerNewApp).Methods("POST")
}

70
api/routes_setup.go Normal file
View File

@ -0,0 +1,70 @@
package api
import (
"errors"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"go.uber.org/zap"
"net/http"
)
type setupApi struct {
config *config.Config
logger *zap.Logger
oauth *Oauth
}
func newSetupApi(config *config.Config, logger *zap.Logger, oauth *Oauth) *setupApi {
return &setupApi{config: config, logger: logger, oauth: oauth}
}
func (s setupApi) setupHandler(w http.ResponseWriter, r *http.Request) {
status := getAuthedStatusFromRequest(r)
if status {
redirectCookie := getCookie(r, REDIRECT_AFTER_AUTH)
if redirectCookie != "" {
deleteCookie(w, REDIRECT_AFTER_AUTH)
http.Redirect(w, r, redirectCookie, http.StatusFound)
return
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte("Setup is complete, you are authorized to use the proxy."))
return
}
http.Redirect(w, r, s.oauth.authUrl(), http.StatusFound)
}
func (s setupApi) callbackHandler(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("error") != "" {
http.Error(w, errors.Join(errors.New("Error authorizing with Gitea: "), errors.New(r.URL.Query().Get("error"))).Error(), http.StatusBadRequest)
}
code := r.URL.Query().Get("code")
if len(code) == 0 {
http.Error(w, "No code provided", http.StatusBadRequest)
return
}
token, err := s.oauth.exchange(code)
if err != nil {
http.Error(w, "Failed to exchange code for token", http.StatusInternalServerError)
return
}
setAuthCookie(token.AccessToken, s.config.Domain, w)
http.Redirect(w, r, "/setup", http.StatusFound)
}
func setupApiRoutes(params RouteParams) {
r := params.R
setupRouter := r.PathPrefix("/setup").Subrouter()
setupRouter.Use(giteaOauthVerifyMiddleware(params.Config))
setupApi := newSetupApi(params.Config, params.Logger, params.Oauth)
setupRouter.HandleFunc("", setupApi.setupHandler).Methods("GET")
setupRouter.HandleFunc("/callback", setupApi.callbackHandler).Methods("GET")
}

73
api/routes_webhooks.go Normal file
View File

@ -0,0 +1,73 @@
package api
import (
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/sdk/gitea"
"encoding/json"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"go.uber.org/zap"
"gorm.io/gorm"
"net/http"
)
type webhookApi struct {
config *config.Config
logger *zap.Logger
db *gorm.DB
whm *WebhookManager
}
func newWebhookApi(cfg *config.Config, db *gorm.DB, logger *zap.Logger, whm *WebhookManager) *webhookApi {
return &webhookApi{config: cfg, db: db, logger: logger, whm: whm}
}
func (a *webhookApi) handlePullRequest(w http.ResponseWriter, r *http.Request) {
webhook := a.getWebhookData(w, r)
payload := &structs.PullRequestPayload{}
err := json.Unmarshal(webhook, payload)
if a.unmarshallError(w, err) {
return
}
a.whm.HandlePullRequest(payload, r)
}
func setupWebhookRoutes(params RouteParams) {
cfg := params.Config
db := params.Db
logger := params.Logger
r := params.R
whm := params.WebhookManager
webhookApi := newWebhookApi(cfg, db, logger, whm)
webhookRouter := r.PathPrefix("/api").Subrouter()
webhookRouter.Use(gitea.VerifyWebhookSignatureMiddleware(cfg.GiteaWebHookSecret))
webhookRouter.Use(storeWebhookDataMiddleware(logger))
webhookRouter.HandleFunc("/webhooks/pull_request", webhookApi.handlePullRequest).Methods("POST")
}
func (a *webhookApi) getWebhookData(w http.ResponseWriter, r *http.Request) []byte {
return getWebhookData(r, a.logger)
}
func (a *webhookApi) unmarshallError(w http.ResponseWriter, err error) bool {
if err != nil {
a.logger.Error("Failed to unmarshal webhook data", zap.Error(err))
http.Error(w, "Failed to unmarshal webhook data", http.StatusInternalServerError)
}
return err != nil
}
func getWebhookData(r *http.Request, logger *zap.Logger) []byte {
webhook := r.Context().Value(WEBHOOK_CONTEXT_KEY)
webhookData, ok := webhook.([]byte)
if !ok {
logger.Fatal("Failed to get webhook data from context")
}
return webhookData
}

133
api/webhook_manager.go Normal file
View File

@ -0,0 +1,133 @@
package api
import (
"bytes"
giteaTypes "code.gitea.io/gitea/modules/structs"
"encoding/json"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/db/model"
"go.uber.org/zap"
"gorm.io/gorm"
"io"
"net/http"
"regexp"
"strings"
)
type WebhookManager struct {
config *config.Config
db *gorm.DB
logger *zap.Logger
}
type IncomingWebhookData struct {
Headers http.Header
}
func NewWebhookManager(cfg *config.Config, db *gorm.DB, logger *zap.Logger) *WebhookManager {
return &WebhookManager{config: cfg, db: db, logger: logger}
}
func (whm *WebhookManager) HandlePullRequest(request *giteaTypes.PullRequestPayload, r *http.Request) {
ghEvent := convertPullRequestEvent(request)
githubAction := translatePrAction(request.Action, true)
r.Header.Set("X-GitHub-Event", githubAction)
whm.sendWebhooks(ghEvent, r)
}
func (whm *WebhookManager) sendWebhooks(request interface{}, r *http.Request) {
var apps []model.Apps
result := whm.db.Find(&apps)
if result.Error != nil {
whm.logger.Error("Failed to query apps", zap.Error(result.Error))
return
}
for _, app := range apps {
go func(app model.Apps) {
payloadBytes, err := json.Marshal(request)
if err != nil {
whm.logger.Error("Failed to marshal payload", zap.Error(err))
return
}
var rawMap map[string]interface{}
err = json.Unmarshal(payloadBytes, &rawMap)
if err != nil {
whm.logger.Error("Failed to unmarshal payload", zap.Error(err))
return
}
rawMap["installation"] = struct {
ID uint `json:"id"`
}{ID: app.ID}
payloadBytes, err = json.Marshal(rawMap)
if err != nil {
whm.logger.Error("Failed to marshal payload", zap.Error(err))
return
}
req, err := http.NewRequest("POST", app.WebhookUrl, bytes.NewBuffer(payloadBytes))
if err != nil {
whm.logger.Error("Failed to create request", zap.Error(err), zap.String("url", app.WebhookUrl))
return
}
req.Header.Set("Content-Type", "application/json")
for key, values := range r.Header {
for _, value := range values {
req.Header.Add(key, value)
}
}
payload := toNormalizedJson(payloadBytes)
signature := generatePayloadSignature(payload, app.WebhookSecret)
req.Header.Set("X-Hub-Signature-256", signature)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
whm.logger.Error("Failed to send webhook", zap.Error(err), zap.String("url", app.WebhookUrl))
return
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
whm.logger.Error("Failed to close response body", zap.Error(err))
}
}(resp.Body)
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
whm.logger.Info("Webhook sent successfully", zap.String("url", app.WebhookUrl))
} else {
whm.logger.Error("Webhook failed", zap.String("url", app.WebhookUrl), zap.Int("status", resp.StatusCode))
}
}(app)
}
}
/*
* Ported from https://github.com/octokit/webhooks.js/blob/984f3f51e077dbc7637aa55406c3bb114dbb7f82/src/to-normalized-json-string.ts
*/
func toNormalizedJson(payload []byte) []byte {
jsonString := string(payload)
// Regex to find Unicode escape sequences
re := regexp.MustCompile(`\\u([\da-fA-F]{4})`)
// Function to convert found sequences to uppercase
replaceFunc := func(match string) string {
parts := strings.Split(match, "\\u")
// Convert the Unicode sequence part to uppercase
return parts[0] + "\\u" + strings.ToUpper(parts[1])
}
// Replace the matches in the jsonString
normalizedString := re.ReplaceAllStringFunc(jsonString, replaceFunc)
return []byte(normalizedString)
}

34
cmd/proxy/main.go Normal file
View File

@ -0,0 +1,34 @@
package main
import (
"git.lumeweb.com/LumeWeb/gitea-github-proxy/api"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/db"
"go.uber.org/fx"
"go.uber.org/fx/fxevent"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"net/http"
)
func main() {
logger, _ := zap.NewDevelopment()
fx.New(
fx.Supply(logger),
fx.WithLogger(func(logger *zap.Logger) fxevent.Logger {
log := &fxevent.ZapLogger{Logger: logger}
log.UseLogLevel(zapcore.InfoLevel)
log.UseErrorLevel(zapcore.ErrorLevel)
return log
}),
config.Module,
db.Module,
fx.Provide(api.NewOauth),
fx.Provide(api.NewRouter),
fx.Provide(NewServer),
fx.Provide(api.NewWebhookManager),
fx.Invoke(api.SetupRoutes),
fx.Invoke(func(*http.Server) {}),
).Run()
}

48
cmd/proxy/server.go Normal file
View File

@ -0,0 +1,48 @@
package main
import (
"context"
"fmt"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"github.com/gorilla/mux"
"go.uber.org/fx"
"go.uber.org/zap"
"net"
"net/http"
)
type ServerParams struct {
fx.In
Logger *zap.Logger
Router *mux.Router
Config *config.Config
}
func NewServer(lc fx.Lifecycle, params ServerParams) (*http.Server, error) {
srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", params.Config.Host, params.Config.Port),
Handler: params.Router,
}
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
ln, err := net.Listen("tcp", srv.Addr)
if err != nil {
return err
}
go func() {
err := srv.Serve(ln)
if err != nil {
params.Logger.Fatal("Failed to serve", zap.Error(err))
}
}()
return nil
},
OnStop: func(ctx context.Context) error {
return srv.Shutdown(ctx)
},
})
return srv, nil
}

158
config/config.go Normal file
View File

@ -0,0 +1,158 @@
package config
import (
"encoding"
"encoding/base64"
"fmt"
"github.com/fatih/structs"
"github.com/iancoleman/strcase"
"github.com/spf13/viper"
"go.uber.org/fx"
"go.uber.org/zap"
"reflect"
)
import (
"crypto/ed25519"
)
type PrivateKey ed25519.PrivateKey
var (
_ encoding.TextUnmarshaler = (*PrivateKey)(nil)
_ encoding.TextMarshaler = (*PrivateKey)(nil)
)
func (p *PrivateKey) UnmarshalText(text []byte) error {
dec, err := base64.StdEncoding.DecodeString(string(text))
if err != nil {
return err
}
*p = dec
return nil
}
func (p *PrivateKey) MarshalText() ([]byte, error) {
return []byte(base64.StdEncoding.EncodeToString(*p)), nil
}
type Config struct {
Host string `mapstructure:"host" structs:"host"`
Port int `mapstructure:"port" structs:"port"`
GiteaUrl string `mapstructure:"gitea_url" structs:"gitea_url"`
DbPath string `mapstructure:"db_path" structs:"db_path"`
Oauth OauthConfig
JwtPrivateKey PrivateKey
Domain string `mapstructure:"domain" structs:"domain"`
GiteaWebHookSecret string `mapstructure:"gitea_webhook_secret" structs:"gitea_webhook_secret"`
}
type OauthConfig struct {
Authorization string `mapstructure:"authorization" structs:"authorization"`
Token string `mapstructure:"token" structs:"token"`
ClientId string `mapstructure:"client_id" structs:"client_id"`
ClientSecret string `mapstructure:"client_secret" structs:"client_secret"`
RefreshToken string `mapstructure:"refresh_token" structs:"refresh_token"`
}
var Module = fx.Module("config",
fx.Options(
fx.Provide(NewConfig),
),
)
func NewConfig(logger *zap.Logger) *Config {
c := &Config{}
_, sk, err := ed25519.GenerateKey(nil)
if err != nil {
logger.Fatal("Error generating private key", zap.Error(err))
}
// Set the default values
viper.SetDefault("host", "localhost")
viper.SetDefault("port", 8080)
viper.SetDefault("gitea_url", "")
viper.SetDefault("db_path", "data.db")
viper.SetDefault("jwt_private_key", base64.StdEncoding.EncodeToString(sk))
viper.SetDefault("oauth.client_id", "")
viper.SetDefault("oauth.client_secret", "")
viper.AddConfigPath(".")
viper.AddConfigPath("/etc/gitea-github-proxy")
viper.SetConfigName("config")
viper.SetConfigType("yaml")
err = viper.ReadInConfig()
if err != nil {
err = viper.SafeWriteConfig()
if err != nil {
logger.Fatal("Error writing config file", zap.Error(err))
}
}
err = viper.Unmarshal(c)
if err != nil {
logger.Fatal("Error unmarshalling config", zap.Error(err))
}
/* err = viper.UnmarshalKey("jwt_private_key", &c.JwtPrivateKey, viper.DecodeHook(mapstructure.TextUnmarshallerHookFunc()))
if err != nil {
logger.Fatal("Error unmarshalling jwtPrivateKey", zap.Error(err))
}*/
if len(c.GiteaUrl) == 0 {
logger.Fatal("Gitea URL is required")
}
return c
}
func SaveConfig(cfg *Config) error {
data := structs.New(cfg)
for _, field := range data.Fields() {
processFields(field, nil)
}
/* if field, ok := interface{}(cfg.JwtPrivateKey).(encoding.TextMarshaler); ok {
text, err := field.MarshalText()
if err != nil {
return err
}
viper.Set("jwt_private_key", string(text))
}
*/
return viper.WriteConfig()
}
func processFields(f *structs.Field, parent *structs.Field) {
if f.IsZero() {
return
}
if f.Kind() == reflect.Struct {
fields := f.Fields()
if len(fields) > 0 {
for _, field := range fields {
processFields(field, f)
}
return
}
}
name := ""
if parent != nil {
name = fmt.Sprintf("%s.%s", parent.Name(), f.Name())
} else {
name = f.Name()
}
name = strcase.ToSnakeWithIgnore(name, ".")
viper.Set(name, f.Value())
}

47
db/db.go Normal file
View File

@ -0,0 +1,47 @@
package db
import (
"context"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/config"
"git.lumeweb.com/LumeWeb/gitea-github-proxy/db/model"
"go.uber.org/fx"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type DatabaseParams struct {
fx.In
Config *config.Config
}
var Module = fx.Module("db",
fx.Options(
fx.Provide(NewDatabase),
),
)
func NewDatabase(lc fx.Lifecycle, params DatabaseParams) *gorm.DB {
db, err := gorm.Open(sqlite.Open(params.Config.DbPath), &gorm.Config{})
if err != nil {
panic(err)
}
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
return db.AutoMigrate(
&model.Apps{},
)
},
OnStop: func(ctx context.Context) error {
sqlDb, err := db.DB()
if err != nil {
return err
}
return sqlDb.Close()
},
})
return db
}

14
db/model/apps.go Normal file
View File

@ -0,0 +1,14 @@
package model
import "gorm.io/gorm"
type Apps struct {
gorm.Model
Name string
Url string
WebhookUrl string
WebhookSecret string
Code string `gorm:"uniqueIndex"`
PrivateKey string
}

55
go.mod Normal file
View File

@ -0,0 +1,55 @@
module git.lumeweb.com/LumeWeb/gitea-github-proxy
go 1.21
require (
code.gitea.io/gitea v1.21.5
code.gitea.io/sdk/gitea v0.17.1
github.com/fatih/structs v1.1.0
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/google/go-github/v59 v59.0.0
github.com/gorilla/mux v1.8.1
github.com/iancoleman/strcase v0.3.0
github.com/spf13/viper v1.18.2
go.uber.org/fx v1.20.1
go.uber.org/zap v1.26.0
golang.org/x/oauth2 v0.17.0
gorm.io/driver/sqlite v1.5.5
gorm.io/gorm v1.25.7
)
require (
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-fed/httpsig v1.1.1-0.20201223112313-55836744818e // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/dig v1.17.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.19.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/sys v0.17.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)