diff --git a/server/README.md b/server/README.md
index a017fb9..28e93b0 100644
--- a/server/README.md
+++ b/server/README.md
@@ -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"
- ]
- }
-}
-
-~~~~~~~~~~~~~~~~~~~~~~~~~~
\ No newline at end of file
diff --git a/server/api/handler/api.go b/server/api/handler/api.go
index 501ac49..b9a20f8 100644
--- a/server/api/handler/api.go
+++ b/server/api/handler/api.go
@@ -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
diff --git a/server/api/handler/common.go b/server/api/handler/common.go
index 20b8296..9ae0bf6 100644
--- a/server/api/handler/common.go
+++ b/server/api/handler/common.go
@@ -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 {
diff --git a/server/api/handler/compat.go b/server/api/handler/compat.go
index 6b654bf..1bf5b60 100644
--- a/server/api/handler/compat.go
+++ b/server/api/handler/compat.go
@@ -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")
}
diff --git a/server/api/handler/website.go b/server/api/handler/website.go
index 0df6148..a9923db 100644
--- a/server/api/handler/website.go
+++ b/server/api/handler/website.go
@@ -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
+
+}
diff --git a/server/cmd/scnserver/main.go b/server/cmd/scnserver/main.go
index 4dbc5f7..7f52c51 100644
--- a/server/cmd/scnserver/main.go
+++ b/server/cmd/scnserver/main.go
@@ -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)
diff --git a/server/config.go b/server/config.go
index 9424749..9fcdfa3 100644
--- a/server/config.go
+++ b/server/config.go
@@ -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"),
}
}
diff --git a/server/google/androidPublisher.go b/server/google/androidPublisher.go
new file mode 100644
index 0000000..c983221
--- /dev/null
+++ b/server/google/androidPublisher.go
@@ -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
+}
diff --git a/server/google/client.go b/server/google/client.go
new file mode 100644
index 0000000..433cac0
--- /dev/null
+++ b/server/google/client.go
@@ -0,0 +1,9 @@
+package google
+
+import (
+ "context"
+)
+
+type AndroidPublisherClient interface {
+ GetProductPurchase(ctx context.Context, packageName string, productId string, token string) (*ProductPurchase, error)
+}
diff --git a/server/google/dummy.go b/server/google/dummy.go
new file mode 100644
index 0000000..dde38ce
--- /dev/null
+++ b/server/google/dummy.go
@@ -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
+}
diff --git a/server/google/oauth2.go b/server/google/oauth2.go
new file mode 100644
index 0000000..bd21e72
--- /dev/null
+++ b/server/google/oauth2.go
@@ -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
+}
diff --git a/server/logic/application.go b/server/logic/application.go
index 2d6d0ab..1fd9c02 100644
--- a/server/logic/application.go
+++ b/server/logic/application.go
@@ -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 {
diff --git a/server/push/dummy.go b/server/push/dummy.go
index 71afbe7..0d56216 100644
--- a/server/push/dummy.go
+++ b/server/push/dummy.go
@@ -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) {
diff --git a/server/swagger/swagger.json b/server/swagger/swagger.json
index aa003ff..782062d 100644
--- a/server/swagger/swagger.json
+++ b/server/swagger/swagger.json
@@ -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"
},
diff --git a/server/swagger/swagger.yaml b/server/swagger/swagger.yaml
index 587b0bd..970758b 100644
--- a/server/swagger/swagger.yaml
+++ b/server/swagger/swagger.yaml
@@ -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:
diff --git a/server/test/common_test.go b/server/test/common_test.go
index 3e80ece..051f9cf 100644
--- a/server/test/common_test.go
+++ b/server/test/common_test.go
@@ -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)
diff --git a/server/website/api.html b/server/website/api.html
index 77d42b5..366afe3 100644
--- a/server/website/api.html
+++ b/server/website/api.html
@@ -25,7 +25,7 @@
Simple Cloud Notifier
-
Get your user-id and user-key from the app and send notifications to your phone by performing a POST request against https://simplecloudnotifier.blackforestbytes.com/
Get your user-id and user-key from the android or iOS app.
And send notifications to your phone by performing a POST request against {{config|baseURL}}/
from anywhere
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/+ {{config|baseURL}}/
Most parameters are optional, you can send a message with only a title (default priority and channel will be used)
curl \ --data "user_id={userid}" \ --data "user_key={userkey}" \ --data "title={message_title}" \ - https://scn.blackforestbytes.com/+ {{config|baseURL}}/ More diff --git a/server/website/api_more.html b/server/website/api_more.html index 0dfde2a..9add34a 100644 --- a/server/website/api_more.html +++ b/server/website/api_more.html @@ -54,7 +54,7 @@
- To send a new notification you send a POST
request to the URL https://scn.blackforestbytes.com/
.
+ To send a new notification you send a POST
request to the URL {{config|baseURL}}/
.
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).
@@ -141,7 +141,7 @@ --data "user_key={userkey}" \ --data "title={message_title}" \ --data "content={message_content}" \ - https://scn.blackforestbytes.com/ + {{config|baseURL}}/
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.
@@ -213,7 +213,7 @@ --data "user_key={userkey}" \ --data "title={message_title}" \ --data "timestamp={unix_timestamp}" \ - https://scn.blackforestbytes.com/ + {{config|baseURL}}/