Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/SDK/Language/CLI.php
Original file line number Diff line number Diff line change
Expand Up @@ -1171,6 +1171,11 @@ public function getFiles(): array
'destination' => 'internal/apns/appwrite/adapter_test.go',
'template' => 'cli/internal/apns/appwrite/adapter_test.go',
],
[
'scope' => 'default',
'destination' => 'internal/apns/appwrite/appid.go',
'template' => 'cli/internal/apns/appwrite/appid.go',
],
[
'scope' => 'default',
'destination' => 'internal/apns/appwrite/auth.go',
Expand Down
108 changes: 106 additions & 2 deletions templates/cli/internal/apns/apns.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ type Key struct {
KeyID string
TeamID string
P8 string
// AppleID is the Apple ID that signed in, when the adapter knows it, so
// later steps can reuse the saved sign-in instead of asking again.
AppleID string
}

// ExistingKey is a key already on the team.
Expand All @@ -73,6 +76,9 @@ var (
ErrUnavailable = errors.New("this APNs key setup is not available here")
// ErrInvalidCredentials means Apple refused the Apple ID or password.
ErrInvalidCredentials = errors.New("Apple did not accept the Apple ID and password")
// ErrSignInNeeded means a request with SavedSignInOnly found no saved
// sign-in it could use. Nothing was asked.
ErrSignInNeeded = errors.New("signing in to Apple is needed")
)

// Asker asks the person at the terminal.
Expand All @@ -99,12 +105,15 @@ type Request struct {
// Reset signs in to Apple again instead of reusing a saved sign-in, and
// the new one replaces it.
Reset bool
Asker Asker
// SavedSignInOnly uses a saved sign-in only: without a valid one the
// adapter returns ErrSignInNeeded instead of asking.
SavedSignInOnly bool
Asker Asker
// Log prints a line for the person running the command.
Log func(format string, args ...any)
}

// Adapter creates APNs keys one way.
// Adapter creates APNs keys, and prepares App IDs, one way.
type Adapter interface {
// Name is how people pick the adapter, for example "appwrite".
Name() string
Expand All @@ -113,6 +122,101 @@ type Adapter interface {
// Apple refuses the sign-in, and a *MaxKeysError when the team has no
// free key slot.
CreateKey(ctx context.Context, request Request) (Key, error)
// PrepareAppID signs in like CreateKey and makes sure the bundle ID is an
// App ID with Push Notifications: APNs refuses pushes for any other
// bundle ID (TopicDisallowed), and a key alone does not register it. It
// registers the App ID when the team has none and turns on Push
// Notifications when it is off, asking first when appID.Ask is set
// (ConfirmAppIDChange words the questions). With
// request.SavedSignInOnly and no saved sign-in it returns
// ErrSignInNeeded.
PrepareAppID(ctx context.Context, request Request, appID AppIDRequest) (AppIDResult, error)
}

// AppIDRequest names the App ID to prepare.
type AppIDRequest struct {
BundleID string
// Ask asks before registering the App ID or turning on Push
// Notifications. Without it the changes are made directly.
Ask bool
}

// AppIDResult is what preparing an App ID found and changed.
type AppIDResult struct {
TeamID string
// Registered and Push describe the App ID after the call.
Registered bool
Push bool
// Created and PushTurnedOn are the changes the call made.
Created bool
PushTurnedOn bool
}

// ConfirmAppIDChange asks before an App ID change: registering the bundle ID
// (with Push Notifications) when registered is false, else turning Push
// Notifications on. Every adapter asks with these words.
func ConfirmAppIDChange(asker Asker, bundleID, teamID string, registered bool) (bool, error) {
question := fmt.Sprintf("%s is not registered as an App ID on team %s, so APNs refuses pushes for it. Register it with Push Notifications?", bundleID, teamID)
if registered {
question = fmt.Sprintf("Push Notifications is off for %s, so APNs refuses pushes for it. Turn it on?", bundleID)
}
choice, err := asker.Choose(question, []string{"Yes", "No"})

return choice == 0, err
}

// AppIDName is an App ID's description in the portal, which allows only
// letters, digits and spaces.
func AppIDName(bundleID string) string {
name := strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' {
return r
}

return ' '
}, bundleID)

