1
2
3 package ghappman
4
5 import (
6 "embed"
7 "fmt"
8 "html/template"
9 "log"
10 "math/rand"
11 "net/http"
12 "os"
13 "time"
14
15 "github.com/gin-gonic/gin"
16 "github.com/google/go-github/v33/github"
17 "golang.org/x/crypto/bcrypt"
18 )
19
20
21 var templates embed.FS
22
23 const (
24
25
26 createAppTimeout = time.Hour
27 ghamURL = "https://ghappman.edge-infra.dev"
28 )
29
30
31 type AppConfig struct {
32 Name string `json:"name"`
33 ClientID string `json:"client_id"`
34 ClientSecret string `json:"client_secret"`
35 PEM string `json:"pem"`
36 AppID string `json:"id"`
37 }
38
39
40 type CreateAppReq struct {
41 UserKey string `json:"user_key" binding:"required"`
42 InstallSuffix string `json:"install_suffix" binding:"required"`
43 }
44
45
46 type CreateAppRes struct {
47 State string `json:"state"`
48 CreateURL string `json:"create_url"`
49 ExpiresIn time.Duration `json:"expires_in"`
50 }
51
52
53 type Server struct {
54 *gin.Engine
55 Sessions *sessionStore
56 }
57
58
59 func RandAN(n int) string {
60 var runes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
61 genName := make([]rune, n)
62 for i := range genName {
63 genName[i] = runes[rand.Intn(len(runes))]
64 }
65 return string(genName)
66 }
67
68
69 func NewServer() *Server {
70 server := &Server{
71 Engine: gin.Default(),
72 Sessions: newSessionStore(createAppTimeout),
73 }
74 logger := log.New(os.Stdout, "ghappman", log.Ldate|log.Ltime|log.Lshortfile)
75
76 t, err := template.ParseFS(templates, "templates/*.tmpl")
77 if err != nil {
78 logger.Fatalf("failed to load templates dir. err: %v", err.Error())
79 }
80 server.SetHTMLTemplate(t)
81
82 server.GET("/", func(c *gin.Context) {
83
84 c.Status(http.StatusOK)
85 })
86
87 server.POST("/new-app", func(c *gin.Context) {
88 var body CreateAppReq
89 if err := c.ShouldBindJSON(&body); err != nil {
90 c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
91 return
92 }
93 state := RandAN(64)
94 createURL := fmt.Sprintf("%s/new-app?state=%s&install_suffix=%s", ghamURL, state, body.InstallSuffix)
95
96 hash, _ := bcrypt.GenerateFromPassword([]byte(body.UserKey), bcrypt.MinCost)
97 server.Sessions.startSession(state, hash)
98
99 res := &CreateAppRes{
100 State: state,
101 CreateURL: createURL,
102 ExpiresIn: createAppTimeout,
103 }
104 c.JSON(http.StatusAccepted, res)
105 })
106
107 server.GET("/new-app", func(c *gin.Context) {
108 installSuffix := c.Query("install_suffix")
109 state := c.Query("state")
110 if installSuffix == "" || state == "" {
111 c.AbortWithStatus(http.StatusBadRequest)
112 return
113 }
114 if !server.Sessions.hasSession(state) {
115 c.AbortWithStatus(http.StatusBadRequest)
116 return
117 }
118 c.HTML(http.StatusOK, "form.tmpl", gin.H{
119 "state": state,
120 "installName": installSuffix,
121 })
122
123
124
125 })
126
127 server.GET("/new-app/:state/callback", func(c *gin.Context) {
128 code := c.Query("code")
129 state := c.Param("state")
130
131 if code == "" || state == "" {
132 c.AbortWithStatus(http.StatusBadRequest)
133 return
134 }
135 if !server.Sessions.hasSession(state) {
136 c.AbortWithStatus(http.StatusNotFound)
137 return
138 }
139
140
141
142 client := github.NewClient(nil)
143 app, _, err := client.Apps.CompleteAppManifest(c.Request.Context(), code)
144 if err != nil {
145 logger.Printf("failed to complete app manifest. err: %s\n", err.Error())
146 c.Status(http.StatusBadGateway)
147 return
148 }
149
150 session := server.Sessions.getSession(state)
151 if session == nil {
152 c.AbortWithStatus(http.StatusInternalServerError)
153 return
154 }
155 sessionApp := &AppConfig{
156 Name: *app.Name,
157 ClientID: *app.ClientID,
158 PEM: *app.PEM,
159 AppID: fmt.Sprint(*app.ID),
160 }
161 server.Sessions.updateSession(state, sessionApp)
162
163 logger.Println("app created, will attempt to start installing it...")
164 logger.Printf("id: %d\n", app.ID)
165 logger.Printf("name: %s\n", *app.Name)
166
167
168
169
170
171 if app.Name == nil || *app.Name == "" {
172 logger.Println("failed to create app. app name from github was missing")
173 c.Status(http.StatusBadGateway)
174 return
175 }
176 installationURL := fmt.Sprintf("https://github.com/apps/%s/installations/new", *app.Name)
177 c.Redirect(http.StatusFound, installationURL)
178 })
179
180 server.GET("/new-app/:state/app", func(c *gin.Context) {
181
182 state := c.Param("state")
183 if state == "" {
184 c.AbortWithStatus(http.StatusBadRequest)
185 return
186 }
187
188
189 key := c.Request.Header.Get("X-Appkey")
190 if key == "" {
191 c.AbortWithStatus(http.StatusUnauthorized)
192 return
193 }
194 session := server.Sessions.getSession(state)
195 if session == nil {
196 c.AbortWithStatus(http.StatusNotFound)
197 return
198 }
199 hash := session.KeyHash
200 if err := bcrypt.CompareHashAndPassword(hash, []byte(key)); err != nil {
201 c.AbortWithStatus(http.StatusNotFound)
202 return
203 }
204
205
206
207 appConfig := session.App
208 if appConfig == nil {
209 c.AbortWithStatus(http.StatusBadRequest)
210 return
211 }
212 fmt.Printf("app creation complete. expiring the session: %v\n", state)
213 server.Sessions.endSession(state)
214 c.JSON(http.StatusOK, appConfig)
215 })
216
217 return server
218 }
219
View as plain text