This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

superconfig / 5pkgs / timeful / self-host.patch
22 kB 647 lines
1diff --git a/frontend/public/index.html b/frontend/public/index.html 2index a300a8a4..48cb0e61 100644 3--- a/frontend/public/index.html 4+++ b/frontend/public/index.html 5@@ -1,21 +1,6 @@ 6 <!DOCTYPE html> 7 <html lang="en"> 8 <head> 9- <!-- Google Tag Manager --> 10- <script> 11- ;(function (w, d, s, l, i) { 12- w[l] = w[l] || [] 13- w[l].push({ "gtm.start": new Date().getTime(), event: "gtm.js" }) 14- var f = d.getElementsByTagName(s)[0], 15- j = d.createElement(s), 16- dl = l != "dataLayer" ? "&l=" + l : "" 17- j.async = true 18- j.src = "https://www.googletagmanager.com/gtm.js?id=" + i + dl 19- f.parentNode.insertBefore(j, f) 20- })(window, document, "script", "dataLayer", "GTM-M677X6V") 21- </script> 22- <!-- End Google Tag Manager --> 23- <script async src="https://cdn.fuseplatform.net/publift/tags/2/4241/fuse.js"></script> 24 25 <meta charset="utf-8" /> 26 <meta http-equiv="X-UA-Compatible" content="IE=edge" /> 27@@ -59,16 +44,6 @@ 28 /> 29 </head> 30 <body> 31- <!-- Google Tag Manager (noscript) --> 32- <noscript 33- ><iframe 34- src="https://www.googletagmanager.com/ns.html?id=GTM-M677X6V" 35- height="0" 36- width="0" 37- style="display: none; visibility: hidden" 38- ></iframe 39- ></noscript> 40- <!-- End Google Tag Manager (noscript) --> 41 <noscript> 42 <strong 43 >We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work 44diff --git a/frontend/src/App.vue b/frontend/src/App.vue 45index 176178b5..01469b17 100644 46--- a/frontend/src/App.vue 47+++ b/frontend/src/App.vue 48@@ -357,7 +357,10 @@ export default { 49 this.webviewDialog = true 50 return 51 } 52- this.$router.push({ name: "sign-in" }) 53+ this.$router.push({ 54+ name: "sign-in", 55+ query: { next: this.$route.fullPath }, 56+ }) 57 // this.signInDialog = true 58 } else { 59 this.$router.push({ name: "sign-in" }) 60diff --git a/frontend/src/components/Footer.vue b/frontend/src/components/Footer.vue 61index 35d9a6d5..7e8815d4 100644 62--- a/frontend/src/components/Footer.vue 63+++ b/frontend/src/components/Footer.vue 64@@ -30,7 +30,7 @@ 65 </svg> 66 </a> 67 <a 68- href="https://github.com/schej-it/timeful.app" 69+ href="https://github.com/Lassulus/superconfig/tree/master/5pkgs/timeful" 70 aria-label="GitHub" 71 target="_blank" 72 ><v-icon class="tw-text-white/80 hover:tw-text-white" 73diff --git a/frontend/src/components/home/Dashboard.vue b/frontend/src/components/home/Dashboard.vue 74index c56eebff..af75f247 100644 75--- a/frontend/src/components/home/Dashboard.vue 76+++ b/frontend/src/components/home/Dashboard.vue 77@@ -8,7 +8,7 @@ 78 Dashboard 79 </div> 80 <div 81- v-if="!isPremiumUser" 82+ v-if="enablePaywall && !isPremiumUser" 83 class="tw-flex tw-items-baseline tw-gap-2 tw-text-sm tw-font-normal tw-text-very-dark-gray" 84 > 85 <div> 86@@ -240,7 +240,13 @@ export default { 87 }, 88 computed: { 89 ...mapGetters(["isPremiumUser"]), 90- ...mapState(["authUser", "events", "groupsEnabled", "folders"]), 91+ ...mapState([ 92+ "authUser", 93+ "events", 94+ "groupsEnabled", 95+ "folders", 96+ "enablePaywall", 97+ ]), 98 orderedFolders() { 99 return this.folders.sort((a, b) => { 100 return a.name.localeCompare(b.name) 101diff --git a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue 102index 1e028082..eb523062 100644 103--- a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue 104+++ b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue 105@@ -1250,11 +1250,7 @@ export default { 106 ...mapState(["authUser", "overlayAvailabilitiesEnabled"]), 107 ...mapGetters(["isPremiumUser"]), 108 showAds() { 109- return ( 110- !this.ownerIsPremium && 111- !this.isPremiumUser && 112- this.state !== this.states.SET_SPECIFIC_TIMES 113- ) 114+ return false 115 }, 116 /** Returns the width of the right side of the calendar */ 117 rightSideWidth() { 118diff --git a/frontend/src/main.js b/frontend/src/main.js 119index 2d370f7a..9dfa971a 100644 120--- a/frontend/src/main.js 121+++ b/frontend/src/main.js 122@@ -5,19 +5,12 @@ import router from "./router" 123 import store from "./store" 124 import vuetify from "./plugins/vuetify" 125 import posthogPlugin from "./plugins/posthog" 126-import VueGtm from "@gtm-support/vue2-gtm" 127 import VueMeta from "vue-meta" 128 import "./index.css" 129 130 // Posthog 131 Vue.use(posthogPlugin) 132 133-// Google Analytics 134-Vue.use(VueGtm, { 135- id: "GTM-M677X6V", 136- vueRouter: router, 137-}) 138- 139 // Site Metadata 140 Vue.use(VueMeta) 141 142diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js 143index a0770841..b9460a8f 100644 144--- a/frontend/src/store/index.js 145+++ b/frontend/src/store/index.js 146@@ -29,7 +29,7 @@ export default new Vuex.Store({ 147 signUpFormEnabled: false, 148 daysOnlyEnabled: true, 149 overlayAvailabilitiesEnabled: true, 150- enablePaywall: true, 151+ enablePaywall: false, 152 153 // Experiments 154 pricingPageConversion: "control", 155diff --git a/frontend/src/views/Event.vue b/frontend/src/views/Event.vue 156index 81887280..79786ce3 100644 157--- a/frontend/src/views/Event.vue 158+++ b/frontend/src/views/Event.vue 159@@ -638,11 +638,7 @@ export default { 160 ...mapState(["authUser", "events"]), 161 ...mapGetters(["isPremiumUser"]), 162 showAds() { 163- return ( 164- !this.ownerIsPremium && 165- !this.isPremiumUser && 166- !this.isSettingSpecificTimes 167- ) 168+ return false 169 }, 170 allowScheduleEvent() { 171 return this.scheduleOverlapComponent?.allowScheduleEvent 172diff --git a/frontend/src/views/Landing.vue b/frontend/src/views/Landing.vue 173index b8a50d8f..41d7521c 100644 174--- a/frontend/src/views/Landing.vue 175+++ b/frontend/src/views/Landing.vue 176@@ -31,13 +31,10 @@ 177 class="tw-mb-4 tw-flex tw-select-none tw-items-center tw-rounded-full tw-border tw-border-light-gray-stroke tw-bg-white/70 tw-px-2.5 tw-py-1.5 tw-text-sm tw-text-dark-gray" 178 > 179 We're open source! 180- <github-button 181- v-once 182- class="-tw-mb-1 tw-ml-2" 183- href="https://github.com/schej-it/timeful.app" 184- data-show-count="true" 185- aria-label="Star timeful.app on GitHub" 186- >Star</github-button 187+ <a 188+ class="tw-ml-2 tw-text-blue" 189+ href="https://github.com/Lassulus/superconfig/tree/master/5pkgs/timeful" 190+ >Source</a 191 > 192 </div> 193 <div 194@@ -111,20 +108,6 @@ 195 transition="fade-transition" 196 contain 197 /> 198- <vue-vimeo-player 199- video-url="https://player.vimeo.com/video/1083205305?h=d58bef862a" 200- :player-width="800" 201- :player-height="800" 202- :options="{ 203- muted: true, 204- playsinline: true, 205- responsive: true, 206- }" 207- :controls="false" 208- :autoplay="true" 209- :loop="true" 210- @play="onPlay" 211- /> 212 </div> 213 </div> 214 </div> 215@@ -168,25 +151,6 @@ 216 /> 217 </div> 218 219- <!-- Video --> 220- <div 221- class="tw-flex tw-justify-center tw-bg-green tw-px-4 tw-pb-12 tw-pt-24 md:tw-pb-16" 222- > 223- <div 224- class="tw-h-[300px] tw-max-w-3xl tw-flex-1 sm:tw-h-[400px] md:tw-h-[450px]" 225- > 226- <iframe 227- class="tw-h-full tw-w-full" 228- src="https://www.youtube.com/embed/vFkBC8BrkOk?si=pF64JAIyDhom_1do" 229- title="Timeful demo" 230- frameborder="0" 231- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" 232- referrerpolicy="strict-origin-when-cross-origin" 233- allowfullscreen 234- ></iframe> 235- </div> 236- </div> 237- 238 <!-- Reddit Testimonials --> 239 <div class="tw-flex tw-justify-center tw-bg-light-gray tw-py-12"> 240 <div class="tw-mx-4 tw-max-w-3xl tw-flex-1 sm:tw-mx-16"> 241@@ -299,11 +263,9 @@ import NewEvent from "@/components/NewEvent.vue" 242 import NewDialog from "@/components/NewDialog.vue" 243 import LandingPageHeader from "@/components/landing/LandingPageHeader.vue" 244 import Logo from "@/components/Logo.vue" 245-import GithubButton from "vue-github-button" 246 import SignInDialog from "@/components/SignInDialog.vue" 247 import { calendarTypes } from "@/constants" 248 import HowItWorksDialog from "@/components/HowItWorksDialog.vue" 249-import { vueVimeoPlayer } from "vue-vimeo-player" 250 import Footer from "@/components/Footer.vue" 251 import PronunciationMenu from "@/components/PronunciationMenu.vue" 252 import { mapState, mapMutations } from "vuex" 253@@ -325,11 +287,9 @@ export default { 254 NewEvent, 255 NewDialog, 256 LandingPageHeader, 257- GithubButton, 258 Logo, 259 SignInDialog, 260 HowItWorksDialog, 261- vueVimeoPlayer, 262 Footer, 263 PronunciationMenu, 264 AuthUserMenu, 265diff --git a/frontend/src/views/SignIn.vue b/frontend/src/views/SignIn.vue 266index d667be55..6be32f29 100644 267--- a/frontend/src/views/SignIn.vue 268+++ b/frontend/src/views/SignIn.vue 269@@ -32,70 +32,20 @@ 270 </v-card-title> 271 <v-card-text class="tw-flex tw-flex-col tw-items-center tw-pt-6"> 272 <div class="tw-mb-4 tw-flex tw-w-full tw-flex-col tw-gap-y-2"> 273- <v-btn 274- block 275- @click="signIn(calendarTypes.GOOGLE)" 276- class="tw-bg-white" 277- > 278+ <v-btn block @click="signInGithub" class="tw-bg-white"> 279 <div class="tw-flex tw-w-full tw-items-center tw-gap-2"> 280- <v-img 281- class="tw-flex-initial" 282- width="20" 283- height="20" 284- src="@/assets/google_logo.svg" 285- /> 286+ <v-icon class="tw-flex-initial" size="20">mdi-github</v-icon> 287 <v-spacer /> 288- {{ isSignUp ? "Sign up with" : "Continue with" }} Google 289+ {{ isSignUp ? "Sign up with" : "Continue with" }} GitHub 290 <v-spacer /> 291 </div> 292 </v-btn> 293- <v-btn 294- block 295- @click="signIn(calendarTypes.OUTLOOK)" 296- class="tw-bg-white" 297+ <div 298+ v-if="$route.query.error === 'github'" 299+ class="tw-text-center tw-text-sm tw-text-red" 300 > 301- <div class="tw-flex tw-w-full tw-items-center tw-gap-2"> 302- <v-img 303- class="tw-flex-initial" 304- width="20" 305- height="20" 306- src="@/assets/outlook_logo.svg" 307- /> 308- <v-spacer /> 309- {{ isSignUp ? "Sign up with" : "Continue with" }} Outlook 310- <v-spacer /> 311- </div> 312- </v-btn> 313- 314- <div class="tw-my-2 tw-flex tw-items-center tw-gap-3"> 315- <v-divider /> 316- <span class="tw-text-xs tw-text-gray">or</span> 317- <v-divider /> 318- </div> 319- 320- <div> 321- <div class="tw-mb-1 tw-text-sm tw-font-medium"> 322- Email address 323- </div> 324- <v-text-field 325- v-model="email" 326- class="tw-mb-2" 327- placeholder="Enter your email..." 328- type="email" 329- solo 330- hide-details="auto" 331- :error-messages="emailError" 332- @keydown.enter="submitEmail" 333- /> 334- <v-btn 335- block 336- color="primary" 337- :loading="sending" 338- :disabled="sending" 339- @click="submitEmail" 340- > 341- {{ isSignUp ? "Sign up with" : "Continue with" }} Email 342- </v-btn> 343+ GitHub sign-in failed. Make sure your GitHub account has a 344+ verified email address and try again. 345 </div> 346 </div> 347 <div class="tw-text-center tw-text-xs"> 348@@ -251,7 +201,7 @@ 349 350 <script> 351 import { authTypes, calendarTypes } from "@/constants" 352-import { post, signInGoogle, signInOutlook } from "@/utils" 353+import { getEventsCreated, post, signInGoogle, signInOutlook } from "@/utils" 354 import { mapMutations } from "vuex" 355 import Logo from "@/components/Logo.vue" 356 357@@ -299,6 +249,15 @@ export default { 358 359 methods: { 360 ...mapMutations(["setAuthUser"]), 361+ signInGithub() { 362+ const params = new URLSearchParams({ 363+ timezoneOffset: new Date().getTimezoneOffset(), 364+ eventsToLink: getEventsCreated().join(","), 365+ }) 366+ const next = this.$route.query.next 367+ if (typeof next === "string") params.set("redirect", next) 368+ window.location.href = `/api/auth/github?${params}` 369+ }, 370 signIn(provider) { 371 const state = this.upgradeRedirect 372 ? { type: authTypes.UPGRADE, upgradeParams: this.$route.query.upgradeParams } 373diff --git a/server/main.go b/server/main.go 374index b038861e..1678b7df 100644 375--- a/server/main.go 376+++ b/server/main.go 377@@ -175,7 +175,9 @@ func main() { 378 router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) 379 380 // Run server 381- if os.Getenv("NODE_ENV") == "staging" { 382+ if listenAddr := os.Getenv("LISTEN_ADDR"); listenAddr != "" { 383+ router.Run(listenAddr) 384+ } else if os.Getenv("NODE_ENV") == "staging" { 385 router.Run(":3003") 386 } else { 387 router.Run(":3002") 388diff --git a/server/routes/auth.go b/server/routes/auth.go 389index 6444e714..2869116d 100644 390--- a/server/routes/auth.go 391+++ b/server/routes/auth.go 392@@ -41,6 +41,8 @@ func InitAuth(router *gin.RouterGroup) { 393 authRouter.POST("/otp/check-email", checkEmail) 394 authRouter.POST("/otp/send", sendOtp) 395 authRouter.POST("/otp/verify", verifyOtp) 396+ 397+ initGithubAuth(authRouter) 398 } 399 400 // @Summary Signs user in 401diff --git a/server/routes/github_auth.go b/server/routes/github_auth.go 402new file mode 100644 403index 00000000..40ef9206 404--- /dev/null 405+++ b/server/routes/github_auth.go 406@@ -0,0 +1,241 @@ 407+/* GitHub OAuth sign-in (self-host patch). Accounts are keyed by the GitHub 408+ * account's primary verified email, like the email OTP flow, and carry no 409+ * calendar access. */ 410+package routes 411+ 412+import ( 413+ "context" 414+ "crypto/rand" 415+ "encoding/hex" 416+ "encoding/json" 417+ "fmt" 418+ "net/http" 419+ "net/url" 420+ "os" 421+ "strconv" 422+ "strings" 423+ "time" 424+ 425+ "github.com/gin-contrib/sessions" 426+ "github.com/gin-gonic/gin" 427+ "go.mongodb.org/mongo-driver/bson" 428+ "go.mongodb.org/mongo-driver/bson/primitive" 429+ "schej.it/server/db" 430+ "schej.it/server/logger" 431+ "schej.it/server/models" 432+ "schej.it/server/utils" 433+) 434+ 435+var githubHttpClient = &http.Client{Timeout: 10 * time.Second} 436+ 437+// Session keys holding the in-flight sign-in between the redirect to GitHub 438+// and the callback. 439+const ( 440+ githubStateKey = "githubState" 441+ githubRedirectKey = "githubRedirect" 442+ githubTzKey = "githubTimezoneOffset" 443+ githubLinkKey = "githubEventsToLink" 444+) 445+ 446+func initGithubAuth(authRouter *gin.RouterGroup) { 447+ authRouter.GET("/github", githubSignIn) 448+ authRouter.GET("/github/callback", githubCallback) 449+} 450+ 451+// @Summary Starts GitHub sign-in 452+// @Description Redirects to GitHub's OAuth consent page. Query: redirect (relative path to return to), timezoneOffset, eventsToLink (comma-separated event ids created while signed out) 453+// @Tags auth 454+// @Router /auth/github [get] 455+func githubSignIn(c *gin.Context) { 456+ clientId := os.Getenv("GITHUB_CLIENT_ID") 457+ if clientId == "" { 458+ c.String(http.StatusNotFound, "GitHub sign-in is not configured") 459+ return 460+ } 461+ 462+ stateBytes := make([]byte, 32) 463+ if _, err := rand.Read(stateBytes); err != nil { 464+ logger.StdErr.Panicln(err) 465+ } 466+ state := hex.EncodeToString(stateBytes) 467+ 468+ session := sessions.Default(c) 469+ session.Set(githubStateKey, state) 470+ session.Set(githubRedirectKey, safeRedirectPath(c.Query("redirect"))) 471+ session.Set(githubTzKey, c.Query("timezoneOffset")) 472+ session.Set(githubLinkKey, c.Query("eventsToLink")) 473+ session.Save() 474+ 475+ params := url.Values{} 476+ params.Set("client_id", clientId) 477+ params.Set("scope", "read:user user:email") 478+ params.Set("state", state) 479+ c.Redirect(http.StatusFound, "https://github.com/login/oauth/authorize?"+params.Encode()) 480+} 481+ 482+// @Summary GitHub OAuth callback 483+// @Description Exchanges the code, finds or creates the user by primary verified email, sets the session and redirects back into the app 484+// @Tags auth 485+// @Router /auth/github/callback [get] 486+func githubCallback(c *gin.Context) { 487+ session := sessions.Default(c) 488+ expectedState, _ := session.Get(githubStateKey).(string) 489+ redirect, _ := session.Get(githubRedirectKey).(string) 490+ tzString, _ := session.Get(githubTzKey).(string) 491+ eventsToLink, _ := session.Get(githubLinkKey).(string) 492+ // The state is single-use: every exit path below saves the session with 493+ // these keys removed. Each Save emits a Set-Cookie, so save exactly once. 494+ session.Delete(githubStateKey) 495+ session.Delete(githubRedirectKey) 496+ session.Delete(githubTzKey) 497+ session.Delete(githubLinkKey) 498+ 499+ fail := func(reason string, err error) { 500+ logger.StdErr.Printf("GitHub sign-in failed: %s: %v", reason, err) 501+ session.Save() 502+ c.Redirect(http.StatusFound, "/sign-in?error=github") 503+ } 504+ 505+ if expectedState == "" || c.Query("state") != expectedState { 506+ fail("state mismatch", nil) 507+ return 508+ } 509+ if c.Query("error") != "" { 510+ fail("denied", fmt.Errorf("%s", c.Query("error"))) 511+ return 512+ } 513+ 514+ accessToken, err := githubExchangeCode(c.Query("code")) 515+ if err != nil { 516+ fail("code exchange", err) 517+ return 518+ } 519+ 520+ var ghUser struct { 521+ Login string `json:"login"` 522+ Name string `json:"name"` 523+ AvatarUrl string `json:"avatar_url"` 524+ } 525+ if err := githubGet(accessToken, "/user", &ghUser); err != nil { 526+ fail("fetch user", err) 527+ return 528+ } 529+ 530+ var ghEmails []struct { 531+ Email string `json:"email"` 532+ Primary bool `json:"primary"` 533+ Verified bool `json:"verified"` 534+ } 535+ if err := githubGet(accessToken, "/user/emails", &ghEmails); err != nil { 536+ fail("fetch emails", err) 537+ return 538+ } 539+ email := "" 540+ for _, e := range ghEmails { 541+ if e.Verified && (e.Primary || email == "") { 542+ email = e.Email 543+ } 544+ } 545+ if email == "" { 546+ fail("no verified email", nil) 547+ return 548+ } 549+ email = utils.NormalizeEmail(email) 550+ 551+ var userId primitive.ObjectID 552+ if existing := db.GetUserByEmail(email); existing != nil { 553+ userId = existing.Id 554+ } else { 555+ firstName, lastName := ghUser.Login, "" 556+ if name := strings.TrimSpace(ghUser.Name); name != "" { 557+ firstName, lastName, _ = strings.Cut(name, " ") 558+ } 559+ timezoneOffset, _ := strconv.Atoi(tzString) 560+ 561+ res, err := db.UsersCollection.InsertOne(context.Background(), models.User{ 562+ Email: email, 563+ FirstName: firstName, 564+ LastName: strings.TrimSpace(lastName), 565+ Picture: ghUser.AvatarUrl, 566+ TimezoneOffset: timezoneOffset, 567+ TokenOrigin: models.WEB, 568+ }) 569+ if err != nil { 570+ logger.StdErr.Panicln(err) 571+ } 572+ userId = res.InsertedID.(primitive.ObjectID) 573+ } 574+ 575+ // Claim events this browser created while signed out (same as /auth/sign-in) 576+ for _, eventIdString := range strings.Split(eventsToLink, ",") { 577+ eventId, err := primitive.ObjectIDFromHex(eventIdString) 578+ if err == nil { 579+ db.EventsCollection.UpdateOne(context.Background(), bson.M{"_id": eventId, "ownerId": nil}, bson.M{"$set": bson.M{"ownerId": userId}}) 580+ } 581+ } 582+ 583+ session.Set("userId", userId.Hex()) 584+ session.Save() 585+ 586+ c.Redirect(http.StatusFound, redirect) 587+} 588+ 589+// safeRedirectPath only allows same-origin absolute paths, so the callback 590+// cannot be turned into an open redirect. 591+func safeRedirectPath(path string) string { 592+ if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || strings.HasPrefix(path, "/\\") { 593+ return "/home" 594+ } 595+ return path 596+} 597+ 598+func githubExchangeCode(code string) (string, error) { 599+ form := url.Values{} 600+ form.Set("client_id", os.Getenv("GITHUB_CLIENT_ID")) 601+ form.Set("client_secret", os.Getenv("GITHUB_CLIENT_SECRET")) 602+ form.Set("code", code) 603+ 604+ req, err := http.NewRequest(http.MethodPost, "https://github.com/login/oauth/access_token", strings.NewReader(form.Encode())) 605+ if err != nil { 606+ return "", err 607+ } 608+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 609+ req.Header.Set("Accept", "application/json") 610+ 611+ resp, err := githubHttpClient.Do(req) 612+ if err != nil { 613+ return "", err 614+ } 615+ defer resp.Body.Close() 616+ 617+ var body struct { 618+ AccessToken string `json:"access_token"` 619+ Error string `json:"error"` 620+ } 621+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { 622+ return "", err 623+ } 624+ if body.AccessToken == "" { 625+ return "", fmt.Errorf("no access token (status %d, error %q)", resp.StatusCode, body.Error) 626+ } 627+ return body.AccessToken, nil 628+} 629+ 630+func githubGet(accessToken string, path string, out interface{}) error { 631+ req, err := http.NewRequest(http.MethodGet, "https://api.github.com"+path, nil) 632+ if err != nil { 633+ return err 634+ } 635+ req.Header.Set("Authorization", "Bearer "+accessToken) 636+ req.Header.Set("Accept", "application/vnd.github+json") 637+ 638+ resp, err := githubHttpClient.Do(req) 639+ if err != nil { 640+ return err 641+ } 642+ defer resp.Body.Close() 643+ if resp.StatusCode != http.StatusOK { 644+ return fmt.Errorf("GET %s: status %d", path, resp.StatusCode) 645+ } 646+ return json.NewDecoder(resp.Body).Decode(out) 647+}