return strings.Join(strings.Fields(name), " ")
}

var bundleIDPattern = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)

// CheckBundleID reports whether bundleID is shaped like an iOS bundle ID.
func CheckBundleID(bundleID string) error {
if !bundleIDPattern.MatchString(bundleID) {
return fmt.Errorf("%q is not a bundle ID: a bundle ID has letters, digits, hyphens and dots", bundleID)
}

return nil
}

// PrepareAppID checks the request, runs the adapter, and checks what it
// returned, so every adapter is held to the same contract.
func PrepareAppID(ctx context.Context, adapter Adapter, request Request, appID AppIDRequest) (AppIDResult, error) {
if err := CheckBundleID(appID.BundleID); err != nil {
return AppIDResult{}, err
}
if request.TeamID != "" && !teamIDPattern.MatchString(request.TeamID) {
return AppIDResult{}, fmt.Errorf("%q is not an Apple team ID: a team ID is 10 uppercase letters or digits", request.TeamID)
}
if request.Log == nil {
request.Log = func(string, ...any) {}
}

result, err := adapter.PrepareAppID(ctx, request, appID)
if err != nil {
return result, err
}
switch {
case !teamIDPattern.MatchString(result.TeamID):
return result, fmt.Errorf("the %s APNs setup returned an invalid team ID %q", adapter.Name(), result.TeamID)
case request.TeamID != "" && result.TeamID != request.TeamID:
return result, fmt.Errorf("the %s APNs setup checked the App ID on team %s, not %s", adapter.Name(), result.TeamID, request.TeamID)
case (result.Created || result.Push) && !result.Registered, result.PushTurnedOn && !result.Push:
return result, fmt.Errorf("the %s APNs setup returned an inconsistent App ID result %+v", adapter.Name(), result)
}

return result, nil
}

