This repository has no description
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+}