package main import ( "fmt" "time" "net/http" "io" "os" "context" "log" "io/ioutil" "strings" "encoding/json" "math/rand" gogpt "github.com/sashabaranov/go-gpt3" "github.com/stripe/stripe-go/v74" "github.com/stripe/stripe-go/v74/event" "github.com/stripe/stripe-go/v74/webhook" "github.com/stripe/stripe-go/v74/checkout/session" "gorm.io/gorm" "gorm.io/driver/sqlite" "github.com/creikey/rpgpt/server/codes" ) // BoughtType values. do not reorganize these or you fuck up the database const ( DayPass = iota ) const ( // A-Z and 0-9, four digits means this many codes MaxCodes = 36 * 36 * 36 * 36 ) type User struct { CreatedAt time.Time UpdatedAt time.Time DeletedAt gorm.DeletedAt `gorm:"index"` Code codes.UserCode `gorm:"primaryKey"` // of maximum value max codes, incremented one by one. These are converted to 4 digit alphanumeric code users can remember/use BoughtTime int64 // unix time. Used to figure out if the pass is still valid BoughtType int // enum IsFulfilled bool // before users are checked out they are unfulfilled CheckoutSessionID string } var c *gogpt.Client var logResponses = false var doCors = false var checkoutRedirectTo string var daypassPriceId string var webhookSecret string var db *gorm.DB var daypassTimedOut = make(map[codes.UserCode]int64) // value is time last requested, rate limiting by day pass. If exists is rate limited, should be removed when ok to request again // for 10 free minutes a day, is when ip address began requesting var ipAddyTenFree = make(map[string]int64) func cleanTimedOut() { for k, v := range daypassTimedOut { if currentTime() - v > 1 { delete(daypassTimedOut, k) } } for k, v := range ipAddyTenFree { if currentTime() - v > 24*60*60 { delete(ipAddyTenFree, k) } } } func isUserOld(user User) bool { return (currentTime() - user.BoughtTime) > 24*60*60 } func clearOld(db *gorm.DB) { var users []User result := db.Find(&users) if result.Error != nil { log.Fatal(result.Error) } var toDelete []codes.UserCode // codes for _, user := range users { if user.BoughtType != 0 { panic("Don't know how to handle bought type " + string(user.BoughtType) + " yet") } if isUserOld(user) { toDelete = append(toDelete, user.Code) } } for _, del := range toDelete { db.Delete(&User{}, del) } } func handleEvent(event stripe.Event) error { if event.Type == "checkout.session.completed" { var session stripe.CheckoutSession err := json.Unmarshal(event.Data.Raw, &session) if err != nil { return fmt.Errorf("Error parsing webhook JSON %s", err) } params := &stripe.CheckoutSessionParams{} params.AddExpand("line_items") // Retrieve the session. If you require line items in the response, you may include them by expanding line_items. // Fulfill the purchase... var toFulfill User found := false for trial := 0; trial < 5; trial++ { if db.Where("checkout_session_id = ?", session.ID).First(&toFulfill).Error != nil { log.Println("Failed to fulfill user with ID " + session.ID) } else { found = true break } } if !found { return fmt.Errorf("Error Failed to find user in database to fulfill: very bad! ID: " + session.ID) } else { userString, err := codes.CodeToString(toFulfill.Code) if err != nil { return fmt.Errorf("Error strange thing, saved user's code was unable to be converted to a string %s", err) } log.Printf("Fulfilling user with code %s number %d\n", userString, toFulfill.Code) if(toFulfill.IsFulfilled) { log.Printf("User with code %s is already fulfilled, strange\n", userString) } toFulfill.IsFulfilled = true err = db.Save(&toFulfill).Error if err != nil { return fmt.Errorf("Failed to save fulfilled flag status to database: %s", err) } } } return nil } func webhookResponse(w http.ResponseWriter, req *http.Request) { const MaxBodyBytes = int64(65536) req.Body = http.MaxBytesReader(w, req.Body, MaxBodyBytes) body, err := ioutil.ReadAll(req.Body) if err != nil { log.Printf("Error reading request body: %v\n", err) w.WriteHeader(http.StatusServiceUnavailable) return } endpointSecret := webhookSecret event, err := webhook.ConstructEvent(body, req.Header.Get("Stripe-Signature"), endpointSecret) if err != nil { log.Printf("Error verifying webhook signature %s\n", err) w.WriteHeader(http.StatusBadRequest) // Return a 400 error on a bad signature return } err = handleEvent(event) if err != nil { w.WriteHeader(http.StatusBadRequest) } w.WriteHeader(http.StatusOK) } func checkout(w http.ResponseWriter, req *http.Request) { if doCors { w.Header().Set("Access-Control-Allow-Origin", "*") } // generate a code var newCode string var newCodeUser codes.UserCode found := false for i := 0; i < 1000; i++ { codeInt := rand.Intn(MaxCodes) newCodeUser = codes.UserCode(codeInt) var tmp User r := db.Where("Code = ?", newCodeUser).Limit(1).Find(&tmp) if r.RowsAffected == 0{ var err error newCode, err = codes.CodeToString(newCodeUser) if err != nil { w.WriteHeader(http.StatusInternalServerError) log.Fatalf("Failed to generate code from random number: %s", err) return } found = true break } } if !found { w.WriteHeader(http.StatusInternalServerError) log.Fatal("Failed to find new code!!!") return } customMessage := fmt.Sprintf("**IMPORTANT** Your Day Pass Code is %s", newCode) params := &stripe.CheckoutSessionParams { LineItems: []*stripe.CheckoutSessionLineItemParams { &stripe.CheckoutSessionLineItemParams{ Price: stripe.String(daypassPriceId), Quantity: stripe.Int64(1), }, }, Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), SuccessURL: stripe.String(checkoutRedirectTo), CancelURL: stripe.String(checkoutRedirectTo), CustomText: &stripe.CheckoutSessionCustomTextParams{ Submit: &stripe.CheckoutSessionCustomTextSubmitParams { Message: &customMessage } }, } s, err := session.New(params) if err != nil { log.Printf("session.New: %v", err) } log.Printf("Creating user code %s with checkout session ID %s\n", newCode, newCodeUser ,s.ID) result := db.Create(&User { Code: newCodeUser, BoughtTime: currentTime(), BoughtType: DayPass, IsFulfilled: false, CheckoutSessionID: s.ID, }) if result.Error != nil { w.WriteHeader(http.StatusInternalServerError) log.Printf("Failed to write to database: %s", result.Error) } else { fmt.Fprintf(w, "%s|%s", newCode, s.URL) } } func index(w http.ResponseWriter, req *http.Request) { req.Body = http.MaxBytesReader(w, req.Body, 1024 * 1024) // no sending huge files to crash the server if doCors { w.Header().Set("Access-Control-Allow-Origin", "*") } bodyBytes, err := io.ReadAll(req.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) log.Println("Bad error: ", err) return } else { bodyString := string(bodyBytes) splitBody := strings.Split(bodyString, "|") if len(splitBody) != 2 { w.WriteHeader(http.StatusBadRequest) } var promptString string = splitBody[1] var userToken string = splitBody[0] // see if need to pay rejected := false cleanTimedOut() { if len(userToken) != 4 { // where I do the IP rate limiting userKey := req.RemoteAddr createdTime, ok := ipAddyTenFree[userKey] if !ok { ipAddyTenFree[userKey] = currentTime() rejected = false } else { if currentTime() - createdTime < 10*60 { rejected = false } else { rejected = true // out of free time buddy } } } else { var thisUser User thisUserCode, err := codes.ParseUserCode(userToken) if err != nil { log.Printf("Error: Failed to parse user token %s\n", userToken) rejected = true } else { err := db.First(&thisUser, thisUserCode).Error if err != nil { log.Printf("User code %d string %s couldn't be found in the database: %s\n", thisUserCode, userToken, err) rejected = true } else { if isUserOld(thisUser) { log.Println("User code " + userToken + " is old, not valid") db.Delete(&thisUser) rejected = true } else { // now have valid user, in the database, to be rate limit checked // rate limiting based on user token if !thisUser.IsFulfilled { log.Println("Unfulfilled user trying to play, might've been unresponded to event. Retrieving backlog of unfulfilled events...\n") params := &stripe.EventListParams{} params.Filters.AddFilter("delivery_success", "", "false") i := event.List(params) for i.Next() { e := i.Event() log.Println("Unfulfilled event! Of type %s. Handling...\n", e.Type) err := handleEvent(*e) if err != nil { log.Println("Failed to fulfill unfulfilled event: %s\n", err) } } } if thisUser.IsFulfilled { _, exists := daypassTimedOut[thisUserCode] if exists { rejected = true } else { rejected = false daypassTimedOut[thisUserCode] = currentTime() } } else { log.Println("User with code and existing entry in database was not fulfilled, and wanted to play... Very bad. Usercode: %s\n", thisUserCode) } } } } } } if rejected { fmt.Fprintf(w, "0") return } if logResponses { log.Println("Println line prompt string: ", promptString) } ctx := context.Background() req := gogpt.CompletionRequest { Model: "curie:ft-alnar-games-2023-03-31-04-24-33", MaxTokens: 80, Prompt: promptString, Temperature: 0.9, FrequencyPenalty: 0.0, PresencePenalty: 0.6, TopP: 1.0, Stop: []string{"\n"}, N: 1, } resp, err := c.CreateCompletion(ctx, req) if err != nil { log.Println("Error Failed to generate: ", err) w.WriteHeader(http.StatusInternalServerError) return } response := resp.Choices[0].Text if logResponses { log.Println("Println response: `", response + "`") log.Println() } fmt.Fprintf(w, "1%s", response) } } func currentTime() int64 { return time.Now().Unix() } func main() { var err error db, err = gorm.Open(sqlite.Open("rpgpt.db"), &gorm.Config{}) if err != nil { log.Fatal(err) } db.AutoMigrate(&User{}) clearOld(db) api_key := os.Getenv("OPENAI_API_KEY") if api_key == "" { log.Fatal("Must provide openai key") } checkoutRedirectTo = os.Getenv("REDIRECT_TO") if checkoutRedirectTo == "" { log.Fatal("Must provide a base URL (without slash) for playgpt to redirect to") } stripeKey := os.Getenv("STRIPE_KEY") if stripeKey == "" { log.Fatal("Must provide stripe key") } daypassPriceId = os.Getenv("PRICE_ID") if daypassPriceId == "" { log.Fatal("Must provide daypass price ID") } stripe.Key = stripeKey webhookSecret = os.Getenv("WEBHOOK_SECRET") if webhookSecret == "" { log.Fatal("Must provide webhook secret for receiving checkout completed events") } logResponses = os.Getenv("LOG_RESPONSES") != "" doCors = os.Getenv("CORS") != "" c = gogpt.NewClient(api_key) http.HandleFunc("/", index) http.HandleFunc("/webhook", webhookResponse) http.HandleFunc("/checkout", checkout) portString := ":8090" log.Println("DO NOT RUN WITH CLOUDFLARE PROXY it rate limits based on IP, if behind proxy every IP will be the same. Would need to fix req.RemoteAddr. Serving on " + portString + "...") http.ListenAndServe(portString, nil) }