var (
Expand Down
86 changes: 86 additions & 0 deletions templates/cli/internal/apns/apns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ func (otherTeam) CreateKey(context.Context, apns.Request) (apns.Key, error) {
return apns.Key{KeyID: "NEWKEY1234", TeamID: "OTHER12345", P8: apnstest.P8()}, nil
}

func (otherTeam) PrepareAppID(context.Context, apns.Request, apns.AppIDRequest) (apns.AppIDResult, error) {
return apns.AppIDResult{TeamID: "OTHER12345", Registered: true, Push: true}, nil
}

func TestRegistry(t *testing.T) {
empty := apns.NewRegistry()
if _, err := empty.Get("appwrite"); err == nil || !strings.Contains(err.Error(), "this build has none") {
Expand All @@ -113,3 +117,85 @@ func TestRegistry(t *testing.T) {
t.Errorf("err = %v", err)
}
}

func TestPrepareAppIDChecksTheRequest(t *testing.T) {
adapter := &apnstest.Adapter{TeamID: "TEAM123456"}
for _, test := range []struct {
bundleID, teamID, want string
}{
{"", "", "is not a bundle ID"},
{"com.example app", "", "is not a bundle ID"},
{"com.example.app", "team", "is not an Apple team ID"},
} {
if _, err := apns.PrepareAppID(context.Background(), adapter, apns.Request{TeamID: test.teamID}, apns.AppIDRequest{BundleID: test.bundleID}); err == nil || !strings.Contains(err.Error(), test.want) {
t.Errorf("%q, %q: err = %v", test.bundleID, test.teamID, err)
}
}
if len(adapter.AppIDRequests) != 0 {
t.Errorf("ran the adapter for a bad request: %+v", adapter.AppIDRequests)
}

result, err := apns.PrepareAppID(context.Background(), adapter, apns.Request{}, apns.AppIDRequest{BundleID: "com.example.app", Ask: true})
if err != nil || result != (apns.AppIDResult{TeamID: "TEAM123456", Registered: true, Push: true}) || !adapter.AppIDRequests[0].Ask {
t.Errorf("result = %+v, %v, calls %v", result, err, adapter.AppIDRequests)
}
}

func TestPrepareAppIDHoldsAdaptersToTheContract(t *testing.T) {
for _, test := range []struct {
name string
adapter apns.Adapter
teamID string
want string
}{
{"team ID", &apnstest.Adapter{AppIDResult: apns.AppIDResult{TeamID: "bad", Registered: true}}, "", `invalid team ID "bad"`},
{"other team", &otherTeam{}, "TEAM123456", "on team OTHER12345, not TEAM123456"},
{"created but not registered", &apnstest.Adapter{AppIDResult: apns.AppIDResult{TeamID: "TEAM123456", Created: true}}, "", "inconsistent"},
{"push turned on but off", &apnstest.Adapter{AppIDResult: apns.AppIDResult{TeamID: "TEAM123456", Registered: true, PushTurnedOn: true}}, "", "inconsistent"},
} {
t.Run(test.name, func(t *testing.T) {
_, err := apns.PrepareAppID(context.Background(), test.adapter, apns.Request{TeamID: test.teamID}, apns.AppIDRequest{BundleID: "com.example.app"})
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Errorf("err = %v", err)
}
})
}

needed := &apnstest.Adapter{SignInNeeded: true}
if _, err := apns.PrepareAppID(context.Background(), needed, apns.Request{SavedSignInOnly: true}, apns.AppIDRequest{BundleID: "com.example.app"}); !errors.Is(err, apns.ErrSignInNeeded) {
t.Errorf("err = %v", err)
}
}

func TestAppIDNameKeepsLettersAndDigits(t *testing.T) {
if name := apns.AppIDName("io.appwrite.push-test_2"); name != "io appwrite push test 2" {
t.Errorf("name = %q", name)
}
}

type choosing struct {
asked []string
choice int
}

func (c *choosing) Ask(string, bool) (string, error) { return "", errors.New("no questions") }

func (c *choosing) Choose(question string, options []string) (int, error) {
c.asked = append(c.asked, question+" "+strings.Join(options, "/"))

return c.choice, nil
}

func TestConfirmAppIDChange(t *testing.T) {
asker := &choosing{}
if yes, err := apns.ConfirmAppIDChange(asker, "com.example.app", "TEAM123456", false); !yes || err != nil {
t.Errorf("register = %v, %v", yes, err)
}
asker.choice = 1
if yes, err := apns.ConfirmAppIDChange(asker, "com.example.app", "TEAM123456", true); yes || err != nil {
t.Errorf("turn on = %v, %v", yes, err)
}
if strings.Join(asker.asked, "|") != "com.example.app is not registered as an App ID on team TEAM123456, so APNs refuses pushes for it. Register it with Push Notifications? Yes/No|Push Notifications is off for com.example.app, so APNs refuses pushes for it. Turn it on? Yes/No" {
t.Errorf("asked %v", asker.asked)
}
}
42 changes: 41 additions & 1 deletion templates/cli/internal/apns/apnstest/apnstest.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ type Adapter struct {
TeamID string
Err error
Requests []apns.Request

// AppIDResult is what PrepareAppID returns; zero means a registered App
// ID with Push Notifications. Its team defaults like TeamID's.
AppIDResult apns.AppIDResult
AppIDErr error
// SignInNeeded makes a request limited to a saved sign-in get
// apns.ErrSignInNeeded, as when nothing is saved.
SignInNeeded bool
// AppIDRequests and AppIDSignIns record each PrepareAppID call.
AppIDRequests []apns.AppIDRequest
AppIDSignIns []apns.Request
}

// Name returns AdapterName, or "fake".
Expand All @@ -44,7 +55,36 @@ func (a *Adapter) CreateKey(_ context.Context, request apns.Request) (apns.Key,
team = a.TeamID
}

return apns.Key{KeyID: a.KeyID, TeamID: team, P8: P8()}, nil
appleID := request.AppleID
if appleID == "" {
appleID = "dev@example.com"
}

return apns.Key{KeyID: a.KeyID, TeamID: team, P8: P8(), AppleID: appleID}, nil
}

