Added google androidpublisher/v3 api to verify google purchase tokens

This commit is contained in:
Mike Schwörer 2022-11-25 22:42:21 +01:00
parent 6d80638cf8
commit 3a0c65a849
Signed by: Mikescher
GPG Key ID: D3C7172E0A70F8CF
18 changed files with 608 additions and 163 deletions

View File

@ -2,51 +2,19 @@
//TODO
- Return client on register
- Register with noclient=true
- return subscribtions in list-channels (?)
- migration script for existing data
- ack/read deliveries && return ack-count (? or not, how to query?)
- verify pro_token
- full-text-search: https://www.sqlite.org/fts5.html#contentless_tables
- dark mode toggle for html
- app-store link in HTML
- route to re-check all pro-token
- tests
- deploy
~~~~~~~~~~~~~~~~~~~~~~~~~~
https://developers.google.com/android-publisher/api-ref/purchases/products/get
https://console.cloud.google.com/apis/credentials?project=api-5982391131063970546-620227
https://pkg.go.dev/google.golang.org/api/androidpublisher/v2
https://github.com/googleapis/google-api-go-client
{
"web":
{
"client_id": "57770789756-7c29a3gif5346dkte194j1rb6u3mt5vs.apps.googleusercontent.com",
"project_id": "api-5982391131063970546-620227",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_secret": "wG22acxdYUfpi8RkSsjyu6Tw",
"redirect_uris":
[
"https://scn.blackforestbytes.com/index.php"
],
"javascript_origins":
[
"https://scn.blackforestbytes.com"
]
}
}
~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -82,7 +82,7 @@ func (h APIHandler) CreateUser(g *gin.Context) ginresp.HTTPResponse {
}
if b.ProToken != nil {
ptok, err := h.app.VerifyProToken(*b.ProToken)
ptok, err := h.app.VerifyProToken(ctx, *b.ProToken)
if err != nil {
return ginresp.APIError(g, 500, apierr.FAILED_VERIFY_PRO_TOKEN, "Failed to query purchase status", err)
}
@ -232,7 +232,7 @@ func (h APIHandler) UpdateUser(g *gin.Context) ginresp.HTTPResponse {
}
if b.ProToken != nil {
ptok, err := h.app.VerifyProToken(*b.ProToken)
ptok, err := h.app.VerifyProToken(ctx, *b.ProToken)
if err != nil {
return ginresp.APIError(g, 500, apierr.FAILED_VERIFY_PRO_TOKEN, "Failed to query purchase status", err)
}
@ -697,8 +697,8 @@ func (h APIHandler) UpdateChannel(g *gin.Context) ginresp.HTTPResponse {
// @Tags API-v2
//
// @Param query_data query handler.ListChannelMessages.query false " "
// @Param uid path int true "UserID"
// @Param cid path int true "ChannelID"
// @Param uid path int true "UserID"
// @Param cid path int true "ChannelID"
//
// @Success 200 {object} handler.ListChannelMessages.response
// @Failure 400 {object} ginresp.apiError

View File

@ -162,9 +162,9 @@ func (h CommonHandler) Health(g *gin.Context) ginresp.HTTPResponse {
//
// @Param secs path number true "sleep delay (in seconds)"
//
// @Success 200 {object} handler.Sleep.response
// @Failure 400 {object} ginresp.apiError
// @Failure 500 {object} ginresp.apiError
// @Success 200 {object} handler.Sleep.response
// @Failure 400 {object} ginresp.apiError
// @Failure 500 {object} ginresp.apiError
//
// @Router /api/sleep/{secs} [post]
func (h CommonHandler) Sleep(g *gin.Context) ginresp.HTTPResponse {

View File

@ -85,7 +85,7 @@ func (h CompatHandler) Register(g *gin.Context) ginresp.HTTPResponse {
}
if data.ProToken != nil {
ptok, err := h.app.VerifyProToken(*data.ProToken)
ptok, err := h.app.VerifyProToken(ctx, "ANDROID|v2|"+*data.ProToken)
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query purchase status")
}
@ -634,7 +634,7 @@ func (h CompatHandler) Upgrade(g *gin.Context) ginresp.HTTPResponse {
}
if data.ProToken != nil {
ptok, err := h.app.VerifyProToken(*data.ProToken)
ptok, err := h.app.VerifyProToken(ctx, "ANDROID|v2|"+*data.ProToken)
if err != nil {
return ginresp.CompatAPIError(0, "Failed to query purchase status")
}

View File

@ -14,12 +14,14 @@ import (
type WebsiteHandler struct {
app *logic.Application
rexTemplate *regexp.Regexp
rexConfig *regexp.Regexp
}
func NewWebsiteHandler(app *logic.Application) WebsiteHandler {
return WebsiteHandler{
app: app,
rexTemplate: regexp.MustCompile("{{template\\|[A-Za-z0-9_\\-.]+}}"),
rexConfig: regexp.MustCompile("{{config\\|[A-Za-z0-9_\\-.]+}}"),
}
}
@ -94,6 +96,21 @@ func (h WebsiteHandler) serveAsset(g *gin.Context, fn string, repl bool) ginresp
if failed {
return ginresp.InternalError(errors.New("template replacement failed"))
}
data = h.rexConfig.ReplaceAllFunc(data, func(match []byte) []byte {
prefix := len("{{config|")
suffix := len("}}")
cfgKey := match[prefix : len(match)-suffix]
cval, ok := h.getReplConfig(string(cfgKey))
if !ok {
failed = true
}
return []byte(cval)
})
if failed {
return ginresp.InternalError(errors.New("config replacement failed"))
}
}
mime := "text/plain"
@ -117,3 +134,23 @@ func (h WebsiteHandler) serveAsset(g *gin.Context, fn string, repl bool) ginresp
return ginresp.Data(http.StatusOK, mime, data)
}
func (h WebsiteHandler) getReplConfig(key string) (string, bool) {
key = strings.TrimSpace(strings.ToLower(key))
if key == "baseurl" {
return h.app.Config.BaseURL, true
}
if key == "ip" {
return h.app.Config.ServerIP, true
}
if key == "port" {
return h.app.Config.ServerPort, true
}
if key == "namespace" {
return h.app.Config.Namespace, true
}
return "", false
}

View File

@ -6,6 +6,7 @@ import (
"blackforestbytes.com/simplecloudnotifier/common"
"blackforestbytes.com/simplecloudnotifier/common/ginext"
"blackforestbytes.com/simplecloudnotifier/db"
"blackforestbytes.com/simplecloudnotifier/google"
"blackforestbytes.com/simplecloudnotifier/jobs"
"blackforestbytes.com/simplecloudnotifier/logic"
"blackforestbytes.com/simplecloudnotifier/push"
@ -38,7 +39,7 @@ func main() {
var nc push.NotificationClient
if conf.DummyFirebase {
nc, err = push.NewDummy()
nc = push.NewDummy()
} else {
nc, err = push.NewFirebaseConn(conf)
if err != nil {
@ -47,9 +48,20 @@ func main() {
}
}
var apc google.AndroidPublisherClient
if conf.DummyGoogleAPI {
apc = google.NewDummy()
} else {
apc, err = google.NewAndroidPublisherAPI(conf)
if err != nil {
log.Fatal().Err(err).Msg("failed to init google-api")
return
}
}
jobRetry := jobs.NewDeliveryRetryJob(app)
app.Init(conf, ginengine, nc, []logic.Job{jobRetry})
app.Init(conf, ginengine, nc, apc, []logic.Job{jobRetry})
router.Init(ginengine)

View File

@ -8,110 +8,158 @@ import (
)
type Config struct {
Namespace string
GinDebug bool
ServerIP string
ServerPort string
DBFile string
RequestTimeout time.Duration
ReturnRawErrors bool
DummyFirebase bool
FirebaseTokenURI string
FirebaseProjectID string
FirebasePrivKeyID string
FirebaseClientMail string
FirebasePrivateKey string
Namespace string
BaseURL string
GinDebug bool
ServerIP string
ServerPort string
DBFile string
RequestTimeout time.Duration
ReturnRawErrors bool
DummyFirebase bool
FirebaseTokenURI string
FirebaseProjectID string
FirebasePrivKeyID string
FirebaseClientMail string
FirebasePrivateKey string
DummyGoogleAPI bool
GoogleAPITokenURI string
GoogleAPIPrivKeyID string
GoogleAPIClientMail string
GoogleAPIPrivateKey string
GooglePackageName string
GoogleProProductID string
}
var Conf Config
var configLocHost = func() Config {
return Config{
Namespace: "local-host",
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: "8080",
DBFile: ".run-data/db.sqlite3",
RequestTimeout: 16 * time.Second,
ReturnRawErrors: true,
DummyFirebase: true,
FirebaseTokenURI: "",
FirebaseProjectID: "",
FirebasePrivKeyID: "",
FirebaseClientMail: "",
FirebasePrivateKey: "",
Namespace: "local-host",
BaseURL: "http://localhost:8080",
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: "8080",
DBFile: ".run-data/db.sqlite3",
RequestTimeout: 16 * time.Second,
ReturnRawErrors: true,
DummyFirebase: true,
FirebaseTokenURI: "",
FirebaseProjectID: "",
FirebasePrivKeyID: "",
FirebaseClientMail: "",
FirebasePrivateKey: "",
DummyGoogleAPI: true,
GoogleAPITokenURI: "",
GoogleAPIPrivKeyID: "",
GoogleAPIClientMail: "",
GoogleAPIPrivateKey: "",
GooglePackageName: "",
GoogleProProductID: "",
}
}
var configLocDocker = func() Config {
return Config{
Namespace: "local-docker",
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: "80",
DBFile: "/data/scn_docker.sqlite3",
RequestTimeout: 16 * time.Second,
ReturnRawErrors: true,
DummyFirebase: true,
FirebaseTokenURI: "",
FirebaseProjectID: "",
FirebasePrivKeyID: "",
FirebaseClientMail: "",
FirebasePrivateKey: "",
Namespace: "local-docker",
BaseURL: "http://localhost:8080",
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: "80",
DBFile: "/data/scn_docker.sqlite3",
RequestTimeout: 16 * time.Second,
ReturnRawErrors: true,
DummyFirebase: true,
FirebaseTokenURI: "",
FirebaseProjectID: "",
FirebasePrivKeyID: "",
FirebaseClientMail: "",
FirebasePrivateKey: "",
DummyGoogleAPI: true,
GoogleAPITokenURI: "",
GoogleAPIPrivKeyID: "",
GoogleAPIClientMail: "",
GoogleAPIPrivateKey: "",
GooglePackageName: "",
GoogleProProductID: "",
}
}
var configDev = func() Config {
return Config{
Namespace: "develop",
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: "80",
DBFile: "/data/scn.sqlite3",
RequestTimeout: 16 * time.Second,
ReturnRawErrors: true,
DummyFirebase: false,
FirebaseTokenURI: "https://oauth2.googleapis.com/token",
FirebaseProjectID: confEnv("FB_PROJECTID"),
FirebasePrivKeyID: confEnv("FB_PRIVATEKEYID"),
FirebaseClientMail: confEnv("FB_CLIENTEMAIL"),
FirebasePrivateKey: confEnv("FB_PRIVATEKEY"),
Namespace: "develop",
BaseURL: confEnv("BASE_URL"),
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: "80",
DBFile: "/data/scn.sqlite3",
RequestTimeout: 16 * time.Second,
ReturnRawErrors: true,
DummyFirebase: false,
FirebaseTokenURI: "https://oauth2.googleapis.com/token",
FirebaseProjectID: confEnv("FB_PROJECTID"),
FirebasePrivKeyID: confEnv("FB_PRIVATEKEYID"),
FirebaseClientMail: confEnv("FB_CLIENTEMAIL"),
FirebasePrivateKey: confEnv("FB_PRIVATEKEY"),
DummyGoogleAPI: false,
GoogleAPITokenURI: "https://oauth2.googleapis.com/token",
GoogleAPIPrivKeyID: confEnv("GOOG_PRIVATEKEYID"),
GoogleAPIClientMail: confEnv("GOOG_CLIENTEMAIL"),
GoogleAPIPrivateKey: confEnv("GOOG_PRIVATEKEY"),
GooglePackageName: confEnv("GOOG_PACKAGENAME"),
GoogleProProductID: confEnv("GOOG_PROPRODUCTID"),
}
}
var configStag = func() Config {
return Config{
Namespace: "staging",
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: "80",
DBFile: "/data/scn.sqlite3",
RequestTimeout: 16 * time.Second,
ReturnRawErrors: true,
DummyFirebase: false,
FirebaseTokenURI: "https://oauth2.googleapis.com/token",
FirebaseProjectID: confEnv("FB_PROJECTID"),
FirebasePrivKeyID: confEnv("FB_PRIVATEKEYID"),
FirebaseClientMail: confEnv("FB_CLIENTEMAIL"),
FirebasePrivateKey: confEnv("FB_PRIVATEKEY"),
Namespace: "staging",
BaseURL: confEnv("BASE_URL"),
GinDebug: true,
ServerIP: "0.0.0.0",
ServerPort: "80",
DBFile: "/data/scn.sqlite3",
RequestTimeout: 16 * time.Second,
ReturnRawErrors: true,
DummyFirebase: false,
FirebaseTokenURI: "https://oauth2.googleapis.com/token",
FirebaseProjectID: confEnv("FB_PROJECTID"),
FirebasePrivKeyID: confEnv("FB_PRIVATEKEYID"),
FirebaseClientMail: confEnv("FB_CLIENTEMAIL"),
FirebasePrivateKey: confEnv("FB_PRIVATEKEY"),
DummyGoogleAPI: false,
GoogleAPITokenURI: "https://oauth2.googleapis.com/token",
GoogleAPIPrivKeyID: confEnv("GOOG_PRIVATEKEYID"),
GoogleAPIClientMail: confEnv("GOOG_CLIENTEMAIL"),
GoogleAPIPrivateKey: confEnv("GOOG_PRIVATEKEY"),
GooglePackageName: confEnv("GOOG_PACKAGENAME"),
GoogleProProductID: confEnv("GOOG_PROPRODUCTID"),
}
}
var configProd = func() Config {
return Config{
Namespace: "production",
GinDebug: false,
ServerIP: "0.0.0.0",
ServerPort: "80",
DBFile: "/data/scn.sqlite3",
RequestTimeout: 16 * time.Second,
ReturnRawErrors: false,
DummyFirebase: false,
FirebaseTokenURI: "https://oauth2.googleapis.com/token",
FirebaseProjectID: confEnv("FB_PROJECTID"),
FirebasePrivKeyID: confEnv("FB_PRIVATEKEYID"),
FirebaseClientMail: confEnv("FB_CLIENTEMAIL"),
FirebasePrivateKey: confEnv("FB_PRIVATEKEY"),
Namespace: "production",
BaseURL: confEnv("BASE_URL"),
GinDebug: false,
ServerIP: "0.0.0.0",
ServerPort: "80",
DBFile: "/data/scn.sqlite3",
RequestTimeout: 16 * time.Second,
ReturnRawErrors: false,
DummyFirebase: false,
FirebaseTokenURI: "https://oauth2.googleapis.com/token",
FirebaseProjectID: confEnv("FB_PROJECTID"),
FirebasePrivKeyID: confEnv("FB_PRIVATEKEYID"),
FirebaseClientMail: confEnv("FB_CLIENTEMAIL"),
FirebasePrivateKey: confEnv("FB_PRIVATEKEY"),
DummyGoogleAPI: false,
GoogleAPITokenURI: "https://oauth2.googleapis.com/token",
GoogleAPIPrivKeyID: confEnv("GOOG_PRIVATEKEYID"),
GoogleAPIClientMail: confEnv("GOOG_CLIENTEMAIL"),
GoogleAPIPrivateKey: confEnv("GOOG_PRIVATEKEY"),
GooglePackageName: confEnv("GOOG_PACKAGENAME"),
GoogleProProductID: confEnv("GOOG_PROPRODUCTID"),
}
}

View File

@ -0,0 +1,151 @@
package google
import (
scn "blackforestbytes.com/simplecloudnotifier"
"context"
"encoding/json"
"errors"
"fmt"
"github.com/rs/zerolog/log"
"io"
"net/http"
"time"
)
// https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products/get
// https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products#ProductPurchase
type AndroidPublisher struct {
client http.Client
auth *GoogleOAuth2
baseURL string
}
func NewAndroidPublisherAPI(conf scn.Config) (AndroidPublisherClient, error) {
googauth, err := NewAuth(conf.GoogleAPITokenURI, conf.GoogleAPIPrivKeyID, conf.GoogleAPIClientMail, conf.GoogleAPIPrivateKey)
if err != nil {
return nil, err
}
return &AndroidPublisher{
client: http.Client{Timeout: 5 * time.Second},
auth: googauth,
baseURL: "https://androidpublisher.googleapis.com/androidpublisher",
}, nil
}
type PurchaseType int
const (
PurchaseTypeTest PurchaseType = 0 // i.e. purchased from a license testing account
PurchaseTypePromo PurchaseType = 1 // i.e. purchased using a promo code
PurchaseTypeRewarded PurchaseType = 2 // i.e. from watching a video ad instead of paying
)
type ConsumptionState int
const (
ConsumptionStateYetToBeConsumed ConsumptionState = 0
ConsumptionStateConsumed ConsumptionState = 1
)
type PurchaseState int
const (
PurchaseStatePurchased PurchaseState = 0
PurchaseStateCanceled PurchaseState = 1
PurchaseStatePending PurchaseState = 2
)
type AcknowledgementState int
const (
AcknowledgementStateYetToBeAcknowledged AcknowledgementState = 0
AcknowledgementStateAcknowledged AcknowledgementState = 1
)
type ProductPurchase struct {
Kind string `json:"kind"`
PurchaseTimeMillis string `json:"purchaseTimeMillis"`
PurchaseState *PurchaseState `json:"purchaseState"`
ConsumptionState ConsumptionState `json:"consumptionState"`
DeveloperPayload string `json:"developerPayload"`
OrderId string `json:"orderId"`
PurchaseType *PurchaseType `json:"purchaseType"`
AcknowledgementState AcknowledgementState `json:"acknowledgementState"`
PurchaseToken *string `json:"purchaseToken"`
ProductId *string `json:"productId"`
Quantity *int `json:"quantity"`
ObfuscatedExternalAccountId string `json:"obfuscatedExternalAccountId"`
ObfuscatedExternalProfileId string `json:"obfuscatedExternalProfileId"`
RegionCode string `json:"regionCode"`
}
type apiError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (ap AndroidPublisher) GetProductPurchase(ctx context.Context, packageName string, productId string, token string) (*ProductPurchase, error) {
uri := fmt.Sprintf("%s/v3/applications/%s/purchases/products/%s/tokens/%s", ap.baseURL, packageName, productId, token)
request, err := http.NewRequestWithContext(ctx, "GET", uri, nil)
if err != nil {
return nil, err
}
tok, err := ap.auth.Token(ctx)
if err != nil {
log.Err(err).Msg("Refreshing FB token failed")
return nil, err
}
request.Header.Set("Authorization", "Bearer "+tok)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
response, err := ap.client.Do(request)
if err != nil {
return nil, err
}
defer func() { _ = response.Body.Close() }()
respBodyBin, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
if response.StatusCode == 400 {
var errBody struct {
Error apiError `json:"error"`
}
if err := json.Unmarshal(respBodyBin, &errBody); err != nil {
return nil, err
}
if errBody.Error.Code == 400 {
return nil, nil // probably token not found
}
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
if bstr, err := io.ReadAll(response.Body); err == nil {
return nil, errors.New(fmt.Sprintf("GetProducts-Request returned %d: %s", response.StatusCode, string(bstr)))
} else {
return nil, errors.New(fmt.Sprintf("GetProducts-Request returned %d", response.StatusCode))
}
}
var respBody ProductPurchase
if err := json.Unmarshal(respBodyBin, &respBody); err != nil {
return nil, err
}
if respBody.Kind != "androidpublisher#productPurchase" {
return nil, errors.New(fmt.Sprintf("Invalid ProductPurchase.kind: '%s'", respBody.Kind))
}
return &respBody, nil
}

9
server/google/client.go Normal file
View File

@ -0,0 +1,9 @@
package google
import (
"context"
)
type AndroidPublisherClient interface {
GetProductPurchase(ctx context.Context, packageName string, productId string, token string) (*ProductPurchase, error)
}

16
server/google/dummy.go Normal file
View File

@ -0,0 +1,16 @@
package google
import (
"context"
_ "embed"
)
type DummyGoogleAPIClient struct{}
func NewDummy() AndroidPublisherClient {
return &DummyGoogleAPIClient{}
}
func (d DummyGoogleAPIClient) GetProductPurchase(ctx context.Context, packageName string, productId string, token string) (*ProductPurchase, error) {
return nil, nil // = purchase not found
}

174
server/google/oauth2.go Normal file
View File

@ -0,0 +1,174 @@
package google
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/timeext"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type GoogleOAuth2 struct {
client *http.Client
scopes []string
tokenURL string
privateKeyID string
clientMail string
currToken *string
tokenExpiry *time.Time
privateKey *rsa.PrivateKey
}
func NewAuth(tokenURL string, privKeyID string, cmail string, pemstr string) (*GoogleOAuth2, error) {
pkey, err := decodePemKey(pemstr)
if err != nil {
return nil, err
}
return &GoogleOAuth2{
client: &http.Client{Timeout: 3 * time.Second},
tokenURL: tokenURL,
privateKey: pkey,
privateKeyID: privKeyID,
clientMail: cmail,
scopes: []string{
"https://www.googleapis.com/auth/androidpublisher",
},
}, nil
}
func decodePemKey(pemstr string) (*rsa.PrivateKey, error) {
var raw []byte
block, _ := pem.Decode([]byte(pemstr))
if block != nil {
raw = block.Bytes
} else {
raw = []byte(pemstr)
}
pkey8, err1 := x509.ParsePKCS8PrivateKey(raw)
if err1 == nil {
privkey, ok := pkey8.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("private key is invalid")
}
return privkey, nil
}
pkey1, err2 := x509.ParsePKCS1PrivateKey(raw)
if err2 == nil {
return pkey1, nil
}
return nil, errors.New(fmt.Sprintf("failed to parse private-key: [ %v | %v ]", err1, err2))
}
func (a *GoogleOAuth2) Token(ctx context.Context) (string, error) {
if a.currToken == nil || a.tokenExpiry == nil || a.tokenExpiry.Before(time.Now()) {
err := a.Refresh(ctx)
if err != nil {
return "", err
}
}
return *a.currToken, nil
}
func (a *GoogleOAuth2) Refresh(ctx context.Context) error {
assertion, err := a.encodeAssertion(a.privateKey)
if err != nil {
return err
}
body := url.Values{
"assertion": []string{assertion},
"grant_type": []string{"urn:ietf:params:oauth:grant-type:jwt-bearer"},
}.Encode()
req, err := http.NewRequestWithContext(ctx, "POST", a.tokenURL, strings.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
reqNow := time.Now()
resp, err := a.client.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if bstr, err := io.ReadAll(resp.Body); err == nil {
return errors.New(fmt.Sprintf("Auth-Request returned %d: %s", resp.StatusCode, string(bstr)))
} else {
return errors.New(fmt.Sprintf("Auth-Request returned %d", resp.StatusCode))
}
}
respBodyBin, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
var respBody struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(respBodyBin, &respBody); err != nil {
return err
}
a.currToken = langext.Ptr(respBody.AccessToken)
a.tokenExpiry = langext.Ptr(reqNow.Add(timeext.FromSeconds(respBody.ExpiresIn)))
return nil
}
func (a *GoogleOAuth2) encodeAssertion(key *rsa.PrivateKey) (string, error) {
headBin, err := json.Marshal(gin.H{"alg": "RS256", "typ": "JWT", "kid": a.privateKeyID})
if err != nil {
return "", err
}
head := base64.RawURLEncoding.EncodeToString(headBin)
now := time.Now().Add(-10 * time.Second) // jwt hack against unsynced clocks
claimBin, err := json.Marshal(gin.H{"iss": a.clientMail, "scope": strings.Join(a.scopes, " "), "aud": a.tokenURL, "exp": now.Add(time.Hour).Unix(), "iat": now.Unix()})
if err != nil {
return "", err
}
claim := base64.RawURLEncoding.EncodeToString(claimBin)
checksum := sha256.New()
checksum.Write([]byte(head + "." + claim))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, checksum.Sum(nil))
if err != nil {
return "", err
}
return head + "." + claim + "." + base64.RawURLEncoding.EncodeToString(sig), nil
}

View File

@ -5,6 +5,7 @@ import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/common/ginresp"
"blackforestbytes.com/simplecloudnotifier/db"
"blackforestbytes.com/simplecloudnotifier/google"
"blackforestbytes.com/simplecloudnotifier/models"
"blackforestbytes.com/simplecloudnotifier/push"
"context"
@ -24,13 +25,14 @@ import (
)
type Application struct {
Config scn.Config
Gin *gin.Engine
Database *db.Database
Firebase push.NotificationClient
Jobs []Job
stopChan chan bool
Port string
Config scn.Config
Gin *gin.Engine
Database *db.Database
Firebase push.NotificationClient
AndroidPublisher google.AndroidPublisherClient
Jobs []Job
stopChan chan bool
Port string
}
func NewApp(db *db.Database) *Application {
@ -40,10 +42,11 @@ func NewApp(db *db.Database) *Application {
}
}
func (app *Application) Init(cfg scn.Config, g *gin.Engine, fb push.NotificationClient, jobs []Job) {
func (app *Application) Init(cfg scn.Config, g *gin.Engine, fb push.NotificationClient, apc google.AndroidPublisherClient, jobs []Job) {
app.Config = cfg
app.Gin = g
app.Firebase = fb
app.AndroidPublisher = apc
app.Jobs = jobs
}
@ -142,8 +145,41 @@ func (app *Application) QuotaMax(ispro bool) int {
}
}
func (app *Application) VerifyProToken(token string) (bool, error) {
return false, nil //TODO implement pro verification
func (app *Application) VerifyProToken(ctx *AppContext, token string) (bool, error) {
if strings.HasPrefix(token, "ANDROID|v2|") {
subToken := token[len("ANDROID|v2|"):]
return app.VerifyAndroidProToken(ctx, subToken)
}
if strings.HasPrefix(token, "IOS|v2|") {
subToken := token[len("IOS|v2|"):]
return app.VerifyIOSProToken(ctx, subToken)
}
return false, nil
}
func (app *Application) VerifyAndroidProToken(ctx *AppContext, token string) (bool, error) {
purchase, err := app.AndroidPublisher.GetProductPurchase(ctx, app.Config.GooglePackageName, app.Config.GoogleProProductID, token)
if err != nil {
return false, err
}
if purchase == nil {
return false, nil
}
if purchase.PurchaseState == nil {
return false, nil
}
if *purchase.PurchaseState != google.PurchaseStatePurchased {
return false, nil
}
return true, nil
}
func (app *Application) VerifyIOSProToken(ctx *AppContext, token string) (bool, error) {
return false, nil //TODO IOS
}
func (app *Application) Migrate() error {

View File

@ -8,8 +8,8 @@ import (
type DummyConnector struct{}
func NewDummy() (NotificationClient, error) {
return &DummyConnector{}, nil
func NewDummy() NotificationClient {
return &DummyConnector{}
}
func (d DummyConnector) SendNotification(ctx context.Context, client models.Client, msg models.Message) (string, error) {

View File

@ -213,7 +213,7 @@
}
},
"/api/db-test": {
"get": {
"post": {
"tags": [
"Common"
],
@ -1013,7 +1013,7 @@
}
}
},
"/api/users/": {
"/api/users": {
"post": {
"tags": [
"API-v2"
@ -2366,12 +2366,6 @@
},
"handler.CreateUser.body": {
"type": "object",
"required": [
"agent_model",
"agent_version",
"client_type",
"fcm_token"
],
"properties": {
"agent_model": {
"type": "string"
@ -2385,6 +2379,9 @@
"fcm_token": {
"type": "string"
},
"no_client": {
"type": "boolean"
},
"pro_token": {
"type": "string"
},

View File

@ -71,15 +71,12 @@ definitions:
type: string
fcm_token:
type: string
no_client:
type: boolean
pro_token:
type: string
username:
type: string
required:
- agent_model
- agent_version
- client_type
- fcm_token
type: object
handler.DatabaseTest.response:
properties:
@ -615,7 +612,7 @@ paths:
tags:
- API-v1
/api/db-test:
get:
post:
operationId: api-common-dbtest
responses:
"200":
@ -1162,7 +1159,7 @@ paths:
summary: Upgrade a free account to a paid account
tags:
- API-v1
/api/users/:
/api/users:
post:
operationId: api-user-create
parameters:

View File

@ -66,8 +66,6 @@ func NewSimpleWebserver() (*logic.Application, func()) {
panic(err)
}
//dbfile := "/home/mike/Code/private/SimpleCloudNotifier/server/.run-data/db_test.sqlite3"
fmt.Println("DatabaseFile: " + dbfile)
conf := scn.Config{
@ -98,8 +96,10 @@ func NewSimpleWebserver() (*logic.Application, func()) {
nc := push.NewTestSink()
apc := google.NewDummy()
jobRetry := jobs.NewDeliveryRetryJob(app)
app.Init(conf, ginengine, nc, []logic.Job{jobRetry})
app.Init(conf, ginengine, nc, apc, []logic.Job{jobRetry})
router.Init(ginengine)

View File

@ -25,7 +25,7 @@
<a tabindex="-1" href="/" class="linkcaption"><h1>Simple Cloud Notifier</h1></a>
<p>Get your user-id and user-key from the app and send notifications to your phone by performing a POST request against <code>https://simplecloudnotifier.blackforestbytes.com/</code></p>
<p>Get your user-id and user-key from the android or iOS app.<br/>And send notifications to your phone by performing a POST request against <code>{{config|baseURL}}/</code> from anywhere</p>
<pre>
curl \
--data "user_id=${userid}" \
@ -36,14 +36,14 @@ curl \
--data "msg_id=$(uuidgen)" \
--data "timestamp=$(date +%s)" \
--data "channel={channel_name}" \
https://scn.blackforestbytes.com/</pre>
{{config|baseURL}}/</pre>
<p>Most parameters are optional, you can send a message with only a title (default priority and channel will be used)</p>
<pre>
curl \
--data "user_id={userid}" \
--data "user_key={userkey}" \
--data "title={message_title}" \
https://scn.blackforestbytes.com/</pre>
{{config|baseURL}}/</pre>
<a href="/api_more" class="button bordered tertiary" style="float: right; min-width: 100px; text-align: center">More</a>

View File

@ -54,7 +54,7 @@
<h2>API Requests</h2>
<div class="section">
<p>
To send a new notification you send a <code>POST</code> request to the URL <code>https://scn.blackforestbytes.com/</code>.
To send a new notification you send a <code>POST</code> request to the URL <code>{{config|baseURL}}/</code>.
All Parameters can either directly be submitted as URL parameters or they can be put into the POST body (either multipart/form-data or JSON).
</p>
<p>
@ -141,7 +141,7 @@
--data "user_key={userkey}" \
--data "title={message_title}" \
--data "content={message_content}" \
https://scn.blackforestbytes.com/</pre>
{{config|baseURL}}/</pre>
</div>
<h2>Message Priority</h2>
@ -159,7 +159,7 @@
--data "user_key={userkey}" \
--data "title={message_title}" \
--data "priority={0|1|2}" \
https://scn.blackforestbytes.com/s</pre>
{{config|baseURL}}/</pre>
</div>
<h2>Channels</h2>
@ -174,7 +174,7 @@
--data "user_key={userkey}" \
--data "title={message_title}" \
--data "channel={my_channel}" \
https://scn.blackforestbytes.com/s</pre>
{{config|baseURL}}/</pre>
</div>
<h2>Message Uniqueness</h2>
@ -194,7 +194,7 @@
--data "user_key={userkey}" \
--data "title={message_title}" \
--data "msg_id={message_id}" \
https://scn.blackforestbytes.com/</pre>
{{config|baseURL}}/</pre>
<p>
Be aware that the server only saves send messages for a short amount of time. Because of that you can only use this to prevent duplicates in a short time-frame, older messages with the same ID are probably already deleted and the message will be send again.
</p>
@ -213,7 +213,7 @@
--data "user_key={userkey}" \
--data "title={message_title}" \
--data "timestamp={unix_timestamp}" \
https://scn.blackforestbytes.com/</pre>
{{config|baseURL}}/</pre>
</div>
<h2>Bash script example</h2>