// PrepareAppID records the call and returns AppIDResult or AppIDErr.
func (a *Adapter) PrepareAppID(_ context.Context, request apns.Request, appID apns.AppIDRequest) (apns.AppIDResult, error) {
a.AppIDRequests = append(a.AppIDRequests, appID)
a.AppIDSignIns = append(a.AppIDSignIns, request)
if request.SavedSignInOnly && a.SignInNeeded {
return apns.AppIDResult{}, apns.ErrSignInNeeded
}
if a.AppIDErr != nil {
return apns.AppIDResult{}, a.AppIDErr
}
result := a.AppIDResult
if result == (apns.AppIDResult{}) {
result = apns.AppIDResult{Registered: true, Push: true}
}
if result.TeamID == "" {
result.TeamID = request.TeamID
}
if result.TeamID == "" {
result.TeamID = a.TeamID
}

return result, nil
}

// P8 returns a new APNs-shaped private key in PEM form.
Expand Down
56 changes: 55 additions & 1 deletion templates/cli/internal/apns/appwrite/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,56 @@ func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key
return apns.Key{}, fmt.Errorf("created key %s but could not download it, and Apple allows only one download. Revoke it in the Apple Developer portal: %w", keyID, err)
}

return apns.Key{KeyID: keyID, TeamID: teamID, P8: p8}, nil
return apns.Key{KeyID: keyID, TeamID: teamID, P8: p8, AppleID: portal.appleID}, nil
}

// PrepareAppID signs in, then makes sure the bundle ID is an App ID with Push
// Notifications on the team, asking first when appID.Ask is set.
func (a *Adapter) PrepareAppID(ctx context.Context, request apns.Request, appID apns.AppIDRequest) (apns.AppIDResult, error) {
portal, err := a.connect(ctx, request)
if err != nil {
return apns.AppIDResult{}, err
}
defer portal.saveSession()

teamID, err := portal.chooseTeam(ctx, request.TeamID)
if err != nil {
return apns.AppIDResult{}, err
}
result := apns.AppIDResult{TeamID: teamID}
found, err := portal.appID(ctx, teamID, appID.BundleID)
if err != nil {
return result, err
}
if found == nil {
if appID.Ask {
register, err := apns.ConfirmAppIDChange(request.Asker, appID.BundleID, teamID, false)
if err != nil || !register {
return result, err
}
}
if found, err = portal.createAppID(ctx, teamID, appID.BundleID, apns.AppIDName(appID.BundleID)); err != nil {
return result, err
}
result.Created = true
}
result.Registered = true
if !found.Push {
// Registering it was already agreed to as "with Push Notifications".
if appID.Ask && !result.Created {
turnOn, err := apns.ConfirmAppIDChange(request.Asker, appID.BundleID, teamID, true)
if err != nil || !turnOn {
return result, err
}
}
if err := portal.enablePush(ctx, teamID, found); err != nil {
return result, err
}
result.PushTurnedOn = true
}
result.Push = found.Push

return result, nil
}

func sessionPath(dir string) string {
Expand Down Expand Up @@ -136,6 +185,7 @@ func (a *Adapter) connect(ctx context.Context, request apns.Request) (*portalCli
if session, err := portal.session(ctx); err == nil && session != nil {
if accountName == "" || strings.EqualFold(session.User.EmailAddress, accountName) {
logf("Using the saved Apple sign-in for %s.", session.User.EmailAddress)
portal.appleID = session.User.EmailAddress

return portal, nil
}
Expand All @@ -144,6 +194,9 @@ func (a *Adapter) connect(ctx context.Context, request apns.Request) (*portalCli
withJar.Jar = jar
portal.jar = jar
}
if request.SavedSignInOnly {
return nil, apns.ErrSignInNeeded
}
// An expired session's cookies stay: among them is the one that marks
// this machine as trusted, which lets Apple skip two-factor on sign-in.
if err := portal.loadWidgetKey(ctx); err != nil {
Expand Down Expand Up @@ -179,6 +232,7 @@ func (a *Adapter) connect(ctx context.Context, request apns.Request) (*portalCli
if session == nil {
return nil, errors.New("Apple signed you in but App Store Connect refused the session. Sign in once at https://appstoreconnect.apple.com to accept any pending agreements, then try again")
}
portal.appleID = accountName

return portal, nil
}
Expand Down
Loading
Loading