diff --git a/mock-server/app/http.php b/mock-server/app/http.php index 1a7d67fccb..ba725d7311 100644 --- a/mock-server/app/http.php +++ b/mock-server/app/http.php @@ -1052,6 +1052,346 @@ ]); }); +/** + * Messaging providers and project platforms, for `appwrite init apns`. Kept per project in a file, + * since the workers share no memory, so a second run sees what the first created. + */ +function apnsMockState(Request $request, callable $change): mixed +{ + $project = (string) $request->getHeader('x-appwrite-project', 'default'); + + return mockState('apns-' . $project, ['providers' => [], 'platforms' => []], $change); +} + +function mockState(string $name, array $initial, callable $change): mixed +{ + $handle = \fopen(APP_STORAGE_CACHE . '/' . \preg_replace('/[^A-Za-z0-9_-]/', '', $name) . '.json', 'c+'); + if ($handle === false) { + throw new Exception(Exception::GENERAL_MOCK, 'Failed to open the mock state', 500); + } + \flock($handle, LOCK_EX); + $state = \json_decode((string) \stream_get_contents($handle), true) ?: $initial; + $result = $change($state); + \ftruncate($handle, 0); + \rewind($handle); + \fwrite($handle, \json_encode($state)); + \flock($handle, LOCK_UN); + \fclose($handle); + + return $result; +} + +function apnsMockProvider(string $id, array $params): array +{ + return [ + '$id' => $id, + 'name' => $params['name'] ?? '', + 'provider' => 'apns', + 'type' => 'push', + 'enabled' => (bool) ($params['enabled'] ?? true), + 'credentials' => [ + 'authKeyId' => $params['authKeyId'] ?? '', + 'teamId' => $params['teamId'] ?? '', + 'bundleId' => $params['bundleId'] ?? '', + ], + 'options' => ['sandbox' => (bool) ($params['sandbox'] ?? false)], + ]; +} + +App::get('/v1/messaging/providers') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $kind = ''; + foreach ((array) $request->getParam('queries', []) as $query) { + $decoded = \json_decode((string) $query, true); + if (($decoded['attribute'] ?? '') === 'provider') { + $kind = (string) ($decoded['values'][0] ?? ''); + } + } + $providers = apnsMockState($request, fn (array &$state) => \array_values(\array_filter( + $state['providers'], + fn (array $provider) => $kind === '' || $provider['provider'] === $kind + ))); + $response->json(['total' => \count($providers), 'providers' => $providers]); + }); + +App::post('/v1/messaging/providers/apns') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $params = $request->getParams(); + $provider = apnsMockState($request, function (array &$state) use ($params) { + $provider = apnsMockProvider('provider' . \count($state['providers']), $params); + $state['providers'][] = $provider; + return $provider; + }); + $response->setStatusCode(Response::STATUS_CODE_CREATED)->json($provider); + }); + +App::patch('/v1/messaging/providers/apns/:providerId') + ->param('providerId', '', new Text(36), 'Provider ID.') + ->inject('request') + ->inject('response') + ->action(function (string $providerId, Request $request, UtopiaSwooleResponse $response) { + $params = $request->getParams(); + $provider = apnsMockState($request, function (array &$state) use ($providerId, $params) { + foreach ($state['providers'] as $index => $provider) { + if ($provider['$id'] === $providerId) { + return $state['providers'][$index] = apnsMockProvider($providerId, $params); + } + } + return null; + }); + if ($provider === null) { + throw new Exception(Exception::GENERAL_MOCK, 'Provider not found', 404); + } + $response->json($provider); + }); + +App::get('/v1/project/platforms') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $platforms = apnsMockState($request, fn (array &$state) => $state['platforms']); + $response->json(['total' => \count($platforms), 'platforms' => $platforms]); + }); + +App::post('/v1/project/platforms/apple') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $params = $request->getParams(); + if (($params['bundleIdentifier'] ?? '') === '') { + throw new Exception(Exception::GENERAL_MOCK, 'Missing bundleIdentifier', 400); + } + $platform = apnsMockState($request, function (array &$state) use ($params) { + $platform = [ + '$id' => 'platform' . \count($state['platforms']), + 'type' => 'apple', + 'name' => $params['name'] ?? '', + 'bundleIdentifier' => $params['bundleIdentifier'], + ]; + $state['platforms'][] = $platform; + return $platform; + }); + $response->setStatusCode(Response::STATUS_CODE_CREATED)->json($platform); + }); + +/** + * Apple's sign-in, App Store Connect and Developer portal, for `appwrite init apns` with + * APPWRITE_APPLE_HOST set to /apple. Any password signs in, without two-factor, and the session + * cookie carries the Apple ID. Each Apple ID has one team with room for two keys, and its own App + * IDs; an App ID ending in .nopush already exists with Push Notifications off. + */ +const APPLE_MOCK_TEAM = 'E2ETEAM123'; + +function appleMockAccount(Request $request): string +{ + return \rawurldecode((string) $request->getCookie('myacinfo', '')); +} + +function appleMockState(string $account, callable $change): mixed +{ + return mockState('apple-' . \md5($account), ['keys' => [], 'appIds' => []], $change); +} + +function appleMockAppId(array $appId): array +{ + return [ + 'type' => 'bundleIds', + 'id' => $appId['id'], + 'attributes' => ['identifier' => $appId['identifier'], 'seedId' => APPLE_MOCK_TEAM], + 'relationships' => ['bundleIdCapabilities' => ['data' => $appId['push'] ? [['type' => 'bundleIdCapabilities', 'id' => $appId['id'] . '_PUSH']] : []]], + ]; +} + +function appleMockPortal(Request $request, UtopiaSwooleResponse $response, callable $handle): void +{ + $account = appleMockAccount($request); + if ($account === '') { + $response->setStatusCode(401)->json(['resultCode' => 1100, 'resultString' => 'Your session has expired']); + return; + } + $response->json(appleMockState($account, $handle) + ['resultCode' => 0]); +} + +App::get('/apple/olympus/v1/app/config') + ->inject('response') + ->action(function (UtopiaSwooleResponse $response) { + $response->json(['authServiceKey' => 'e2e-widget-key']); + }); + +App::get('/apple/olympus/v1/session') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $account = appleMockAccount($request); + if ($account === '') { + $response->setStatusCode(401)->json([]); + return; + } + $response->json(['user' => ['emailAddress' => $account], 'provider' => ['providerId' => 1, 'name' => 'E2E Team']]); + }); + +App::post('/apple/appleauth/auth/signin/init') + ->inject('response') + ->action(function (UtopiaSwooleResponse $response) { + $response->json([ + 'iteration' => 1000, + 'salt' => \base64_encode(\random_bytes(16)), + 'protocol' => 's2k', + 'b' => \base64_encode(\random_bytes(256)), + 'c' => 'e2e', + ]); + }); + +App::get('/apple/appleauth/auth/signin') + ->inject('response') + ->action(function (UtopiaSwooleResponse $response) { + $response->noContent(); + }); + +App::post('/apple/appleauth/auth/signin/complete') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $account = (string) (\json_decode($request->getRawPayload(), true)['accountName'] ?? ''); + if ($account === '') { + $response->setStatusCode(401)->json([]); + return; + } + $response + ->addCookie('myacinfo', \rawurlencode($account), \time() + 3600, '/') + ->json([]); + }); + +App::post('/apple/services-account/QH65B2/account/listTeams.action') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + appleMockPortal($request, $response, fn () => [ + 'teams' => [['teamId' => APPLE_MOCK_TEAM, 'name' => 'E2E Team', 'type' => 'Company/Organization']], + ]); + }); + +App::post('/apple/services-account/QH65B2/account/auth/key/list') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $response->addHeader('csrf', 'e2e-csrf')->addHeader('csrf_ts', (string) \time()); + appleMockPortal($request, $response, fn (array &$state) => [ + 'keys' => \array_map(fn (array $key) => ['keyId' => $key['id'], 'keyName' => $key['name'], 'canRevoke' => true], $state['keys']), + ]); + }); + +App::post('/apple/services-account/QH65B2/account/auth/key/v2/create') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + if ($request->getHeader('csrf', '') === '') { + $response->setStatusCode(403)->json([]); + return; + } + $name = (string) (\json_decode($request->getRawPayload(), true)['name'] ?? ''); + appleMockPortal($request, $response, function (array &$state) use ($name) { + if (\count($state['keys']) >= 2) { + return ['resultCode' => 1, 'resultString' => 'There is a problem with the request entity', 'userString' => 'You have already reached the maximum allowed number of Keys for this service.']; + } + $key = \openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]); + \openssl_pkey_export($key, $pem); + $id = \strtoupper(\substr(\bin2hex(\random_bytes(5)), 0, 10)); + $state['keys'][] = ['id' => $id, 'name' => $name, 'p8' => $pem, 'downloaded' => false]; + return ['keys' => [['keyId' => $id, 'keyName' => $name, 'canRevoke' => true]]]; + }); + }); + +App::get('/apple/services-account/QH65B2/account/auth/key/download') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $account = appleMockAccount($request); + $keyId = (string) $request->getParam('keyId', ''); + $p8 = $account === '' ? null : appleMockState($account, function (array &$state) use ($keyId) { + foreach ($state['keys'] as $index => $key) { + if ($key['id'] === $keyId && !$key['downloaded']) { + $state['keys'][$index]['downloaded'] = true; + return $key['p8']; + } + } + return null; + }); + if ($p8 === null) { + $response->json(['resultCode' => 1, 'resultString' => 'The key cannot be downloaded']); + return; + } + $response->setContentType('application/octet-stream')->send($p8); + }); + +App::post('/apple/services-account/v1/bundleIds') + ->inject('request') + ->inject('response') + ->action(function (Request $request, UtopiaSwooleResponse $response) { + $account = appleMockAccount($request); + if ($account === '') { + $response->setStatusCode(401)->json([]); + return; + } + $body = \json_decode($request->getRawPayload(), true) ?: []; + if ($request->getHeader('x-http-method-override', '') === 'GET') { + \parse_str((string) ($body['urlEncodedQueryParams'] ?? ''), $query); + $identifier = (string) ($query['filter']['identifier'] ?? ''); + $appIds = appleMockState($account, function (array &$state) use ($identifier) { + if (\str_ends_with($identifier, '.nopush') && !isset($state['appIds'][$identifier])) { + $state['appIds'][$identifier] = ['id' => 'APPID' . \count($state['appIds']), 'identifier' => $identifier, 'push' => false]; + } + return \array_values(\array_filter($state['appIds'], fn (array $appId) => \str_starts_with($appId['identifier'], $identifier))); + }); + $response->json([ + 'data' => \array_map(appleMockAppId(...), $appIds), + 'included' => \array_values(\array_map(fn (array $appId) => [ + 'type' => 'bundleIdCapabilities', + 'id' => $appId['id'] . '_PUSH', + 'attributes' => ['capabilityType' => 'PUSH_NOTIFICATIONS', 'enabled' => true], + ], \array_filter($appIds, fn (array $appId) => $appId['push']))), + ]); + return; + } + $identifier = (string) ($body['data']['attributes']['identifier'] ?? ''); + $appId = appleMockState($account, function (array &$state) use ($identifier) { + return $state['appIds'][$identifier] = ['id' => 'APPID' . \count($state['appIds']), 'identifier' => $identifier, 'push' => false]; + }); + $response->setStatusCode(Response::STATUS_CODE_CREATED)->json(['data' => appleMockAppId($appId)]); + }); + +App::patch('/apple/services-account/v1/bundleIds/:id') + ->param('id', '', new Text(36), 'App ID.') + ->inject('request') + ->inject('response') + ->action(function (string $id, Request $request, UtopiaSwooleResponse $response) { + $account = appleMockAccount($request); + if ($account === '') { + $response->setStatusCode(401)->json([]); + return; + } + $body = \json_decode($request->getRawPayload(), true) ?: []; + $push = ($body['data']['relationships']['bundleIdCapabilities']['data'][0]['relationships']['capability']['data']['id'] ?? '') === 'PUSH_NOTIFICATIONS'; + $appId = appleMockState($account, function (array &$state) use ($id, $push) { + foreach ($state['appIds'] as $identifier => $appId) { + if ($appId['id'] === $id) { + $state['appIds'][$identifier]['push'] = $appId['push'] || $push; + return $state['appIds'][$identifier]; + } + } + return null; + }); + if ($appId === null) { + $response->setStatusCode(404)->json(['errors' => [['status' => '404', 'detail' => 'No App ID ' . $id]]]); + return; + } + $response->json(['data' => appleMockAppId($appId)]); + }); + App::shutdown() ->groups(['mock']) ->inject('utopia') diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index 8de2d0b2a1..35ab5e1753 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -1126,6 +1126,126 @@ public function getFiles(): array 'destination' => 'internal/cmd/initskill.go', 'template' => 'cli/internal/cmd/initskill.go', ], + [ + 'scope' => 'default', + 'destination' => 'internal/cmd/initpush.go', + 'template' => 'cli/internal/cmd/initpush.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/cmd/initpushdetect.go', + 'template' => 'cli/internal/cmd/initpushdetect.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/cmd/initpush_test.go', + 'template' => 'cli/internal/cmd/initpush_test.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/apns.go', + 'template' => 'cli/internal/apns/apns.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/apns_test.go', + 'template' => 'cli/internal/apns/apns_test.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/apnstest/apnstest.go', + 'template' => 'cli/internal/apns/apnstest/apnstest.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/appwrite/adapter.go', + 'template' => 'cli/internal/apns/appwrite/adapter.go', + ], + [ + 'scope' => 'default', + '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', + 'template' => 'cli/internal/apns/appwrite/auth.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/appwrite/jar.go', + 'template' => 'cli/internal/apns/appwrite/jar.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/appwrite/portal.go', + 'template' => 'cli/internal/apns/appwrite/portal.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/appwrite/srp.go', + 'template' => 'cli/internal/apns/appwrite/srp.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/appwrite/srp_test.go', + 'template' => 'cli/internal/apns/appwrite/srp_test.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/expo/adapter.go', + 'template' => 'cli/internal/apns/expo/adapter.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/expo/adapter_test.go', + 'template' => 'cli/internal/apns/expo/adapter_test.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/expo/helper/helper.js', + 'template' => 'cli/internal/apns/expo/helper/helper.js', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/expo/helper/package.json', + 'template' => 'cli/internal/apns/expo/helper/package.json', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/expo/helper/package-lock.json', + 'template' => 'cli/internal/apns/expo/helper/package-lock.json', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/expo/runtime.go', + 'template' => 'cli/internal/apns/expo/runtime.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/expo/runtime_test.go', + 'template' => 'cli/internal/apns/expo/runtime_test.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/fastlane/adapter.go', + 'template' => 'cli/internal/apns/fastlane/adapter.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/fastlane/adapter_test.go', + 'template' => 'cli/internal/apns/fastlane/adapter_test.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/fastlane/helper/Fastfile', + 'template' => 'cli/internal/apns/fastlane/helper/Fastfile', + ], [ 'scope' => 'default', 'destination' => 'internal/cmd/pull.go', diff --git a/templates/cli/internal/apns/apns.go b/templates/cli/internal/apns/apns.go new file mode 100644 index 0000000000..5b24baf5ea --- /dev/null +++ b/templates/cli/internal/apns/apns.go @@ -0,0 +1,300 @@ +// Package apns creates Apple Push Notification service (APNs) auth keys. +// +// Apple has no public API for creating APNs keys. The tools that automate it, +// fastlane's spaceship and @expo/apple-utils, sign in to the Apple Developer +// portal as a person would and call the portal's private API, and so does +// Appwrite's own implementation. Apple can change that API without notice, so +// each implementation is an Adapter, and which one runs is a setting rather +// than a release: when Apple breaks one, people switch to another. +// +// The package imports nothing from the CLI. Prompts and logging come in +// through the Request, so it can move to its own module and repository +// unchanged. +package apns + +import ( + "context" + "errors" + "fmt" + "regexp" + "sort" + "strings" +) + +// Environment is the APNs environment a key works in. +type Environment string + +// The environments Apple offers when creating a key. +const ( + EnvironmentAll Environment = "all" + EnvironmentProduction Environment = "production" + EnvironmentSandbox Environment = "sandbox" +) + +// ParseEnvironment reads an environment name. Empty means all. +func ParseEnvironment(value string) (Environment, error) { + switch environment := Environment(strings.ToLower(strings.TrimSpace(value))); environment { + case "", EnvironmentAll: + return EnvironmentAll, nil + case EnvironmentProduction, EnvironmentSandbox: + return environment, nil + default: + return "", fmt.Errorf("unknown APNs environment %q: use production, sandbox or all", value) + } +} + +// Key is a newly created APNs key. Apple lets its contents be downloaded once. +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. +type ExistingKey struct { + ID string + Name string + CanRevoke bool +} + +// MaxKeysError means the team already has as many APNs keys as Apple allows. +// Keys lists the team's keys when the adapter could read them. +type MaxKeysError struct { + Keys []ExistingKey +} + +func (e *MaxKeysError) Error() string { + return "the Apple team already has the maximum number of APNs keys" +} + +var ( + // ErrUnavailable means the adapter cannot run here, for example because + // the runtime it needs is missing. Nothing was sent to Apple. + 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. +type Asker interface { + // Ask reads one line. secret hides what is typed. + Ask(question string, secret bool) (string, error) + // Choose returns the index of the chosen option. + Choose(question string, options []string) (int, error) +} + +// Request describes the key to create. +type Request struct { + // TeamID is the Apple Developer team that owns the key. Empty lets the + // adapter use the account's only team, or ask which one. + TeamID string + Name string + Environment Environment + // AppleID and Password answer the sign-in questions when set. + AppleID string + Password string + // SessionDir is where an adapter may keep a signed-in session between + // runs, readable by the user only. + SessionDir string + // Reset signs in to Apple again instead of reusing a saved sign-in, and + // the new one replaces it. + Reset bool + // 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, and prepares App IDs, one way. +type Adapter interface { + // Name is how people pick the adapter, for example "appwrite". + Name() string + // CreateKey signs in, creates the key and downloads it. It returns + // ErrUnavailable when it cannot run here, ErrInvalidCredentials when + // 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 ( + keyIDPattern = regexp.MustCompile(`^[A-Z0-9]{10}$`) + teamIDPattern = regexp.MustCompile(`^[A-Z0-9]{10}$`) +) + +// CreateKey checks the request, runs the adapter, and checks what it returned, +// so every adapter is held to the same contract. +func CreateKey(ctx context.Context, adapter Adapter, request Request) (Key, error) { + environment, err := ParseEnvironment(string(request.Environment)) + if err != nil { + return Key{}, err + } + request.Environment = environment + if strings.TrimSpace(request.Name) == "" { + return Key{}, errors.New("an APNs key needs a name") + } + if request.TeamID != "" && !teamIDPattern.MatchString(request.TeamID) { + return Key{}, 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) {} + } + + key, err := adapter.CreateKey(ctx, request) + if err != nil { + return Key{}, err + } + switch { + case !keyIDPattern.MatchString(key.KeyID): + return Key{}, fmt.Errorf("the %s APNs setup returned an invalid key ID %q", adapter.Name(), key.KeyID) + case !teamIDPattern.MatchString(key.TeamID): + return Key{}, fmt.Errorf("the %s APNs setup returned an invalid team ID %q", adapter.Name(), key.TeamID) + case request.TeamID != "" && key.TeamID != request.TeamID: + return Key{}, fmt.Errorf("the %s APNs setup created key %s on team %s, not %s", adapter.Name(), key.KeyID, key.TeamID, request.TeamID) + case !strings.Contains(key.P8, "PRIVATE KEY"): + return Key{}, fmt.Errorf("the %s APNs setup created key %s but returned no .p8 contents", adapter.Name(), key.KeyID) + } + + return key, nil +} + +// Registry holds the adapters a build offers, by name. +type Registry struct { + adapters map[string]Adapter +} + +// NewRegistry returns a registry holding adapters. +func NewRegistry(adapters ...Adapter) *Registry { + registry := &Registry{adapters: map[string]Adapter{}} + for _, adapter := range adapters { + registry.adapters[adapter.Name()] = adapter + } + + return registry +} + +// Names lists the registered adapters in order. +func (r *Registry) Names() []string { + names := make([]string, 0, len(r.adapters)) + for name := range r.adapters { + names = append(names, name) + } + sort.Strings(names) + + return names +} + +// Get returns the adapter called name. +func (r *Registry) Get(name string) (Adapter, error) { + name = strings.ToLower(strings.TrimSpace(name)) + if adapter, ok := r.adapters[name]; ok { + return adapter, nil + } + if len(r.adapters) == 0 { + return nil, fmt.Errorf("APNs key setup %q is not available: this build has none", name) + } + + return nil, fmt.Errorf("unknown APNs key setup %q: use one of %s", name, strings.Join(r.Names(), ", ")) +} diff --git a/templates/cli/internal/apns/apns_test.go b/templates/cli/internal/apns/apns_test.go new file mode 100644 index 0000000000..6cf18df473 --- /dev/null +++ b/templates/cli/internal/apns/apns_test.go @@ -0,0 +1,201 @@ +package apns_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/apnstest" +) + +func TestParseEnvironment(t *testing.T) { + for value, want := range map[string]apns.Environment{ + "": apns.EnvironmentAll, + "all": apns.EnvironmentAll, + "Production": apns.EnvironmentProduction, + " sandbox ": apns.EnvironmentSandbox, + } { + if got, err := apns.ParseEnvironment(value); err != nil || got != want { + t.Errorf("ParseEnvironment(%q) = %q, %v", value, got, err) + } + } + if _, err := apns.ParseEnvironment("staging"); err == nil { + t.Error("accepted staging") + } +} + +func TestCreateKeyPassesTheRequestThrough(t *testing.T) { + adapter := &apnstest.Adapter{KeyID: "NEWKEY1234", TeamID: "TEAM123456"} + key, err := apns.CreateKey(context.Background(), adapter, apns.Request{Name: "Appwrite Push", Environment: "Sandbox"}) + if err != nil { + t.Fatal(err) + } + if key.KeyID != "NEWKEY1234" || key.TeamID != "TEAM123456" { + t.Errorf("key = %+v", key) + } + if len(adapter.Requests) != 1 || adapter.Requests[0].Environment != apns.EnvironmentSandbox || adapter.Requests[0].Log == nil { + t.Errorf("requests = %+v", adapter.Requests) + } +} + +func TestCreateKeyChecksTheRequest(t *testing.T) { + adapter := &apnstest.Adapter{KeyID: "NEWKEY1234"} + for _, request := range []apns.Request{ + {Name: "Appwrite Push", Environment: "staging"}, + {Name: " "}, + {Name: "Appwrite Push", TeamID: "team"}, + } { + if _, err := apns.CreateKey(context.Background(), adapter, request); err == nil { + t.Errorf("accepted %+v", request) + } + } + if len(adapter.Requests) != 0 { + t.Errorf("ran the adapter for a bad request: %+v", adapter.Requests) + } +} + +func TestCreateKeyHoldsAdaptersToTheContract(t *testing.T) { + for _, test := range []struct { + name string + adapter apns.Adapter + want string + }{ + {"key ID", &apnstest.Adapter{KeyID: "short", TeamID: "TEAM123456"}, `invalid key ID "short"`}, + {"team ID", &apnstest.Adapter{KeyID: "NEWKEY1234"}, `invalid team ID ""`}, + {"other team", &otherTeam{}, "on team OTHER12345, not TEAM123456"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := apns.CreateKey(context.Background(), test.adapter, apns.Request{Name: "Appwrite Push", TeamID: teamFor(test.name)}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Errorf("err = %v", err) + } + }) + } + + failing := &apnstest.Adapter{Err: apns.ErrInvalidCredentials} + if _, err := apns.CreateKey(context.Background(), failing, apns.Request{Name: "Appwrite Push"}); !errors.Is(err, apns.ErrInvalidCredentials) { + t.Errorf("err = %v", err) + } +} + +func teamFor(test string) string { + if test == "other team" { + return "TEAM123456" + } + + return "" +} + +type otherTeam struct{} + +func (otherTeam) Name() string { return "other" } + +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") { + t.Errorf("empty registry err = %v", err) + } + + registry := apns.NewRegistry(&apnstest.Adapter{AdapterName: "expo"}, &apnstest.Adapter{AdapterName: "appwrite"}) + if names := strings.Join(registry.Names(), ","); names != "appwrite,expo" { + t.Errorf("names = %s", names) + } + if adapter, err := registry.Get(" Expo "); err != nil || adapter.Name() != "expo" { + t.Errorf("Get(expo) = %v, %v", adapter, err) + } + if _, err := registry.Get("fastlane"); err == nil || err.Error() != `unknown APNs key setup "fastlane": use one of appwrite, expo` { + 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) + } +} diff --git a/templates/cli/internal/apns/apnstest/apnstest.go b/templates/cli/internal/apns/apnstest/apnstest.go new file mode 100644 index 0000000000..763c166f2c --- /dev/null +++ b/templates/cli/internal/apns/apnstest/apnstest.go @@ -0,0 +1,102 @@ +// Package apnstest has a scripted APNs adapter for tests. +package apnstest + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +// Adapter is a fake that records each request and returns a fresh P-256 key, +// or Err when set. +type Adapter struct { + AdapterName string + KeyID string + // TeamID is used when the request names no team, as when an account + // with one team signs in. + 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". +func (a *Adapter) Name() string { + if a.AdapterName != "" { + return a.AdapterName + } + + return "fake" +} + +// CreateKey records the request and returns a key or Err. +func (a *Adapter) CreateKey(_ context.Context, request apns.Request) (apns.Key, error) { + a.Requests = append(a.Requests, request) + if a.Err != nil { + return apns.Key{}, a.Err + } + team := request.TeamID + if team == "" { + team = a.TeamID + } + + 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. +func P8() string { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + panic(err) + } + encoded, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + panic(err) + } + + return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encoded})) +} diff --git a/templates/cli/internal/apns/appwrite/adapter.go b/templates/cli/internal/apns/appwrite/adapter.go new file mode 100644 index 0000000000..51eaef7975 --- /dev/null +++ b/templates/cli/internal/apns/appwrite/adapter.go @@ -0,0 +1,278 @@ +// Package appwrite is Appwrite's own APNs key setup, in Go with no runtime to +// install. +// +// It signs in the way the Apple Developer portal's web pages do, with the +// person's Apple ID, password and two-factor code, and calls the portal's +// private API. The sign-in follows spaceship (fastlane), which keeps up with +// Apple's changes in readable code, and the key calls follow +// @expo/apple-utils. +package appwrite + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +// Adapter creates APNs keys with Appwrite's own sign-in. +type Adapter struct { + // For tests. Zero values mean Apple's servers and the real clock. + HTTP *http.Client + Hosts Hosts + Random io.Reader + Now func() time.Time +} + +// New returns the adapter with Apple's servers. +func New() *Adapter { + return &Adapter{} +} + +// Name is how people pick this setup. +func (a *Adapter) Name() string { + return "appwrite" +} + +// CreateKey signs in, creates a team-scoped APNs key and downloads it. The +// signed-in session is kept in request.SessionDir, when set, so later runs +// skip two-factor. +func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { + portal, err := a.connect(ctx, request) + if err != nil { + return apns.Key{}, err + } + defer portal.saveSession() + + teamID, err := portal.chooseTeam(ctx, request.TeamID) + if err != nil { + return apns.Key{}, err + } + existing, err := portal.keys(ctx, teamID) + if err != nil { + return apns.Key{}, err + } + keyID, err := portal.createKey(ctx, teamID, request.Name, request.Environment) + if errors.Is(err, errMaxKeys) { + return apns.Key{}, &apns.MaxKeysError{Keys: existing} + } + if err != nil { + return apns.Key{}, err + } + p8, err := portal.downloadKey(ctx, teamID, keyID) + if err != nil { + 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, 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 { + if dir == "" { + return "" + } + + return filepath.Join(dir, "session.json") +} + +// saveSession keeps the cookies for the next run. Without a session folder +// the session lives only as long as this run. +func (p *portalClient) saveSession() { + if path := sessionPath(p.dir); path != "" { + if err := p.jar.save(path); err != nil { + p.log("Could not save the Apple sign-in, so the next run asks again: %v", err) + } + } +} + +// connect returns a signed-in portal client, reusing the saved session when +// it is still valid and belongs to the Apple ID being used. +func (a *Adapter) connect(ctx context.Context, request apns.Request) (*portalClient, error) { + if request.SessionDir != "" { + if err := os.MkdirAll(request.SessionDir, 0o700); err != nil { + return nil, err + } + } + httpClient := a.HTTP + if httpClient == nil { + httpClient = &http.Client{Timeout: time.Minute} + } + // With Reset nothing is loaded, not even the cookie that marks this + // machine as trusted, so Apple asks for a two-factor code again; the new + // session replaces the saved one. + jar := newSavedJar() + if path := sessionPath(request.SessionDir); path != "" && !request.Reset { + jar.load(path) + } + withJar := *httpClient + withJar.Jar = jar + logf := request.Log + if logf == nil { + logf = func(string, ...any) {} + } + portal := &portalClient{ + http: &withJar, jar: jar, hosts: a.Hosts, dir: request.SessionDir, asker: request.Asker, log: logf, + random: a.Random, now: a.Now, + } + if portal.hosts == (Hosts{}) { + portal.hosts = DefaultHosts + } + if portal.random == nil { + portal.random = rand.Reader + } + if portal.now == nil { + portal.now = time.Now + } + + accountName := strings.TrimSpace(request.AppleID) + 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 + } + // Another Apple ID's session: none of it applies. + jar = newSavedJar() + 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 { + return nil, err + } + + if accountName == "" { + answer, err := request.Asker.Ask("Apple ID (email)", false) + if err != nil { + return nil, err + } + accountName = strings.TrimSpace(answer) + } + password := request.Password + if password == "" { + answer, err := request.Asker.Ask("Password for "+accountName, true) + if err != nil { + return nil, err + } + password = answer + } + + if err := portal.signIn(ctx, accountName, password); err != nil { + return nil, err + } + // Saved as soon as Apple accepts the sign-in, so a failure after this + // point does not cost another two-factor code. + portal.saveSession() + session, err := portal.session(ctx) + if err != nil { + return nil, fmt.Errorf("could not reach App Store Connect: %w", err) + } + 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 +} + +// chooseTeam checks teamID against the account's teams, or picks one. +func (p *portalClient) chooseTeam(ctx context.Context, teamID string) (string, error) { + teams, err := p.teams(ctx) + if errors.Is(err, errSessionExpired) { + return "", errors.New("the Apple session expired. Run the command again to sign in") + } + if err != nil { + return "", err + } + if len(teams) == 0 { + return "", errors.New("this Apple ID belongs to no Apple Developer team. APNs keys need a paid Apple Developer Program membership") + } + if teamID != "" { + ids := make([]string, len(teams)) + for index, team := range teams { + if team.ID == teamID { + return teamID, nil + } + ids[index] = team.ID + } + + return "", fmt.Errorf("this Apple ID is not on team %s. Its teams are %s", teamID, strings.Join(ids, ", ")) + } + if len(teams) == 1 { + p.log("Using Apple team %s (%s).", teams[0].Name, teams[0].ID) + + return teams[0].ID, nil + } + labels := make([]string, len(teams)) + for index, team := range teams { + labels[index] = fmt.Sprintf("%s (%s)", team.Name, team.ID) + } + chosen, err := p.asker.Choose("Which Apple Developer team should own the key?", labels) + if err != nil { + return "", err + } + + return teams[chosen].ID, nil +} diff --git a/templates/cli/internal/apns/appwrite/adapter_test.go b/templates/cli/internal/apns/appwrite/adapter_test.go new file mode 100644 index 0000000000..27225f1c3a --- /dev/null +++ b/templates/cli/internal/apns/appwrite/adapter_test.go @@ -0,0 +1,914 @@ +package appwrite + +import ( + "bytes" + "context" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) +{% verbatim %} +const ( + testAppleID = "dev@example.com" + testPassword = "correct horse battery staple" + testCode = "123456" + testP8 = "-----BEGIN PRIVATE KEY-----\nMIGT\n-----END PRIVATE KEY-----" +) + +// fakeApple plays idmsa.apple.com, App Store Connect and the Developer +// portal: it runs the server side of SRP, so a wrong password or a wrong +// proof fails the way it would against Apple. +type fakeApple struct { + mu sync.Mutex + t *testing.T + requests []string + + widgetKeyGone bool + noTwoFactor bool + noTrustedDevices bool + olympusRefuses bool + // generation names the current signed-in cookie; bumping it expires + // every session handed out before. + generation int + textsSent int + // hashcashBits is the difficulty asked for; empty means 8. + hashcashBits string + teams []Team + keys []apns.ExistingKey + maxKeys bool + + salt []byte + b *big.Int + serverB []byte + clientA []byte + verifier *big.Int + created map[string]map[string]any + // appIDs are the team's App IDs by identifier. + appIDs map[string]*fakeAppID + // appIDPages counts the App ID list pages served. + appIDPages int +} + +type fakeAppID struct { + id string + push bool +} + +func newFakeApple(t *testing.T) *fakeApple { + return &fakeApple{ + t: t, + teams: []Team{{ID: "ABCDE12345", Name: "Example Inc", Type: "Company/Organization"}}, + salt: []byte("0123456789abcdef"), + created: map[string]map[string]any{}, + appIDs: map[string]*fakeAppID{}, + } +} + +func (f *fakeApple) record(r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + f.requests = append(f.requests, r.Method+" "+r.URL.Path) +} + +func (f *fakeApple) signedIn(r *http.Request) bool { + cookie, err := r.Cookie("myacinfo") + + return err == nil && cookie.Value == f.sessionCookie().Value +} + +func (f *fakeApple) sessionCookie() *http.Cookie { + return &http.Cookie{Name: "myacinfo", Value: fmt.Sprintf("signed-in-%d", f.generation), Path: "/"} +} + +// trusted reports whether the request carries the cookie 2sv/trust handed +// out, quoted the way Apple quotes it. +func (f *fakeApple) trusted(r *http.Request) bool { + cookie, err := r.Cookie("DES123") + + return err == nil && cookie.Value == "trusted==SRVT" && cookie.Quoted +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func (f *fakeApple) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.record(r) + body, _ := io.ReadAll(r.Body) + var params map[string]any + if strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") { + form, _ := url.ParseQuery(string(body)) + params = map[string]any{} + for name := range form { + params[name] = form.Get(name) + } + } else { + _ = json.Unmarshal(body, ¶ms) + } + idmsa := r.Header.Get("X-Apple-Widget-Key") == "widget-key" + twoFactorSession := r.Header.Get("X-Apple-Id-Session-Id") == "session-1" && r.Header.Get("scnt") == "scnt-1" + + switch { + case r.Method == http.MethodHead && r.URL.Path == "/logout": + if len(r.Cookies()) > 0 { + f.t.Error("the widget key request sent cookies, which would sign the session out") + } + if !f.widgetKeyGone { + w.Header().Set("Location", "https://idmsa.apple.com/IDMSWebAuth/signout?widgetKey=widget-key&path=/") + } + w.WriteHeader(http.StatusFound) + + case r.URL.Path == "/olympus/v1/app/config": + w.WriteHeader(http.StatusNotFound) + + case r.URL.Path == "/appleauth/auth/signin/init" && idmsa: + f.clientA, _ = base64.StdEncoding.DecodeString(params["a"].(string)) + key, _ := passwordKey(testPassword, f.salt, 1000, "s2k") + x := new(big.Int).SetBytes(sha256Of(f.salt, sha256Of([]byte(":"), key))) + f.verifier = new(big.Int).Exp(srpG, x, srpN) + f.b = big.NewInt(987654321) + B := new(big.Int).Mul(hashPadded(srpN.Bytes(), srpG.Bytes()), f.verifier) + B.Add(B, new(big.Int).Exp(srpG, f.b, srpN)) + B.Mod(B, srpN) + f.serverB = B.Bytes() + writeJSON(w, http.StatusOK, map[string]any{ + "iteration": 1000, "salt": base64.StdEncoding.EncodeToString(f.salt), "protocol": "s2k", + "b": base64.StdEncoding.EncodeToString(f.serverB), "c": "challenge-c", + }) + + case r.Method == http.MethodGet && r.URL.Path == "/appleauth/auth/signin": + bits := f.hashcashBits + if bits == "" { + bits = "8" + } + w.Header().Set("X-Apple-HC-Bits", bits) + w.Header().Set("X-Apple-HC-Challenge", "hc-challenge") + w.WriteHeader(http.StatusOK) + + case r.URL.Path == "/appleauth/auth/signin/complete" && idmsa: + token := r.Header.Get("X-Apple-HC") + if f.hashcashBits != "" && token != "" { + f.t.Errorf("answered a %s-bit hashcash: %q", f.hashcashBits, token) + } else if f.hashcashBits == "" && (!strings.Contains(token, ":hc-challenge::") || leadingZeroBits(sha1.Sum([]byte(token))) < 8) { + f.t.Errorf("hashcash = %q", token) + } + if params["c"] != "challenge-c" || params["accountName"] != testAppleID { + f.t.Errorf("complete params = %v", params) + } + // The server's side of SRP-6a: S = (A * v^u) ^ b mod N. + A := new(big.Int).SetBytes(f.clientA) + u := hashPadded(f.clientA, f.serverB) + S := new(big.Int).Exp(f.verifier, u, srpN) + S.Mul(S, A) + S.Exp(S, f.b, srpN) + K := sha256Of(S.Bytes()) + xor := new(big.Int).Xor(hashPadded(srpN.Bytes()), hashPadded(srpG.Bytes())) + expected := sha256Of(xor.Bytes(), sha256Of([]byte(testAppleID)), f.salt, f.clientA, f.serverB, K) + m1, _ := base64.StdEncoding.DecodeString(params["m1"].(string)) + if !bytes.Equal(m1, expected) { + writeJSON(w, http.StatusUnauthorized, map[string]any{"serviceErrors": []map[string]string{{"code": "-20101", "message": "Your Apple ID or password was incorrect."}}}) + + return + } + if f.noTwoFactor || f.trusted(r) { + http.SetCookie(w, f.sessionCookie()) + w.WriteHeader(http.StatusOK) + + return + } + w.Header().Set("X-Apple-Id-Session-Id", "session-1") + w.Header().Set("scnt", "scnt-1") + writeJSON(w, http.StatusConflict, map[string]any{"authType": "hsa2"}) + + case r.Method == http.MethodGet && r.URL.Path == "/appleauth/auth" && twoFactorSession: + writeJSON(w, http.StatusOK, map[string]any{ + "trustedPhoneNumbers": []map[string]any{{"id": 1, "numberWithDialCode": "+1 (•••) •••-••12", "pushMode": "sms"}}, + "securityCode": map[string]any{"length": 6}, + "noTrustedDevices": f.noTrustedDevices, + }) + + case r.URL.Path == "/appleauth/auth/verify/trusteddevice/securitycode" && twoFactorSession: + code := params["securityCode"].(map[string]any)["code"] + if code != testCode { + writeJSON(w, http.StatusBadRequest, map[string]any{"serviceErrors": []map[string]string{{"code": "-21669", "message": "Incorrect verification code."}}}) + + return + } + http.SetCookie(w, f.sessionCookie()) + w.WriteHeader(http.StatusNoContent) + + case r.Method == http.MethodPut && r.URL.Path == "/appleauth/auth/verify/phone" && twoFactorSession: + f.textsSent++ + writeJSON(w, http.StatusOK, map[string]any{}) + + case r.URL.Path == "/appleauth/auth/verify/phone/securitycode" && twoFactorSession: + phone, _ := params["phoneNumber"].(map[string]any) + code := params["securityCode"].(map[string]any)["code"] + if phone["id"] != float64(1) || params["mode"] != "sms" || code != testCode { + writeJSON(w, http.StatusBadRequest, map[string]any{"serviceErrors": []map[string]string{{"code": "-21669", "message": "Incorrect verification code."}}}) + + return + } + http.SetCookie(w, f.sessionCookie()) + w.WriteHeader(http.StatusOK) + + case r.URL.Path == "/appleauth/auth/2sv/trust" && twoFactorSession: + http.SetCookie(w, &http.Cookie{Name: "DES123", Value: "trusted==SRVT", Quoted: true, Path: "/"}) + w.WriteHeader(http.StatusNoContent) + + case r.URL.Path == "/olympus/v1/session": + if !f.signedIn(r) || f.olympusRefuses { + w.WriteHeader(http.StatusUnauthorized) + + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "user": map[string]any{"emailAddress": testAppleID}, "provider": map[string]any{"providerId": 1, "name": "Example Inc"}, + }) + + case strings.HasPrefix(r.URL.Path, "/services-account/QH65B2/"): + if !f.signedIn(r) { + w.WriteHeader(http.StatusUnauthorized) + + return + } + f.portal(w, r, strings.TrimPrefix(r.URL.Path, "/services-account/QH65B2/"), params) + + case strings.HasPrefix(r.URL.Path, "/services-account/v1/"): + if !f.signedIn(r) { + w.WriteHeader(http.StatusUnauthorized) + + return + } + f.connectAPI(w, r, strings.TrimPrefix(r.URL.Path, "/services-account/v1/"), params) + + default: + f.t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } +} + +func (f *fakeApple) portal(w http.ResponseWriter, r *http.Request, path string, params map[string]any) { + switch path { + case "account/listTeams.action": + // The real endpoint refuses a JSON body with 415. + if r.Header.Get("Content-Type") != "" || r.ContentLength > 0 { + w.WriteHeader(http.StatusUnsupportedMediaType) + + return + } + writeJSON(w, http.StatusOK, map[string]any{"resultCode": 0, "teams": f.teams}) + case "account/auth/key/list": + // Like Apple, the key list takes a form and refuses JSON with 415. + if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") { + w.WriteHeader(http.StatusUnsupportedMediaType) + + return + } + if params["teamId"] == nil || params["pageSize"] != "500" || params["sort"] != "name=asc" { + f.t.Errorf("list params = %v", params) + } + w.Header().Set("csrf", "csrf-token") + w.Header().Set("csrf_ts", "csrf-ts") + keys := []map[string]any{} + for _, key := range f.keys { + keys = append(keys, map[string]any{"keyId": key.ID, "keyName": key.Name, "canRevoke": key.CanRevoke}) + } + writeJSON(w, http.StatusOK, map[string]any{"resultCode": 0, "keys": keys}) + case "account/auth/key/v2/create": + if r.Header.Get("Content-Type") != "application/json" { + w.WriteHeader(http.StatusUnsupportedMediaType) + + return + } + if r.Header.Get("csrf") != "csrf-token" || r.Header.Get("csrf_ts") != "csrf-ts" { + f.t.Errorf("create sent csrf %q / %q", r.Header.Get("csrf"), r.Header.Get("csrf_ts")) + } + if f.maxKeys { + writeJSON(w, http.StatusOK, map[string]any{ + "resultCode": 9401, + "resultString": "You%20have%20already%20reached%20the%20maximum%20allowed%20number%20of%20team%20scoped%20Keys%20for%20this%20service%20in%20production%20and%20sandbox%20environment.", + }) + + return + } + f.created["NEWKEY1234"] = params + writeJSON(w, http.StatusOK, map[string]any{"resultCode": 0, "keys": []map[string]any{{"keyId": "NEWKEY1234", "keyName": params["name"]}}}) + case "account/auth/key/download": + if r.URL.Query().Get("keyId") != "NEWKEY1234" || r.URL.Query().Get("teamId") == "" { + f.t.Errorf("download query = %v", r.URL.Query()) + } + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte(testP8)) + default: + f.t.Errorf("unexpected portal call %s", path) + w.WriteHeader(http.StatusNotFound) + } +} + +// connectAPI plays the portal's App ID API as a web session uses it: reads +// are POSTs with X-HTTP-Method-Override: GET and the query in the body. +func (f *fakeApple) connectAPI(w http.ResponseWriter, r *http.Request, path string, params map[string]any) { + if r.Header.Get("Content-Type") != "application/vnd.api+json" || r.Header.Get("X-Requested-With") != "XMLHttpRequest" { + w.WriteHeader(http.StatusUnsupportedMediaType) + + return + } + switch { + case path == "bundleIds" && r.Method == http.MethodPost && r.Header.Get("X-HTTP-Method-Override") == http.MethodGet: + query, _ := url.ParseQuery(fmt.Sprint(params["urlEncodedQueryParams"])) + if params["teamId"] != "ABCDE12345" || query.Get("include") != "bundleIdCapabilities" { + f.t.Errorf("list App IDs params = %v", params) + } + data := []map[string]any{} + included := []map[string]any{} + // Like Apple, the filter matches by prefix, and the list comes in + // pages of limit, in ID order, with a cursor to the next. + var matching []string + for identifier := range f.appIDs { + if strings.HasPrefix(identifier, query.Get("filter[identifier]")) { + matching = append(matching, identifier) + } + } + sort.Slice(matching, func(i, j int) bool { return f.appIDs[matching[i]].id < f.appIDs[matching[j]].id }) + limit, _ := strconv.Atoi(query.Get("limit")) + offset, _ := strconv.Atoi(query.Get("cursor")) + page := matching[min(offset, len(matching)):min(offset+limit, len(matching))] + f.appIDPages++ + for _, identifier := range page { + appID := f.appIDs[identifier] + capabilities := []map[string]any{{"type": "bundleIdCapabilities", "id": appID.id + "_IN_APP_PURCHASE"}} + included = append(included, map[string]any{"type": "bundleIdCapabilities", "id": appID.id + "_IN_APP_PURCHASE", "attributes": map[string]any{"capabilityType": "IN_APP_PURCHASE"}}) + if appID.push { + capabilities = append(capabilities, map[string]any{"type": "bundleIdCapabilities", "id": appID.id + "_PUSH_NOTIFICATIONS"}) + included = append(included, map[string]any{"type": "bundleIdCapabilities", "id": appID.id + "_PUSH_NOTIFICATIONS", "attributes": map[string]any{"capabilityType": "PUSH_NOTIFICATIONS"}}) + } + data = append(data, map[string]any{ + "type": "bundleIds", + "id": appID.id, + "attributes": map[string]any{"identifier": identifier, "seedId": "ABCDE12345", "platform": "IOS"}, + "relationships": map[string]any{"bundleIdCapabilities": map[string]any{"data": capabilities}}, + }) + } + document := map[string]any{"data": data, "included": included} + if offset+limit < len(matching) { + document["links"] = map[string]any{"next": fmt.Sprintf("https://developer.apple.com/services-account/v1/bundleIds?cursor=%d&limit=%d", offset+limit, limit)} + } + writeJSON(w, http.StatusOK, document) + case path == "bundleIds" && r.Method == http.MethodPost: + data, _ := params["data"].(map[string]any) + attributes, _ := data["attributes"].(map[string]any) + identifier, _ := attributes["identifier"].(string) + name, _ := attributes["name"].(string) + if attributes["teamId"] != "ABCDE12345" || attributes["platform"] != "IOS" || identifier == "" || strings.ContainsAny(name, ".-_") { + writeJSON(w, http.StatusConflict, map[string]any{"errors": []map[string]any{{"status": "409", "detail": "An attribute value is invalid."}}}) + + return + } + appID := &fakeAppID{id: "APPID" + fmt.Sprint(len(f.appIDs)+1)} + f.appIDs[identifier] = appID + writeJSON(w, http.StatusCreated, map[string]any{"data": map[string]any{"type": "bundleIds", "id": appID.id, "attributes": attributes}}) + case strings.HasPrefix(path, "bundleIds/") && r.Method == http.MethodPatch: + encoded, _ := json.Marshal(params) + for _, appID := range f.appIDs { + if path == "bundleIds/"+appID.id && strings.Contains(string(encoded), `"id":"PUSH_NOTIFICATIONS"`) && strings.Contains(string(encoded), `"enabled":true`) && strings.Contains(string(encoded), `"teamId":"ABCDE12345"`) { + appID.push = true + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"type": "bundleIds", "id": appID.id}}) + + return + } + } + f.t.Errorf("unexpected App ID update %s %s", path, encoded) + w.WriteHeader(http.StatusNotFound) + default: + f.t.Errorf("unexpected App ID call %s %s", r.Method, path) + w.WriteHeader(http.StatusNotFound) + } +} + +type scriptedAsker struct { + answers map[string][]string + choices map[string]int + asked []string +} + +func (s *scriptedAsker) Ask(question string, secret bool) (string, error) { + s.asked = append(s.asked, question) + for prefix, answers := range s.answers { + if strings.HasPrefix(question, prefix) && len(answers) > 0 { + s.answers[prefix] = answers[1:] + + return answers[0], nil + } + } + + return "", errors.New("no answer for " + question) +} + +func (s *scriptedAsker) Choose(question string, options []string) (int, error) { + s.asked = append(s.asked, question) + if index, ok := s.choices[question]; ok { + return index, nil + } + + return 0, errors.New("no choice for " + question) +} + +// testClient runs the adapter against the fake with a fixed request, the way +// the CLI does. +type testClient struct { + adapter *Adapter + request apns.Request +} + +func (c testClient) CreateKey(ctx context.Context, teamID, name string, environment apns.Environment) (apns.Key, error) { + request := c.request + request.TeamID, request.Name, request.Environment = teamID, name, environment + + return c.adapter.CreateKey(ctx, request) +} + +// newTestClient points the adapter at fake. credentials holds the Apple ID +// and password the CLI would pass from APPWRITE_APPLE_ID and +// APPWRITE_APPLE_PASSWORD. +func newTestClient(t *testing.T, fake *fakeApple, asker *scriptedAsker, dir string, credentials map[string]string) (testClient, *[]string) { + server := httptest.NewServer(fake) + t.Cleanup(server.Close) + logged := &[]string{} + + return testClient{ + adapter: &Adapter{ + HTTP: server.Client(), + Hosts: Hosts{Idmsa: server.URL, AppStoreConnect: server.URL, Portal: server.URL}, + Now: func() time.Time { return time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC) }, + }, + request: apns.Request{ + AppleID: credentials["APPWRITE_APPLE_ID"], + Password: credentials["APPWRITE_APPLE_PASSWORD"], + SessionDir: dir, + Asker: asker, + Log: func(format string, args ...any) { + *logged = append(*logged, strings.TrimSpace(fmt.Sprintf(format, args...))) + }, + }, + }, logged +} + +func TestCreateKeySignsInWithTwoFactorAndReusesTheSession(t *testing.T) { + fake := newFakeApple(t) + dir := t.TempDir() + asker := &scriptedAsker{answers: map[string][]string{ + "Apple ID": {testAppleID}, + "Password for": {testPassword}, + "Enter the 6-di": {"000000", testCode}, + }} + client, logged := newTestClient(t, fake, asker, dir, nil) + + key, err := client.CreateKey(context.Background(), "", "Appwrite Push", apns.EnvironmentAll) + if err != nil { + t.Fatal(err) + } + if key.KeyID != "NEWKEY1234" || key.TeamID != "ABCDE12345" || key.P8 != testP8 || key.AppleID != testAppleID { + t.Errorf("key = %+v", key) + } + created := fake.created["NEWKEY1234"] + configurations, _ := json.Marshal(created["serviceConfigurations"]) + requests, _ := json.Marshal(created["serviceConfigurationsRequests"]) + if created["teamId"] != "ABCDE12345" || created["name"] != "Appwrite Push" || created["scope"] != "team" || + string(configurations) != `{"U27F4V844T":[]}` || + string(requests) != `[{"environment":"all","identifiers":{},"isNew":true,"scope":"team","serviceId":"U27F4V844T"}]` { + t.Errorf("create params = %v", created) + } + if strings.Join(asker.asked, "|") != "Apple ID (email)|Password for "+testAppleID+"|Enter the 6-digit code shown on your Apple device (or type sms to get a text message)|Enter the 6-digit code shown on your Apple device (or type sms to get a text message)" { + t.Errorf("asked %v", asker.asked) + } + if !contains(*logged, "That code is not right. Try again.") || !contains(*logged, "Using Apple team Example Inc (ABCDE12345).") { + t.Errorf("logged %v", *logged) + } + info, err := os.Stat(filepath.Join(dir, "session.json")) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("session file = %v, %v", info, err) + } + + // A second run reuses the trusted session: no questions, no sign-in. + again := &scriptedAsker{} + second, logged := newTestClient(t, fake, again, dir, nil) + fake.requests = nil + if _, err := second.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push 2", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if len(again.asked) != 0 { + t.Errorf("asked %v", again.asked) + } + for _, request := range fake.requests { + if strings.Contains(request, "/appleauth/") { + t.Errorf("signed in again: %v", fake.requests) + + break + } + } + if !contains(*logged, "Using the saved Apple sign-in for "+testAppleID+".") { + t.Errorf("logged %v", *logged) + } +} + +func TestCreateKeyUsesTheCredentialsInTheRequest(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + asker := &scriptedAsker{} + client, logged := newTestClient(t, fake, asker, t.TempDir(), map[string]string{ + "APPWRITE_APPLE_ID": testAppleID, + "APPWRITE_APPLE_PASSWORD": testPassword, + }) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if len(asker.asked) != 0 { + t.Errorf("asked %v", asker.asked) + } + for _, line := range *logged { + if strings.Contains(line, testPassword) { + t.Errorf("logged the password: %v", *logged) + } + } +} + +func TestCreateKeyRejectsAWrongPassword(t *testing.T) { + fake := newFakeApple(t) + dir := t.TempDir() + asker := &scriptedAsker{answers: map[string][]string{"Apple ID": {testAppleID}, "Password for": {"wrong"}}} + client, _ := newTestClient(t, fake, asker, dir, nil) + + if _, err := client.CreateKey(context.Background(), "", "Appwrite Push", apns.EnvironmentAll); !errors.Is(err, apns.ErrInvalidCredentials) { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "session.json")); err == nil { + t.Error("saved a session after a failed sign-in") + } +} + +func TestCreateKeyReportsAFullTeam(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + fake.maxKeys = true + fake.keys = []apns.ExistingKey{{ID: "OLDKEY1234", Name: "Firebase", CanRevoke: true}, {ID: "OLDKEY5678", Name: "Expo", CanRevoke: true}} + client, _ := newTestClient(t, fake, &scriptedAsker{}, t.TempDir(), map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + + _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll) + var maxKeys *apns.MaxKeysError + if !errors.As(err, &maxKeys) || len(maxKeys.Keys) != 2 || maxKeys.Keys[0].ID != "OLDKEY1234" || maxKeys.Keys[1].Name != "Expo" { + t.Fatalf("err = %v", err) + } + if len(fake.created) != 0 { + t.Errorf("created %v", fake.created) + } +} + +func TestCreateKeyChoosesAmongTeams(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + fake.teams = []Team{{ID: "AAAAA11111", Name: "Personal"}, {ID: "ABCDE12345", Name: "Example Inc"}} + env := map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword} + asker := &scriptedAsker{choices: map[string]int{"Which Apple Developer team should own the key?": 1}} + client, _ := newTestClient(t, fake, asker, t.TempDir(), env) + + key, err := client.CreateKey(context.Background(), "", "Appwrite Push", apns.EnvironmentAll) + if err != nil || key.TeamID != "ABCDE12345" { + t.Fatalf("key = %+v, %v", key, err) + } + + other, _ := newTestClient(t, fake, &scriptedAsker{}, t.TempDir(), env) + if _, err := other.CreateKey(context.Background(), "ZZZZZ99999", "Appwrite Push", apns.EnvironmentAll); err == nil || + !strings.Contains(err.Error(), "AAAAA11111, ABCDE12345") { + t.Errorf("err = %v", err) + } +} + +func TestWidgetKeyFallsBackToTheSavedCopy(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + fake.widgetKeyGone = true + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "widget-key"), []byte("widget-key\n"), 0o600); err != nil { + t.Fatal(err) + } + client, _ := newTestClient(t, fake, &scriptedAsker{}, dir, map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + + empty, _ := newTestClient(t, fake, &scriptedAsker{}, t.TempDir(), map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + if _, err := empty.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err == nil || + !strings.Contains(err.Error(), "sign-in key") { + t.Errorf("err = %v", err) + } +} + +func contains(lines []string, line string) bool { + for _, candidate := range lines { + if candidate == line { + return true + } + } + + return false +} + +func TestCreateKeyWithATextMessageCode(t *testing.T) { + fake := newFakeApple(t) + fake.noTrustedDevices = true + asker := &scriptedAsker{answers: map[string][]string{"Enter the 6-digit code sent to": {testCode}}} + client, _ := newTestClient(t, fake, asker, t.TempDir(), map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if strings.Join(asker.asked, "|") != "Enter the 6-digit code sent to +1 (•••) •••-••12" { + t.Errorf("asked %v", asker.asked) + } + requests := strings.Join(fake.requests, ",") + // With no trusted devices and one phone, Apple has already texted the code. + if !strings.Contains(requests, "POST /appleauth/auth/verify/phone/securitycode") || strings.Contains(requests, "PUT /appleauth/auth/verify/phone,") { + t.Errorf("requests = %v", fake.requests) + } +} + +func TestTheSignInIsSavedAsSoonAsTheCodeIsAccepted(t *testing.T) { + fake := newFakeApple(t) + fake.olympusRefuses = true + dir := t.TempDir() + env := map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword} + client, _ := newTestClient(t, fake, &scriptedAsker{answers: map[string][]string{"Enter the 6-di": {testCode}}}, dir, env) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err == nil || + !strings.Contains(err.Error(), "refused the session") { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "session.json")); err != nil { + t.Fatalf("the accepted sign-in was not saved: %v", err) + } + + fake.olympusRefuses = false + again := &scriptedAsker{} + second, _ := newTestClient(t, fake, again, dir, env) + if _, err := second.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if len(again.asked) != 0 { + t.Errorf("asked %v", again.asked) + } +} + +func TestAnExpiredSessionSignsInAgainWithoutACode(t *testing.T) { + fake := newFakeApple(t) + dir := t.TempDir() + env := map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword} + first, _ := newTestClient(t, fake, &scriptedAsker{answers: map[string][]string{"Enter the 6-di": {testCode}}}, dir, env) + if _, err := first.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + + fake.generation++ + fake.requests = nil + again := &scriptedAsker{} + second, _ := newTestClient(t, fake, again, dir, env) + if _, err := second.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if len(again.asked) != 0 { + t.Errorf("asked %v", again.asked) + } + requests := strings.Join(fake.requests, ",") + if !strings.Contains(requests, "/appleauth/auth/signin/complete") || strings.Contains(requests, "/securitycode") { + t.Errorf("requests = %v", fake.requests) + } +} + +func TestCreateKeyForOneEnvironment(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + env := map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword} + client, _ := newTestClient(t, fake, &scriptedAsker{}, t.TempDir(), env) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentSandbox); err != nil { + t.Fatal(err) + } + requests := fake.created["NEWKEY1234"]["serviceConfigurationsRequests"].([]any) + if environment := requests[0].(map[string]any)["environment"]; environment != "sandbox" { + t.Errorf("environment = %v", environment) + } + +} + +func TestChoosingATextMessageIsNotACodeAttempt(t *testing.T) { + fake := newFakeApple(t) + asker := &scriptedAsker{answers: map[string][]string{ + "Enter the 6-digit code shown": {"sms"}, + "Enter the 6-digit code sent to": {"000000", "111111", testCode}, + }} + client, _ := newTestClient(t, fake, asker, t.TempDir(), map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatalf("the third code was not accepted: %v", err) + } + if fake.textsSent != 1 || len(asker.asked) != 4 { + t.Errorf("texts = %d, asked %v", fake.textsSent, asker.asked) + } +} + +func TestAnImpracticalHashcashIsNotAnswered(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + fake.hashcashBits = "40" + client, _ := newTestClient(t, fake, &scriptedAsker{}, t.TempDir(), map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } +} + +func TestMaxKeysMessages(t *testing.T) { + for message, full := range map[string]bool{ + "You have already reached the maximum allowed number of Keys for this service.": true, + "You have already reached the maximum allowed number of team scoped Keys for this service in production and sandbox environment.": true, + "An unexpected error occurred.": false, + } { + if got := maxKeysMessage.MatchString(message); got != full { + t.Errorf("maxKeysMessage(%q) = %v", message, got) + } + } +} + +func TestResetSignsInAgain(t *testing.T) { + fake := newFakeApple(t) + dir := t.TempDir() + env := map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword} + first, _ := newTestClient(t, fake, &scriptedAsker{answers: map[string][]string{"Enter the 6-di": {testCode}}}, dir, env) + if _, err := first.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + + // The saved session and its trusted-machine cookie are ignored: Apple + // asks for the password check and a code again. + asker := &scriptedAsker{answers: map[string][]string{"Enter the 6-di": {testCode}}} + second, _ := newTestClient(t, fake, asker, dir, env) + second.request.Reset = true + fake.requests = nil + if _, err := second.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + requests := strings.Join(fake.requests, ",") + if !strings.Contains(requests, "/appleauth/auth/signin/complete") || !strings.Contains(requests, "/securitycode") || len(asker.asked) != 1 { + t.Errorf("asked %v, requests = %v", asker.asked, fake.requests) + } + + // The new session replaces the saved one: a normal run reuses it. + third, _ := newTestClient(t, fake, &scriptedAsker{}, dir, env) + fake.requests = nil + if _, err := third.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if strings.Contains(strings.Join(fake.requests, ","), "/appleauth/") { + t.Errorf("signed in again after a reset: %v", fake.requests) + } +} + +// prepareAppID signs in with the saved session of a first run, then prepares +// com.example.app on the fake. +func prepareAppID(t *testing.T, fake *fakeApple, asker *scriptedAsker, ask bool) (apns.AppIDResult, error) { + t.Helper() + dir := t.TempDir() + env := map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword} + first, _ := newTestClient(t, fake, &scriptedAsker{answers: map[string][]string{"Enter the 6-di": {testCode}}}, dir, env) + if _, err := first.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + client, _ := newTestClient(t, fake, asker, dir, env) + client.request.TeamID = "ABCDE12345" + fake.requests = nil + + return client.adapter.PrepareAppID(context.Background(), client.request, apns.AppIDRequest{BundleID: "com.example.app", Ask: ask}) +} + +const registerQuestion = "com.example.app is not registered as an App ID on team ABCDE12345, so APNs refuses pushes for it. Register it with Push Notifications?" + +func TestPrepareAppIDRegistersAMissingAppIDWithPush(t *testing.T) { + fake := newFakeApple(t) + // Only a longer identifier exists, which the portal's prefix filter also returns. + fake.appIDs["com.example.app.widget"] = &fakeAppID{id: "WIDGET", push: true} + asker := &scriptedAsker{choices: map[string]int{registerQuestion: 0}} + + result, err := prepareAppID(t, fake, asker, true) + if err != nil { + t.Fatal(err) + } + if !result.Created || !result.PushTurnedOn || !result.Registered || !result.Push || result.TeamID != "ABCDE12345" { + t.Errorf("result = %+v", result) + } + appID := fake.appIDs["com.example.app"] + if appID == nil || !appID.push { + t.Fatalf("App ID = %+v", appID) + } + // One question covers registering it with push. + if strings.Join(asker.asked, "|") != registerQuestion { + t.Errorf("asked %v", asker.asked) + } +} + +func TestPrepareAppIDTurnsOnPush(t *testing.T) { + fake := newFakeApple(t) + fake.appIDs["com.example.app"] = &fakeAppID{id: "EXISTING"} + asker := &scriptedAsker{} + + result, err := prepareAppID(t, fake, asker, false) + if err != nil { + t.Fatal(err) + } + if result.Created || !result.PushTurnedOn || !result.Push || !fake.appIDs["com.example.app"].push { + t.Errorf("result = %+v", result) + } + if len(asker.asked) != 0 || contains(fake.requests, "POST /services-account/v1/bundleIds/EXISTING") { + t.Errorf("asked %v, requests %v", asker.asked, fake.requests) + } +} + +func TestPrepareAppIDLeavesAReadyAppIDAlone(t *testing.T) { + fake := newFakeApple(t) + fake.appIDs["com.example.app"] = &fakeAppID{id: "READY", push: true} + + result, err := prepareAppID(t, fake, &scriptedAsker{}, true) + if err != nil { + t.Fatal(err) + } + if result.Created || result.PushTurnedOn || !result.Registered || !result.Push { + t.Errorf("result = %+v", result) + } + if contains(fake.requests, "PATCH /services-account/v1/bundleIds/READY") { + t.Errorf("changed a ready App ID: %v", fake.requests) + } +} + +func TestPrepareAppIDChangesNothingWhenDeclined(t *testing.T) { + fake := newFakeApple(t) + asker := &scriptedAsker{choices: map[string]int{registerQuestion: 1}} + + result, err := prepareAppID(t, fake, asker, true) + if err != nil { + t.Fatal(err) + } + if result.Registered || result.Created || len(fake.appIDs) != 0 { + t.Errorf("result = %+v, App IDs %v", result, fake.appIDs) + } +} + +func TestPrepareAppIDWithoutASavedSignInAsksNothing(t *testing.T) { + fake := newFakeApple(t) + asker := &scriptedAsker{} + client, _ := newTestClient(t, fake, asker, t.TempDir(), nil) + client.request.SavedSignInOnly = true + + _, err := client.adapter.PrepareAppID(context.Background(), client.request, apns.AppIDRequest{BundleID: "com.example.app", Ask: true}) + if !errors.Is(err, apns.ErrSignInNeeded) { + t.Fatalf("err = %v", err) + } + if len(asker.asked) != 0 || strings.Contains(strings.Join(fake.requests, ","), "/appleauth/") { + t.Errorf("asked %v, requests %v", asker.asked, fake.requests) + } +} +func TestPrepareAppIDFindsTheAppIDOnALaterPage(t *testing.T) { + fake := newFakeApple(t) + // More longer identifiers than one page, listed before the exact one. + for index := range 250 { + fake.appIDs[fmt.Sprintf("com.example.app.widget%03d", index)] = &fakeAppID{id: fmt.Sprintf("A%03d", index)} + } + fake.appIDs["com.example.app"] = &fakeAppID{id: "Z", push: true} + + result, err := prepareAppID(t, fake, &scriptedAsker{}, true) + if err != nil { + t.Fatal(err) + } + if result.Created || !result.Registered || !result.Push || fake.appIDPages != 2 { + t.Errorf("result = %+v after %d pages", result, fake.appIDPages) + } +} +{% endverbatim %} \ No newline at end of file diff --git a/templates/cli/internal/apns/appwrite/appid.go b/templates/cli/internal/apns/appwrite/appid.go new file mode 100644 index 0000000000..ee586a24f4 --- /dev/null +++ b/templates/cli/internal/apns/appwrite/appid.go @@ -0,0 +1,268 @@ +package appwrite + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" +) +{% verbatim %} +// App IDs live behind the portal's App Store Connect style API under +// services-account/v1, which takes the same cookie session as the key calls. +// With a web session, as fastlane's spaceship uses it, reads are POSTs that +// carry X-HTTP-Method-Override: GET and the query in the body, and every +// write carries the team in its attributes. + +const ( + pushCapability = "PUSH_NOTIFICATIONS" + appIDsPageSize = 200 + // maxAppIDPages bounds the lookup should the portal keep handing out + // next pages. + maxAppIDPages = 100 +) + +// AppID is a registered App ID and whether it can receive pushes. +type AppID struct { + ID string + Identifier string + SeedID string + Push bool +} + +type jsonAPIResource struct { + Type string `json:"type"` + ID string `json:"id"` + Attributes json.RawMessage `json:"attributes"` + Relationships map[string]struct { + Data json.RawMessage `json:"data"` + } `json:"relationships"` +} + +type jsonAPIDocument struct { + Data json.RawMessage `json:"data"` + Included []jsonAPIResource `json:"included"` + // Links.Next is the next page of a list, as a URL whose query carries + // the cursor. + Links struct { + Next string `json:"next"` + } `json:"links"` + Errors []struct { + Status string `json:"status"` + Code string `json:"code"` + Title string `json:"title"` + Detail string `json:"detail"` + } `json:"errors"` +} + +// connectAPI calls services-account/v1/. A GET goes out as a POST with +// the query in the body; other methods send body as JSON:API. +func (p *portalClient) connectAPI(ctx context.Context, method, path, teamID string, query url.Values, body map[string]any) (*jsonAPIDocument, error) { + target := p.hosts.Portal + "/services-account/v1/" + path + headers := map[string]string{ + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/vnd.api+json", + "X-Requested-With": "XMLHttpRequest", + } + if p.csrf != "" || p.csrfTS != "" { + headers["csrf"] = p.csrf + headers["csrf_ts"] = p.csrfTS + } + var payload any + switch method { + case http.MethodGet: + headers["X-HTTP-Method-Override"] = http.MethodGet + method = http.MethodPost + payload = map[string]any{"urlEncodedQueryParams": query.Encode(), "teamId": teamID} + default: + payload = body + } + + response, raw, err := p.request(ctx, method, target, payload, headers) + if err != nil { + return nil, fmt.Errorf("could not reach the Apple Developer portal: %w", err) + } + if token := response.Header.Get("csrf"); token != "" { + p.csrf, p.csrfTS = token, response.Header.Get("csrf_ts") + } + if response.StatusCode == http.StatusUnauthorized { + return nil, errSessionExpired + } + var document jsonAPIDocument + if len(raw) > 0 { + if err := json.Unmarshal(raw, &document); err != nil && response.StatusCode < 300 { + return nil, fmt.Errorf("the Apple Developer portal sent an unreadable response: %w", err) + } + } + if response.StatusCode >= 300 { + for _, problem := range document.Errors { + if detail := strings.TrimSpace(problem.Detail); detail != "" { + return nil, fmt.Errorf("the Apple Developer portal refused the request: %s", detail) + } + } + + return nil, fmt.Errorf("the Apple Developer portal returned HTTP %d for %s", response.StatusCode, path) + } + + return &document, nil +} + +// appID looks up identifier among the team's App IDs. It returns nil when +// the team has none with that identifier. The portal's filter also returns +// longer identifiers that start with it, so every page is read until the +// exact one turns up. +func (p *portalClient) appID(ctx context.Context, teamID, identifier string) (*AppID, error) { + query := url.Values{} + query.Set("filter[identifier]", identifier) + query.Set("include", "bundleIdCapabilities") + query.Set("limit", strconv.Itoa(appIDsPageSize)) + for page := 0; page < maxAppIDPages; page++ { + document, err := p.connectAPI(ctx, http.MethodGet, "bundleIds", teamID, query, nil) + if err != nil { + return nil, err + } + if found, err := exactAppID(document, identifier); found != nil || err != nil { + return found, err + } + if document.Links.Next == "" { + return nil, nil + } + next, err := url.Parse(document.Links.Next) + if err != nil || next.Query().Get("cursor") == "" { + return nil, fmt.Errorf("the Apple Developer portal sent an unreadable next page link %q", document.Links.Next) + } + query.Set("cursor", next.Query().Get("cursor")) + } + + return nil, fmt.Errorf("could not find %s within %d pages of App IDs", identifier, maxAppIDPages) +} + +// exactAppID returns the App ID with exactly identifier on one page of the +// list, or nil. +func exactAppID(document *jsonAPIDocument, identifier string) (*AppID, error) { + var resources []jsonAPIResource + if err := json.Unmarshal(document.Data, &resources); err != nil { + return nil, fmt.Errorf("the Apple Developer portal sent an unreadable App ID list: %w", err) + } + for _, resource := range resources { + var attributes struct { + Identifier string `json:"identifier"` + SeedID string `json:"seedId"` + } + if err := json.Unmarshal(resource.Attributes, &attributes); err != nil { + continue + } + // The filter also matches by prefix: only the exact identifier counts. + if attributes.Identifier != identifier { + continue + } + + return &AppID{ + ID: resource.ID, + Identifier: attributes.Identifier, + SeedID: attributes.SeedID, + Push: hasPush(resource, document.Included), + }, nil + } + + return nil, nil +} + +// hasPush reports whether the App ID has the Push Notifications capability +// among its included capabilities. +func hasPush(appID jsonAPIResource, included []jsonAPIResource) bool { + var linked []struct { + ID string `json:"id"` + } + if relationship, ok := appID.Relationships["bundleIdCapabilities"]; ok { + _ = json.Unmarshal(relationship.Data, &linked) + } + ids := map[string]bool{} + for _, capability := range linked { + ids[capability.ID] = true + } + for _, resource := range included { + if resource.Type != "bundleIdCapabilities" || !ids[resource.ID] { + continue + } + var attributes struct { + CapabilityType string `json:"capabilityType"` + Enabled *bool `json:"enabled"` + } + if err := json.Unmarshal(resource.Attributes, &attributes); err != nil { + continue + } + if attributes.CapabilityType == pushCapability && (attributes.Enabled == nil || *attributes.Enabled) { + return true + } + } + + return false +} + +// createAppID registers identifier as an explicit iOS App ID. +func (p *portalClient) createAppID(ctx context.Context, teamID, identifier, name string) (*AppID, error) { + document, err := p.connectAPI(ctx, http.MethodPost, "bundleIds", teamID, nil, map[string]any{ + "data": map[string]any{ + "type": "bundleIds", + "attributes": map[string]any{ + "identifier": identifier, + "name": name, + "platform": "IOS", + "seedId": teamID, + "teamId": teamID, + }, + }, + }) + if err != nil { + return nil, err + } + var resource jsonAPIResource + if err := json.Unmarshal(document.Data, &resource); err != nil || resource.ID == "" { + return nil, errors.New("the Apple Developer portal registered no App ID") + } + + return &AppID{ID: resource.ID, Identifier: identifier, SeedID: teamID}, nil +} + +// enablePush turns on the Push Notifications capability of an App ID. +func (p *portalClient) enablePush(ctx context.Context, teamID string, appID *AppID) error { + seedID := appID.SeedID + if seedID == "" { + seedID = teamID + } + _, err := p.connectAPI(ctx, http.MethodPatch, "bundleIds/"+appID.ID, teamID, nil, map[string]any{ + "data": map[string]any{ + "type": "bundleIds", + "id": appID.ID, + "attributes": map[string]any{ + "permissions": map[string]bool{"edit": true, "delete": true}, + "seedId": seedID, + "teamId": teamID, + }, + "relationships": map[string]any{ + "bundleIdCapabilities": map[string]any{ + "data": []map[string]any{{ + "type": "bundleIdCapabilities", + "attributes": map[string]any{"enabled": true, "settings": []any{}}, + "relationships": map[string]any{ + "capability": map[string]any{ + "data": map[string]string{"type": "capabilities", "id": pushCapability}, + }, + }, + }}, + }, + }, + }, + }) + if err != nil { + return err + } + appID.Push = true + + return nil +} +{% endverbatim %} \ No newline at end of file diff --git a/templates/cli/internal/apns/appwrite/auth.go b/templates/cli/internal/apns/appwrite/auth.go new file mode 100644 index 0000000000..dbd974f07a --- /dev/null +++ b/templates/cli/internal/apns/appwrite/auth.go @@ -0,0 +1,510 @@ +package appwrite + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +// The sign-in follows spaceship (fastlane), which keeps up with Apple's +// changes in readable code: the widget key from the sign out redirect, SRP +// against idmsa.apple.com, hashcash, then two-factor and a trusted session. + +// Hosts are the Apple servers the client talks to. Tests point them at a fake. +type Hosts struct { + Idmsa string + AppStoreConnect string + Portal string +} + +// DefaultHosts are Apple's production servers. +var DefaultHosts = Hosts{ + Idmsa: "https://idmsa.apple.com", + AppStoreConnect: "https://appstoreconnect.apple.com", + Portal: "https://developer.apple.com", +} + +type portalClient struct { + http *http.Client + jar *savedJar + hosts Hosts + dir string + asker apns.Asker + log func(string, ...any) + random io.Reader + now func() time.Time + + // appleID is the Apple ID of the signed-in session. + appleID string + + widgetKey string + sessionID string + scnt string + csrf string + csrfTS string +} + +type olympusSession struct { + User struct { + EmailAddress string `json:"emailAddress"` + } `json:"user"` + Provider *struct { + ProviderID int64 `json:"providerId"` + Name string `json:"name"` + } `json:"provider"` +} + +type serviceErrors struct { + ServiceErrors []struct { + Code string `json:"code"` + Title string `json:"title"` + Message string `json:"message"` + } `json:"serviceErrors"` +} + +func (e serviceErrors) message() string { + for _, failure := range e.ServiceErrors { + if failure.Message != "" { + return failure.Message + } + if failure.Title != "" { + return failure.Title + } + } + + return "" +} + +func (e serviceErrors) has(code string) bool { + for _, failure := range e.ServiceErrors { + if failure.Code == code { + return true + } + } + + return false +} + +// request sends one request and reads the whole response. +func (p *portalClient) request(ctx context.Context, method, target string, body any, headers map[string]string) (*http.Response, []byte, error) { + var reader io.Reader + contentType := "" + switch value := body.(type) { + case nil: + case url.Values: + reader = strings.NewReader(value.Encode()) + contentType = "application/x-www-form-urlencoded" + default: + encoded, err := json.Marshal(body) + if err != nil { + return nil, nil, err + } + reader = bytes.NewReader(encoded) + contentType = "application/json" + } + request, err := http.NewRequestWithContext(ctx, method, target, reader) + if err != nil { + return nil, nil, err + } + request.Header.Set("User-Agent", "Appwrite CLI") + if contentType != "" { + request.Header.Set("Content-Type", contentType) + } + for name, value := range headers { + request.Header.Set(name, value) + } + response, err := p.http.Do(request) + if err != nil { + return nil, nil, err + } + defer response.Body.Close() + payload, err := io.ReadAll(response.Body) + if err != nil { + return nil, nil, err + } + + return response, payload, nil +} + +// idmsaHeaders are sent with every sign-in request. After Apple asks for +// two-factor, they also carry the session it handed out. +func (p *portalClient) idmsaHeaders() map[string]string { + headers := map[string]string{ + "Accept": "application/json, text/javascript", + "X-Requested-With": "XMLHttpRequest", + "X-Apple-Widget-Key": p.widgetKey, + } + if p.sessionID != "" { + headers["Accept"] = "application/json" + headers["X-Apple-Id-Session-Id"] = p.sessionID + headers["scnt"] = p.scnt + } + + return headers +} + +// session asks App Store Connect who is signed in. It returns nil when the +// cookies do not hold a usable session. +func (p *portalClient) session(ctx context.Context) (*olympusSession, error) { + response, payload, err := p.request(ctx, http.MethodGet, p.hosts.AppStoreConnect+"/olympus/v1/session", nil, + map[string]string{"Accept": "application/json"}) + if err != nil { + return nil, err + } + if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { + return nil, nil + } + var session olympusSession + if response.StatusCode != http.StatusOK || json.Unmarshal(payload, &session) != nil || session.Provider == nil { + return nil, nil + } + + return &session, nil +} + +// loadWidgetKey reads the key App Store Connect's web app signs in with from +// the sign out redirect, then from the copy saved by an earlier run, then from +// the endpoint Apple used to document (removed in September 2026). +func (p *portalClient) loadWidgetKey(ctx context.Context) error { + // Without a session folder there is nowhere to keep a copy. + cache := "" + if p.dir != "" { + cache = filepath.Join(p.dir, "widget-key") + } + if key := p.widgetKeyFromSignOut(ctx); key != "" { + p.widgetKey = key + p.cacheWidgetKey(cache) + + return nil + } + if cached, err := os.ReadFile(cache); cache != "" && err == nil && len(bytes.TrimSpace(cached)) > 0 { + p.widgetKey = string(bytes.TrimSpace(cached)) + + return nil + } + + response, payload, err := p.request(ctx, http.MethodGet, + p.hosts.AppStoreConnect+"/olympus/v1/app/config?hostname=itunesconnect.apple.com", nil, + map[string]string{"Accept": "application/json"}) + if err != nil { + return fmt.Errorf("could not reach App Store Connect: %w", err) + } + var config struct { + AuthServiceKey string `json:"authServiceKey"` + } + if response.StatusCode != http.StatusOK || json.Unmarshal(payload, &config) != nil || config.AuthServiceKey == "" { + return fmt.Errorf("could not read App Store Connect's sign-in key (HTTP %d)", response.StatusCode) + } + p.widgetKey = config.AuthServiceKey + p.cacheWidgetKey(cache) + + return nil +} + +func (p *portalClient) cacheWidgetKey(cache string) { + if cache != "" { + _ = os.WriteFile(cache, []byte(p.widgetKey), 0o600) + } +} + +// widgetKeyFromSignOut must neither send the session cookies nor follow the +// redirect: following it signs the session out. +func (p *portalClient) widgetKeyFromSignOut(ctx context.Context) string { + bare := &http.Client{ + Transport: p.http.Transport, + Timeout: p.http.Timeout, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + request, err := http.NewRequestWithContext(ctx, http.MethodHead, p.hosts.AppStoreConnect+"/logout", nil) + if err != nil { + return "" + } + request.Header.Set("User-Agent", "Appwrite CLI") + response, err := bare.Do(request) + if err != nil { + return "" + } + response.Body.Close() + location, err := url.Parse(response.Header.Get("Location")) + if err != nil { + return "" + } + + return location.Query().Get("widgetKey") +} + +// signIn runs the SRP sign-in and, when Apple asks for it, two-factor. +func (p *portalClient) signIn(ctx context.Context, accountName, password string) error { + client, err := newSRPClient(p.random) + if err != nil { + return err + } + response, payload, err := p.request(ctx, http.MethodPost, p.hosts.Idmsa+"/appleauth/auth/signin/init", map[string]any{ + "a": base64.StdEncoding.EncodeToString(client.A), + "accountName": accountName, + "protocols": []string{"s2k", "s2k_fo"}, + }, p.idmsaHeaders()) + if err != nil { + return fmt.Errorf("could not reach Apple: %w", err) + } + var challenge struct { + serviceErrors + Iteration int `json:"iteration"` + Salt string `json:"salt"` + Protocol string `json:"protocol"` + B string `json:"b"` + C string `json:"c"` + } + if err := json.Unmarshal(payload, &challenge); err != nil || response.StatusCode != http.StatusOK || challenge.B == "" { + if message := challenge.message(); message != "" { + return fmt.Errorf("Apple refused the sign-in: %s", message) + } + + return fmt.Errorf("Apple refused the sign-in (HTTP %d)", response.StatusCode) + } + salt, saltErr := base64.StdEncoding.DecodeString(challenge.Salt) + serverB, bErr := base64.StdEncoding.DecodeString(challenge.B) + if saltErr != nil || bErr != nil { + return errors.New("Apple sent an unreadable sign-in challenge") + } + key, err := passwordKey(password, salt, challenge.Iteration, challenge.Protocol) + if err != nil { + return err + } + m1, m2, err := client.proofs(accountName, key, salt, serverB) + if err != nil { + return err + } + + headers := p.idmsaHeaders() + token, err := p.hashcash(ctx) + if err != nil { + return err + } + if token != "" { + headers["X-Apple-HC"] = token + } + response, payload, err = p.request(ctx, http.MethodPost, p.hosts.Idmsa+"/appleauth/auth/signin/complete?isRememberMeEnabled=false", map[string]any{ + "accountName": accountName, + "c": challenge.C, + "m1": base64.StdEncoding.EncodeToString(m1), + "m2": base64.StdEncoding.EncodeToString(m2), + "rememberMe": false, + }, headers) + if err != nil { + return fmt.Errorf("could not reach Apple: %w", err) + } + + switch response.StatusCode { + case http.StatusOK: + return nil + case http.StatusConflict: + p.sessionID = response.Header.Get("X-Apple-Id-Session-Id") + p.scnt = response.Header.Get("scnt") + + return p.twoFactor(ctx) + case http.StatusUnauthorized, http.StatusForbidden: + return apns.ErrInvalidCredentials + case http.StatusPreconditionFailed: + return errors.New("Apple needs you to accept its terms or update your account first. Sign in at https://appleid.apple.com, then try again") + default: + var failure serviceErrors + _ = json.Unmarshal(payload, &failure) + if message := failure.message(); message != "" { + return fmt.Errorf("Apple refused the sign-in: %s", message) + } + + return fmt.Errorf("Apple refused the sign-in (HTTP %d)", response.StatusCode) + } +} + +// hashcash answers the proof-of-work challenge Apple hands out on the sign-in +// page. Without one, Apple decides; spaceship carries on in that case too. +// maxHashcashBits bounds the work: Apple asks for about 11 bits, and 24 is +// some 16 million attempts, a few seconds. A harder challenge is not +// answered, as one Apple sends without a challenge is not. +const maxHashcashBits = 24 + +func (p *portalClient) hashcash(ctx context.Context) (string, error) { + response, _, err := p.request(ctx, http.MethodGet, + p.hosts.Idmsa+"/appleauth/auth/signin?widgetKey="+url.QueryEscape(p.widgetKey), nil, nil) + if err != nil { + return "", nil + } + bits, err := strconv.Atoi(response.Header.Get("X-Apple-HC-Bits")) + challenge := response.Header.Get("X-Apple-HC-Challenge") + if err != nil || challenge == "" || bits < 0 || bits > maxHashcashBits { + return "", nil + } + + return hashcash(ctx, bits, challenge, p.now()) +} + +type trustedPhone struct { + ID int64 `json:"id"` + NumberWithDialCode string `json:"numberWithDialCode"` + PushMode string `json:"pushMode"` +} + +const codeAttempts = 3 + +// twoFactor asks for the code Apple sent to the person's devices or phone, +// checks it, and marks the session trusted so later runs skip this step. +func (p *portalClient) twoFactor(ctx context.Context) error { + response, payload, err := p.request(ctx, http.MethodGet, p.hosts.Idmsa+"/appleauth/auth", nil, p.idmsaHeaders()) + if err != nil { + return fmt.Errorf("could not reach Apple: %w", err) + } + var options struct { + serviceErrors + TrustedDevices []any `json:"trustedDevices"` + TrustedPhoneNumbers []trustedPhone `json:"trustedPhoneNumbers"` + NoTrustedDevices bool `json:"noTrustedDevices"` + SecurityCode struct { + Length int `json:"length"` + } `json:"securityCode"` + } + if json.Unmarshal(payload, &options) != nil || response.StatusCode >= 300 { + return fmt.Errorf("Apple asked for two-factor authentication but did not say how (HTTP %d)", response.StatusCode) + } + if len(options.TrustedPhoneNumbers) == 0 { + if len(options.TrustedDevices) > 0 { + return errors.New("this Apple ID uses the older two-step verification. Turn on two-factor authentication at https://appleid.apple.com, then try again") + } + if message := options.message(); message != "" { + return fmt.Errorf("Apple could not send a verification code: %s", message) + } + + return errors.New("Apple asked for two-factor authentication but offered no way to receive a code") + } + length := options.SecurityCode.Length + if length == 0 { + length = 6 + } + + // A code was pushed to the trusted devices, unless there are none. With no + // devices and a single phone, Apple has already texted it. + var phone *trustedPhone + question := fmt.Sprintf("Enter the %d-digit code shown on your Apple device (or type sms to get a text message)", length) + if options.NoTrustedDevices { + if len(options.TrustedPhoneNumbers) == 1 { + phone = &options.TrustedPhoneNumbers[0] + } else if phone, err = p.textCode(ctx, options.TrustedPhoneNumbers); err != nil { + return err + } + question = fmt.Sprintf("Enter the %d-digit code sent to %s", length, phone.NumberWithDialCode) + } + + // Only codes Apple rejects count as attempts; switching to a text message + // does not. + wrong := 0 + for { + code, err := p.asker.Ask(question, false) + if err != nil { + return err + } + code = strings.TrimSpace(code) + if phone == nil && strings.EqualFold(code, "sms") { + if phone, err = p.textCode(ctx, options.TrustedPhoneNumbers); err != nil { + return err + } + question = fmt.Sprintf("Enter the %d-digit code sent to %s", length, phone.NumberWithDialCode) + + continue + } + + route := "trusteddevice" + body := map[string]any{"securityCode": map[string]any{"code": code}} + if phone != nil { + route = "phone" + body["phoneNumber"] = map[string]any{"id": phone.ID} + body["mode"] = phoneMode(phone) + } + response, payload, err := p.request(ctx, http.MethodPost, + p.hosts.Idmsa+"/appleauth/auth/verify/"+route+"/securitycode", body, p.idmsaHeaders()) + if err != nil { + return fmt.Errorf("could not reach Apple: %w", err) + } + if response.StatusCode < 300 { + break + } + var failure serviceErrors + _ = json.Unmarshal(payload, &failure) + if failure.has("-21669") || strings.Contains(strings.ToLower(failure.message()), "verification code") { + wrong++ + if wrong >= codeAttempts { + return errors.New("the verification code was wrong too many times") + } + p.log("That code is not right. Try again.") + + continue + } + if message := failure.message(); message != "" { + return fmt.Errorf("Apple refused the verification code: %s", message) + } + + return fmt.Errorf("Apple refused the verification code (HTTP %d)", response.StatusCode) + } + + // Trusting the session is what lets the next run skip two-factor. A + // failure here still leaves a working session, as spaceship notes. + _, _, _ = p.request(ctx, http.MethodGet, p.hosts.Idmsa+"/appleauth/auth/2sv/trust", nil, p.idmsaHeaders()) + + return nil +} + +func phoneMode(phone *trustedPhone) string { + if phone.PushMode != "" { + return phone.PushMode + } + + return "sms" +} + +// textCode asks which trusted phone should get the code and has Apple text it. +func (p *portalClient) textCode(ctx context.Context, phones []trustedPhone) (*trustedPhone, error) { + chosen := 0 + if len(phones) > 1 { + labels := make([]string, len(phones)) + for index, phone := range phones { + labels[index] = phone.NumberWithDialCode + } + var err error + if chosen, err = p.asker.Choose("Which phone number should get the code?", labels); err != nil { + return nil, err + } + } + phone := &phones[chosen] + response, payload, err := p.request(ctx, http.MethodPut, p.hosts.Idmsa+"/appleauth/auth/verify/phone", map[string]any{ + "phoneNumber": map[string]any{"id": phone.ID}, + "mode": phoneMode(phone), + }, p.idmsaHeaders()) + if err != nil { + return nil, fmt.Errorf("could not reach Apple: %w", err) + } + if response.StatusCode >= 300 { + var failure serviceErrors + _ = json.Unmarshal(payload, &failure) + if message := failure.message(); message != "" { + return nil, fmt.Errorf("Apple could not text the code: %s", message) + } + + return nil, fmt.Errorf("Apple could not text the code (HTTP %d)", response.StatusCode) + } + p.log("Apple sent a code to %s.", phone.NumberWithDialCode) + + return phone, nil +} diff --git a/templates/cli/internal/apns/appwrite/jar.go b/templates/cli/internal/apns/appwrite/jar.go new file mode 100644 index 0000000000..8c60dda5ac --- /dev/null +++ b/templates/cli/internal/apns/appwrite/jar.go @@ -0,0 +1,129 @@ +package appwrite + +import ( + "encoding/json" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "path/filepath" + "sync" + "time" +) + +// savedJar is a cookie jar that can be written to disk, so a signed-in and +// trusted Apple session is reused and the next run skips two-factor. +// net/http/cookiejar cannot list its cookies, so each cookie is also kept with +// the URL it came from and replayed into a fresh jar when loaded. +type savedJar struct { + mu sync.Mutex + inner *cookiejar.Jar + cookies map[string]savedCookie +} + +type savedCookie struct { + URL string `json:"url"` + Name string `json:"name"` + Value string `json:"value"` + Quoted bool `json:"quoted,omitempty"` + Domain string `json:"domain,omitempty"` + Path string `json:"path,omitempty"` + Expires time.Time `json:"expires,omitzero"` + Secure bool `json:"secure,omitempty"` + HTTPOnly bool `json:"httpOnly,omitempty"` +} + +func newSavedJar() *savedJar { + inner, _ := cookiejar.New(nil) + + return &savedJar{inner: inner, cookies: map[string]savedCookie{}} +} + +func (j *savedJar) SetCookies(u *url.URL, cookies []*http.Cookie) { + j.mu.Lock() + defer j.mu.Unlock() + j.inner.SetCookies(u, cookies) + origin := (&url.URL{Scheme: u.Scheme, Host: u.Host, Path: "/"}).String() + for _, cookie := range cookies { + domain := cookie.Domain + if domain == "" { + domain = u.Hostname() + } + id := domain + "|" + cookie.Path + "|" + cookie.Name + expires := cookie.Expires + if cookie.MaxAge > 0 { + expires = time.Now().Add(time.Duration(cookie.MaxAge) * time.Second) + } + if cookie.MaxAge < 0 || (!expires.IsZero() && expires.Before(time.Now())) { + delete(j.cookies, id) + + continue + } + j.cookies[id] = savedCookie{ + URL: origin, Name: cookie.Name, Value: cookie.Value, Quoted: cookie.Quoted, + Domain: cookie.Domain, Path: cookie.Path, Expires: expires, + Secure: cookie.Secure, HTTPOnly: cookie.HttpOnly, + } + } +} + +func (j *savedJar) Cookies(u *url.URL) []*http.Cookie { + return j.inner.Cookies(u) +} + +// load replays the cookies saved at path. A missing or unreadable file leaves +// the jar empty: the caller then signs in again. +func (j *savedJar) load(path string) { + contents, err := os.ReadFile(path) + if err != nil { + return + } + var saved []savedCookie + if json.Unmarshal(contents, &saved) != nil { + return + } + for _, cookie := range saved { + origin, err := url.Parse(cookie.URL) + if err != nil || (!cookie.Expires.IsZero() && cookie.Expires.Before(time.Now())) { + continue + } + j.SetCookies(origin, []*http.Cookie{{ + Name: cookie.Name, Value: cookie.Value, Quoted: cookie.Quoted, + Domain: cookie.Domain, Path: cookie.Path, Expires: cookie.Expires, + Secure: cookie.Secure, HttpOnly: cookie.HTTPOnly, + }}) + } +} + +// save writes the cookies to path, readable by the user only. +func (j *savedJar) save(path string) error { + j.mu.Lock() + saved := make([]savedCookie, 0, len(j.cookies)) + for _, cookie := range j.cookies { + saved = append(saved, cookie) + } + j.mu.Unlock() + + contents, err := json.Marshal(saved) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".session-*") + if err != nil { + return err + } + defer os.Remove(temporary.Name()) + if _, err := temporary.Write(contents); err != nil { + temporary.Close() + + return err + } + if err := temporary.Close(); err != nil { + return err + } + + return os.Rename(temporary.Name(), path) +} diff --git a/templates/cli/internal/apns/appwrite/portal.go b/templates/cli/internal/apns/appwrite/portal.go new file mode 100644 index 0000000000..4a33de93e3 --- /dev/null +++ b/templates/cli/internal/apns/appwrite/portal.go @@ -0,0 +1,253 @@ +package appwrite + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) +{% verbatim %} +// The Developer portal's private API, as the portal's own pages and +// @expo/apple-utils use it: cookie authenticated JSON under +// services-account/QH65B2, with the team in every request and CSRF tokens +// that a read hands out and a write sends back. + +const ( + apnsServiceID = "U27F4V844T" + keysPageSize = 500 +) + +// maxKeysMessage is Apple's answer when the team has no free key slot, worded +// "maximum allowed number of Keys" and, since 2026, "maximum allowed number +// of team scoped Keys for this service in production and sandbox +// environment". +var maxKeysMessage = regexp.MustCompile(`(?i)maximum allowed number of (?:[a-z ]+ )?keys`) + +var ( + errSessionExpired = errors.New("the Apple session expired") + errMaxKeys = errors.New("maximum keys") +) + +// Team is an Apple Developer team the account belongs to. +type Team struct { + ID string `json:"teamId"` + Name string `json:"name"` + Type string `json:"type"` +} + +// portalKey is a key as the portal lists it. +type portalKey struct { + ID string `json:"keyId"` + Name string `json:"keyName"` + CanRevoke bool `json:"canRevoke"` +} + +type portalResult struct { + ResultCode int `json:"resultCode"` + ResultString string `json:"resultString"` + UserString string `json:"userString"` +} + +func (r portalResult) message() string { + for _, value := range []string{r.UserString, r.ResultString} { + if decoded, err := url.PathUnescape(value); err == nil && decoded != "" { + return decoded + } + if value != "" { + return value + } + } + + return "" +} + +// bodyEncoding is how a POST to the portal sends its values. +type bodyEncoding int + +const ( + // formBody is what the portal expects, as apple-utils and spaceship send + // it: an endpoint answers a JSON body with 415. + formBody bodyEncoding = iota + // jsonBody is only for creating a key, which takes nested values. + jsonBody +) + +// portal calls one endpoint and returns the raw body after checking the +// result code. GET requests carry teamID in the query, others in the body. +func (p *portalClient) portal(ctx context.Context, method, path, teamID string, values map[string]any, encoding bodyEncoding) ([]byte, error) { + target := p.hosts.Portal + "/services-account/QH65B2/" + path + var body any + if method == http.MethodGet { + query := url.Values{} + if teamID != "" { + query.Set("teamId", teamID) + } + for name, value := range values { + query.Set(name, fmt.Sprint(value)) + } + target += "?" + query.Encode() + } else if encoding == jsonBody { + data := map[string]any{"teamId": teamID} + for name, value := range values { + data[name] = value + } + body = data + } else { + form := url.Values{} + if teamID != "" { + form.Set("teamId", teamID) + } + for name, value := range values { + form.Set(name, fmt.Sprint(value)) + } + // A call with nothing to send has no body at all. + if len(form) > 0 { + body = form + } + } + headers := map[string]string{"Accept": "application/json, text/plain, */*"} + if p.csrf != "" || p.csrfTS != "" { + headers["csrf"] = p.csrf + headers["csrf_ts"] = p.csrfTS + } + + response, payload, err := p.request(ctx, method, target, body, headers) + if err != nil { + return nil, fmt.Errorf("could not reach the Apple Developer portal: %w", err) + } + if token := response.Header.Get("csrf"); token != "" { + p.csrf, p.csrfTS = token, response.Header.Get("csrf_ts") + } + if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { + return nil, errSessionExpired + } + if response.StatusCode >= 300 { + return nil, fmt.Errorf("the Apple Developer portal returned HTTP %d for %s", response.StatusCode, path) + } + + return payload, nil +} + +// result decodes a JSON portal response, failing on a non-zero result code. +func result(payload []byte, out any) error { + var status portalResult + if err := json.Unmarshal(payload, &status); err != nil { + return fmt.Errorf("the Apple Developer portal sent an unreadable response: %w", err) + } + if status.ResultCode != 0 { + message := status.message() + if strings.Contains(message, "session has expired") { + return errSessionExpired + } + if maxKeysMessage.MatchString(message) { + return errMaxKeys + } + if message == "" { + message = fmt.Sprintf("result code %d", status.ResultCode) + } + + return fmt.Errorf("the Apple Developer portal refused the request: %s", message) + } + + return json.Unmarshal(payload, out) +} + +func (p *portalClient) teams(ctx context.Context) ([]Team, error) { + payload, err := p.portal(ctx, http.MethodPost, "account/listTeams.action", "", nil, formBody) + if err != nil { + return nil, err + } + var response struct { + Teams []Team `json:"teams"` + } + if err := result(payload, &response); err != nil { + return nil, err + } + + return response.Teams, nil +} + +// keys lists the team's keys. It also hands out the CSRF tokens that creating +// a key needs, so it always runs first. +func (p *portalClient) keys(ctx context.Context, teamID string) ([]apns.ExistingKey, error) { + var all []apns.ExistingKey + for page := 1; ; page++ { + payload, err := p.portal(ctx, http.MethodPost, "account/auth/key/list", teamID, map[string]any{ + "pageNumber": page, + "pageSize": keysPageSize, + "sort": "name=asc", + }, formBody) + if err != nil { + return nil, err + } + var response struct { + Keys []portalKey `json:"keys"` + } + if err := result(payload, &response); err != nil { + return nil, err + } + for _, key := range response.Keys { + all = append(all, apns.ExistingKey{ID: key.ID, Name: key.Name, CanRevoke: key.CanRevoke}) + } + if len(response.Keys) < keysPageSize { + return all, nil + } + } +} + +// createKey creates a team-scoped key with only APNs enabled, for the given +// environment, and returns its ID. +func (p *portalClient) createKey(ctx context.Context, teamID, name string, environment apns.Environment) (string, error) { + payload, err := p.portal(ctx, http.MethodPost, "account/auth/key/v2/create", teamID, map[string]any{ + "name": name, + "scope": "team", + "serviceConfigurations": map[string][]string{apnsServiceID: {}}, + "serviceConfigurationsRequests": []map[string]any{{ + "isNew": true, + "serviceId": apnsServiceID, + "identifiers": map[string]any{}, + "environment": string(environment), + "scope": "team", + }}, + }, jsonBody) + if err != nil { + return "", err + } + var response struct { + Keys []portalKey `json:"keys"` + } + if err := result(payload, &response); err != nil { + return "", err + } + if len(response.Keys) == 0 || response.Keys[0].ID == "" { + return "", errors.New("the Apple Developer portal created no key") + } + + return response.Keys[0].ID, nil +} + +// downloadKey returns the key's .p8 contents. Apple serves them once. +func (p *portalClient) downloadKey(ctx context.Context, teamID, keyID string) (string, error) { + payload, err := p.portal(ctx, http.MethodGet, "account/auth/key/download", teamID, map[string]any{"keyId": keyID}, formBody) + if err != nil { + return "", err + } + contents := string(payload) + if !strings.Contains(contents, "PRIVATE KEY") { + var ignored struct{} + if err := result(payload, &ignored); err != nil { + return "", err + } + + return "", errors.New("the Apple Developer portal did not send the key") + } + + return contents, nil +} +{% endverbatim %} \ No newline at end of file diff --git a/templates/cli/internal/apns/appwrite/srp.go b/templates/cli/internal/apns/appwrite/srp.go new file mode 100644 index 0000000000..91ab23d115 --- /dev/null +++ b/templates/cli/internal/apns/appwrite/srp.go @@ -0,0 +1,184 @@ +package appwrite + +import ( + "context" + "crypto/pbkdf2" + "crypto/sha1" + "crypto/sha256" + "errors" + "fmt" + "io" + "math/big" + "strconv" + "strings" + "time" +) + +// Apple signs in with SRP-6a over the 2048-bit group of RFC 5054 and SHA-256, +// so the password itself never leaves the machine. This follows +// fastlane-sirp, the SRP client spaceship uses, byte for byte. Its quirks +// matter: x leaves the username out, and A, S and the server proof are +// hashed and sent without leading zero bytes, while k and u pad their inputs +// to the length of N. + +var ( + srpN = mustHex(strings.Join([]string{ + "AC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC3192943DB56050", + "A37329CBB4A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50", + "E8083969EDB767B0CF6095179A163AB3661A05FBD5FAAAE82918A9962F0B93B8", + "55F97993EC975EEAA80D740ADBF4FF747359D041D5C33EA71D281E446B14773B", + "CA97B43A23FB801676BD207A436C6481F1D2B9078717461A5B9D32E688F87748", + "544523B524B0D57D5EA77A2775D2ECFA032CFBDBF52FB3786160279004E57AE6", + "AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DBFBB6", + "94B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73", + }, "")) + srpG = big.NewInt(2) + srpLength = len(srpN.Bytes()) +) + +func mustHex(value string) *big.Int { + number, ok := new(big.Int).SetString(value, 16) + if !ok { + panic("invalid hex: " + value) + } + + return number +} + +func sha256Of(parts ...[]byte) []byte { + hash := sha256.New() + for _, part := range parts { + hash.Write(part) + } + + return hash.Sum(nil) +} + +// padded returns value left-padded with zeros to the length of N. +func padded(value []byte) []byte { + if len(value) >= srpLength { + return value + } + out := make([]byte, srpLength) + copy(out[srpLength-len(value):], value) + + return out +} + +// hashPadded is fastlane-sirp's H(): the hash of the padded values, as a +// number reduced modulo N. +func hashPadded(values ...[]byte) *big.Int { + parts := make([][]byte, len(values)) + for index, value := range values { + parts[index] = padded(value) + } + sum := new(big.Int).SetBytes(sha256Of(parts...)) + + return sum.Mod(sum, srpN) +} + +// passwordKey derives the value Apple's SRP uses as the password: PBKDF2 over +// the SHA-256 of the password, hex encoded first for the older s2k_fo protocol. +func passwordKey(password string, salt []byte, iterations int, protocol string) ([]byte, error) { + digest := sha256Of([]byte(password)) + switch protocol { + case "s2k": + case "s2k_fo": + digest = []byte(fmt.Sprintf("%x", digest)) + default: + return nil, fmt.Errorf("unsupported Apple sign-in protocol %q", protocol) + } + + return pbkdf2.Key(sha256.New, string(digest), salt, iterations, 32) +} + +type srpClient struct { + a *big.Int + A []byte +} + +func newSRPClient(random io.Reader) (*srpClient, error) { + secret := make([]byte, 256) + if _, err := io.ReadFull(random, secret); err != nil { + return nil, err + } + + return newSRPClientWith(new(big.Int).SetBytes(secret)), nil +} + +func newSRPClientWith(a *big.Int) *srpClient { + return &srpClient{a: a, A: new(big.Int).Exp(srpG, a, srpN).Bytes()} +} + +// proofs computes the client proof M1 and the expected server proof M2 for +// the server's salt and B, given the derived password key. +func (c *srpClient) proofs(accountName string, key, salt, serverB []byte) ([]byte, []byte, error) { + B := new(big.Int).SetBytes(serverB) + if new(big.Int).Mod(B, srpN).Sign() == 0 { + return nil, nil, errors.New("Apple sent an invalid sign-in challenge") + } + u := hashPadded(c.A, serverB) + if u.Sign() == 0 { + return nil, nil, errors.New("Apple sent an invalid sign-in challenge") + } + k := hashPadded(srpN.Bytes(), srpG.Bytes()) + x := new(big.Int).SetBytes(sha256Of(salt, sha256Of([]byte(":"), key))) + + // S = (B - k * g^x) ^ (a + u * x) mod N + base := new(big.Int).Exp(srpG, x, srpN) + base.Mul(base, k) + base.Sub(B, base) + base.Mod(base, srpN) + exponent := new(big.Int).Mul(u, x) + exponent.Add(exponent, c.a) + S := new(big.Int).Exp(base, exponent, srpN) + K := sha256Of(S.Bytes()) + + hashN := hashPadded(srpN.Bytes()) + hashG := hashPadded(srpG.Bytes()) + xor := new(big.Int).Xor(hashN, hashG) + M1 := sha256Of(xor.Bytes(), sha256Of([]byte(accountName)), salt, c.A, serverB, K) + M2 := new(big.Int).SetBytes(sha256Of(c.A, M1, K)).Bytes() + + return M1, M2, nil +} + +// hashcash answers Apple's proof-of-work challenge: the first counter whose +// "1:bits:date:challenge::counter" string has a SHA-1 starting with bits +// zero bits. The date is in UTC, as @expo/apple-utils sends it (spaceship +// uses local time, which only matches on a UTC machine). +// The search stops when ctx is done, checked every hashcashBatch attempts. +func hashcash(ctx context.Context, bits int, challenge string, now time.Time) (string, error) { + prefix := "1:" + strconv.Itoa(bits) + ":" + now.UTC().Format("20060102150405") + ":" + challenge + "::" + for counter := 0; ; counter++ { + if counter%hashcashBatch == 0 { + if err := ctx.Err(); err != nil { + return "", err + } + } + token := prefix + strconv.Itoa(counter) + if leadingZeroBits(sha1.Sum([]byte(token))) >= bits { + return token, nil + } + } +} + +const hashcashBatch = 1 << 16 + +func leadingZeroBits(sum [sha1.Size]byte) int { + count := 0 + for _, value := range sum { + if value == 0 { + count += 8 + + continue + } + for mask := byte(0x80); mask != 0 && value&mask == 0; mask >>= 1 { + count++ + } + + break + } + + return count +} diff --git a/templates/cli/internal/apns/appwrite/srp_test.go b/templates/cli/internal/apns/appwrite/srp_test.go new file mode 100644 index 0000000000..55f2fae534 --- /dev/null +++ b/templates/cli/internal/apns/appwrite/srp_test.go @@ -0,0 +1,129 @@ +package appwrite + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/hex" + "errors" + "math/big" + "testing" + "time" +) + +// The expected values were computed with fastlane-sirp and spaceship's +// hashcash, the Ruby code spaceship signs in to Apple with, for fixed inputs. +var srpVectors = []struct { + protocol, password string + iterations int + a, clientA, salt, serverB, derived, m1, m2 string +}{ + { + protocol: "s2k", + password: "correct horse battery staple", + iterations: 1000, + a: "976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c", + clientA: "lOFBKFUcFnIqiPacVzlN2auN4I/AMJTWac7+/T1qEp2nhqIlgqfiFMgS/lLgf2YHa/88G9823FwFSNcRw/h46ujunwE/pkwZKFscpaa68psSBryNQFK1HsWrHblsQdOicXq+dmD+p70d1tx6w935hE8Htq+3iFdyqvb/nrau/qdyKJ1ueEWpnJ71r35XmHdfVQ94/qAsOp753gLCRFNdTmvFONzkqS9PRzC3Ug9F0PKeMf1kQhurzoMZRo/HqS+Pz7bTdDW5efKMg4s6uvX7t+LffwUwxuoCP/NksHQyRznffQivjnh596NJ4XCrEpvu+tE4bOBjtlSYSYI45JQmrw==", + salt: "8XJskbjIRdqLv44iq8fuiQ==", + serverB: "bmqruv0a/EiZ7xJfeLeG1ilXyXzhUJNYtDmwpbgK152y+6RctWvwBHLLnTVQlfHYzKIgMrlChKSyCl6tXjOkKz73r03bRCUL675sZDN7K3pGEL2TvrU/qdb7XBgKUWI1ctW54xynuBy1YpSYoETwrrHirVB3LDKlBdp+J3T+MfuJeR270cGJ/nDKAXtfj3qwagSrM17wI193qe4jEevZD4mvlKztv29zQ6/T9q6vTopJ524kZTzcY00t0y9r7jVFzpRXXMfg5hCzrNdKkECjFpC6/UI/xDKmyl/Uux8yG5dA5Gcxcsb0GfT7g01i3sdd619gUlceFifCpvI56NIapQ==", + derived: "adb53b51adb7d706507d630b73e0d85ec46c34ffa95eabc6bfb6893092bfe538", + m1: "T02D0QeodD+9ldyfjZQUTynd976L9gg52UBkxEFXRjE=", + m2: "bkDJQIvXJOIj1/cMlHJfnab9VrMyBSzaVNJY4kdCik0=", + }, + { + protocol: "s2k_fo", + password: "p@ss wörd", + iterations: 1001, + a: "62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b", + clientA: "q3Y06Eo7PTQ8TvzRw5xZqRwCKcw0GTxg5+6b5a75N+Dy3rnNXSAM0/aPomhpAcHMz14FtSALPmpNXEYa3UxUUGWiLkp1RDLTzdJT2U/+X0lU6XBm5W0O8hyYe3/Na8JrQvNvsDfmahk4/w8ZKKDTSq1cbRiQHWV7wA/D5tl5jh/xPADahOe4jt3rU+COrHR3JcVDWknM0B4cL0HywA2J2/RrK/ushhR/E8Uu7wGvb7LbcFNhOmp4q6q9bLrm5zS6tbH6dXfD3V5W4bvgFOwhKKmS0B39v4sZZRSkHiTdLMAipBgShP5kPAi1S0RoRXFYD1sHI2v7m0mP5P+S45wI", + salt: "lneDgZnxLTb17pcdSfaKvA==", + serverB: "Ht8tUspsId2o6YnOxb/QKSLQ3vn0scNeYsV7ElRHRmXKj3b6neErfO7BCpQFvY59g1hvwwDT2fFa4Ah2nzLjRHG83W/vA/Jt0MltHSECjhV0ASfMHfMQVA6VlDLNAdAO8e4weK49v9j/AWobmdXQjBkSRTuTzMT4rKjXlmXdT54Mo3ryCPxhA2r4u+FYV4qn2vaW2GBKAj2ONMZQW0DWV90LhnnQZ6mib/mpUctOv+rTZ893ZbtytYByZtwnd91AV1QV55KNOmEqRrJEXlb3oqMVLR0FQyai2kZVH5yIT6Jri/wTfKKNkl1vIYsbM5ctc0k7UdADQYQ8070Bd1k0mQ==", + derived: "296173526d4ff2ba32b6027b25166623f12566dad89b707af650e146628e95b4", + m1: "Gfm875AAWz5Rh4ok/WHAzVuVqKPG6jwLnf/5OGyl+H4=", + m2: "XxfUcq4O5mBX/SC9s3QCv8nU0fuz5R47opb5rO9JDzE=", + }, +} + +func decode(t *testing.T, value string) []byte { + t.Helper() + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + t.Fatal(err) + } + + return decoded +} + +func TestSRPMatchesFastlane(t *testing.T) { + for _, vector := range srpVectors { + t.Run(vector.protocol, func(t *testing.T) { + salt := decode(t, vector.salt) + key, err := passwordKey(vector.password, salt, vector.iterations, vector.protocol) + if err != nil || hex.EncodeToString(key) != vector.derived { + t.Fatalf("password key = %x, %v", key, err) + } + + a, _ := new(big.Int).SetString(vector.a, 16) + client := newSRPClientWith(a) + if !bytes.Equal(client.A, decode(t, vector.clientA)) { + t.Errorf("A = %s", base64.StdEncoding.EncodeToString(client.A)) + } + m1, m2, err := client.proofs("dev@example.com", key, salt, decode(t, vector.serverB)) + if err != nil { + t.Fatal(err) + } + if got := base64.StdEncoding.EncodeToString(m1); got != vector.m1 { + t.Errorf("m1 = %s, want %s", got, vector.m1) + } + if got := base64.StdEncoding.EncodeToString(m2); got != vector.m2 { + t.Errorf("m2 = %s, want %s", got, vector.m2) + } + }) + } +} + +func TestSRPRejectsAnInvalidChallenge(t *testing.T) { + client := newSRPClientWith(big.NewInt(12345)) + if _, _, err := client.proofs("dev@example.com", []byte("key"), []byte("salt"), srpN.Bytes()); err == nil { + t.Error("accepted B = N") + } + if _, err := passwordKey("password", []byte("salt"), 1, "s2k_unknown"); err == nil { + t.Error("accepted an unknown protocol") + } +} + +func TestHashcashStopsWhenCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if token, err := hashcash(ctx, 64, "challenge", time.Now()); !errors.Is(err, context.Canceled) || token != "" { + t.Errorf("hashcash = %q, %v", token, err) + } +} + +func TestHashcashUsesUTC(t *testing.T) { + utc := time.Date(2023, 2, 23, 17, 6, 0, 0, time.UTC) + india := utc.In(time.FixedZone("IST", 5*3600+1800)) + got, _ := hashcash(context.Background(), 11, "4d74fb15eb23f465f1f6fcbf534e5877", india) + want, _ := hashcash(context.Background(), 11, "4d74fb15eb23f465f1f6fcbf534e5877", utc) + if got != want { + t.Errorf("hashcash in IST = %s, in UTC = %s", got, want) + } +} + +func TestHashcashMatchesSpaceship(t *testing.T) { + for _, vector := range []struct { + bits int + challenge, date, token string + }{ + {11, "4d74fb15eb23f465f1f6fcbf534e5877", "20230223170600", "1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373"}, + {10, "abc", "20261009120000", "1:10:20261009120000:abc::1266"}, + } { + date, err := time.Parse("20060102150405", vector.date) + if err != nil { + t.Fatal(err) + } + if got, err := hashcash(context.Background(), vector.bits, vector.challenge, date); err != nil || got != vector.token { + t.Errorf("hashcash(%d, %s) = %s, want %s", vector.bits, vector.challenge, got, vector.token) + } + } +} diff --git a/templates/cli/internal/apns/expo/adapter.go b/templates/cli/internal/apns/expo/adapter.go new file mode 100644 index 0000000000..909f7752e2 --- /dev/null +++ b/templates/cli/internal/apns/expo/adapter.go @@ -0,0 +1,420 @@ +// Package expo is the APNs key setup built on @expo/apple-utils, the library +// eas-cli creates APNs keys with. +// +// It runs a small Node script embedded in the binary, with its package.json +// and a lockfile that pins @expo/apple-utils 2.2.1 (MIT) by hash. They are +// installed into the session folder on first use with npm ci +// --ignore-scripts. It runs on Node.js 18 or later, and downloads the Node.js +// LTS into the session folder when the machine has none it can use. +package expo + +import ( + "bytes" + "context" + "embed" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/prompt" +) + +//go:embed helper/helper.js helper/package.json helper/package-lock.json +var helperFiles embed.FS + +const minimumNodeMajor = 18 + +// Adapter creates APNs keys with @expo/apple-utils. +type Adapter struct { + // Node and Npm are the commands to run. Empty means "node" and "npm" + // from PATH, or a downloaded Node.js when those are missing or too old. + Node string + Npm string + // For tests: where Node.js builds are downloaded from, the client, and + // the platform. Zero values mean nodejs.org and this machine. + NodeDist string + HTTP *http.Client + GOOS string + GOARCH string + // apple-utils prompts for anything the request does not answer, and for + // the two-factor code, on the terminal. Nil means the process's own. + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// New returns the adapter using node and npm from PATH and the terminal. +func New() *Adapter { + return &Adapter{} +} + +// Name is how people pick this setup. +func (a *Adapter) Name() string { + return "expo" +} + +type result struct { + KeyID string `json:"keyId"` + AppleID string `json:"appleId"` + TeamID string `json:"teamId"` + P8 string `json:"p8"` + Error string `json:"error"` + Message string `json:"message"` + Keys []struct { + ID string `json:"id"` + Name string `json:"name"` + CanRevoke bool `json:"canRevoke"` + } `json:"keys"` + // The app-id action's result. + Registered bool `json:"registered"` + Push bool `json:"push"` + Created bool `json:"created"` + PushTurnedOn bool `json:"pushTurnedOn"` +} + +// CreateKey installs the helper if needed and runs it. apple-utils always +// creates keys for both APNs environments; for a single-environment request +// that key works too. +func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { + if request.Environment != apns.EnvironmentAll && request.Log != nil { + request.Log("The expo setup creates keys for both APNs environments; the key also works for %s.", request.Environment) + } + arguments := []string{"--name", request.Name} + if request.TeamID != "" { + arguments = append(arguments, "--team-id", request.TeamID) + } + contents, err := a.runHelper(ctx, &request, arguments...) + if err != nil { + return apns.Key{}, err + } + + return decodeResult(contents) +} + +// PrepareAppID runs the helper's app-id action: it checks the App ID, and +// after the question, or straight away without one, registers it and turns +// on Push Notifications. +func (a *Adapter) PrepareAppID(ctx context.Context, request apns.Request, appID apns.AppIDRequest) (apns.AppIDResult, error) { + // apple-utils keeps its saved sign-in per Apple ID, so without one there + // is nothing to restore. + if request.SavedSignInOnly && strings.TrimSpace(request.AppleID) == "" { + return apns.AppIDResult{}, apns.ErrSignInNeeded + } + run := func(mode string) (apns.AppIDResult, error) { + arguments := []string{"--action", "app-id", "--mode", mode, "--bundle-id", appID.BundleID, "--name", apns.AppIDName(appID.BundleID)} + if request.TeamID != "" { + arguments = append(arguments, "--team-id", request.TeamID) + } + if request.SavedSignInOnly { + arguments = append(arguments, "--saved-only", "true") + } + contents, err := a.runHelper(ctx, &request, arguments...) + if err != nil { + return apns.AppIDResult{}, err + } + // The apply run restores the check's sign-in, which apple-utils keeps + // per Apple ID. + var signedIn result + if json.Unmarshal(contents, &signedIn) == nil && signedIn.AppleID != "" { + request.AppleID = signedIn.AppleID + } + + return decodeAppIDResult(contents) + } + if !appID.Ask { + return run("apply") + } + found, err := run("check") + if err != nil || (found.Registered && found.Push) { + return found, err + } + change, err := apns.ConfirmAppIDChange(request.Asker, appID.BundleID, found.TeamID, found.Registered) + if err != nil || !change { + return found, err + } + // The check signed in (again, for a reset) and saved the session, so the + // change reuses it instead of asking again. + request.Reset = false + request.TeamID = found.TeamID + request.SavedSignInOnly = true + + return run("apply") +} + +// runHelper installs the helper if needed, runs it with arguments and returns +// the result it wrote. The terminal stays attached, since apple-utils asks +// for what signing in needs. +func (a *Adapter) runHelper(ctx context.Context, request *apns.Request, arguments ...string) ([]byte, error) { + log := request.Log + if log == nil { + log = func(string, ...any) {} + } + // For a reset the helper signs out of apple-utils before signing in. Its + // sign out asks for the Apple ID and forgets it, so the sign-in would ask + // again; asking here once and passing it to both avoids that. + // Without a terminal, asking here fails the same way the other setups do, + // instead of with apple-utils' own message. + if (request.Reset || !prompt.Interactive()) && !request.SavedSignInOnly && strings.TrimSpace(request.AppleID) == "" && request.Asker != nil { + answer, err := request.Asker.Ask("Apple ID (email)", false) + if err != nil { + return nil, err + } + request.AppleID = strings.TrimSpace(answer) + } + dir := request.SessionDir + if dir == "" { + scratch, err := os.MkdirTemp("", "appwrite-apns-expo-") + if err != nil { + return nil, err + } + defer os.RemoveAll(scratch) + dir = scratch + } + tools, err := a.toolchain(ctx, dir, log) + if err != nil { + return nil, err + } + if err := a.install(ctx, dir, tools); err != nil { + return nil, err + } + + scratch, err := os.MkdirTemp("", "appwrite-apns-expo-result-") + if err != nil { + return nil, err + } + defer os.RemoveAll(scratch) + out := filepath.Join(scratch, "result.json") + + arguments = append([]string{filepath.Join(dir, "helper.js")}, arguments...) + arguments = append(arguments, "--out", out) + if request.Reset { + arguments = append(arguments, "--reset", "true") + } + command := exec.CommandContext(ctx, tools.node, arguments...) + command.Dir = dir + command.Stdin, command.Stdout, command.Stderr = a.terminal() + // apple-utils reads the Apple ID and password from these instead of + // asking, and stores the password in the macOS Keychain unless told not + // to. The CLI never keeps the password. + command.Env = append(tools.env(), "EXPO_NO_KEYCHAIN=1") + if request.AppleID != "" { + command.Env = append(command.Env, "EXPO_APPLE_ID="+request.AppleID) + } + if request.Password != "" { + command.Env = append(command.Env, "EXPO_APPLE_PASSWORD="+request.Password) + } + runErr := command.Run() + + contents, err := os.ReadFile(out) + if err != nil { + if runErr != nil { + return nil, fmt.Errorf("the @expo/apple-utils helper failed: %w", runErr) + } + + return nil, fmt.Errorf("the @expo/apple-utils helper returned no result: %w", err) + } + + return contents, nil +} + +func decodeAppIDResult(contents []byte) (apns.AppIDResult, error) { + var decoded result + if err := json.Unmarshal(contents, &decoded); err != nil { + return apns.AppIDResult{}, fmt.Errorf("the @expo/apple-utils helper returned an unreadable result: %w", err) + } + if err := decoded.err(); err != nil { + return apns.AppIDResult{}, err + } + + return apns.AppIDResult{ + TeamID: decoded.TeamID, + Registered: decoded.Registered, + Push: decoded.Push, + Created: decoded.Created, + PushTurnedOn: decoded.PushTurnedOn, + }, nil +} + +// err is the error the helper reported, other than a full team. +func (r result) err() error { + switch r.Error { + case "": + return nil + case "sign-in-needed": + return apns.ErrSignInNeeded + case "invalid-credentials": + return fmt.Errorf("%w: %s", apns.ErrInvalidCredentials, r.Message) + default: + if r.Message == "" { + return errors.New(r.Error) + } + + return errors.New(r.Message) + } +} + +func decodeResult(contents []byte) (apns.Key, error) { + var decoded result + if err := json.Unmarshal(contents, &decoded); err != nil { + return apns.Key{}, fmt.Errorf("the @expo/apple-utils helper returned an unreadable result: %w", err) + } + switch decoded.Error { + case "": + case "max-keys": + keys := make([]apns.ExistingKey, len(decoded.Keys)) + for index, key := range decoded.Keys { + keys[index] = apns.ExistingKey{ID: key.ID, Name: key.Name, CanRevoke: key.CanRevoke} + } + + return apns.Key{}, &apns.MaxKeysError{Keys: keys} + default: + return apns.Key{}, decoded.err() + } + if decoded.KeyID == "" || decoded.P8 == "" { + return apns.Key{}, errors.New("the @expo/apple-utils helper returned no key") + } + + return apns.Key{KeyID: decoded.KeyID, TeamID: decoded.TeamID, P8: decoded.P8, AppleID: decoded.AppleID}, nil +} + +func (a *Adapter) terminal() (io.Reader, io.Writer, io.Writer) { + stdin, stdout, stderr := a.Stdin, a.Stdout, a.Stderr + if stdin == nil { + stdin = os.Stdin + } + if stdout == nil { + stdout = os.Stdout + } + if stderr == nil { + stderr = os.Stderr + } + + return stdin, stdout, stderr +} + +func (a *Adapter) npm() string { + if a.Npm != "" { + return a.Npm + } + + return "npm" +} + +var nodeVersion = regexp.MustCompile(`^v?(\d+)\.`) + +func checkNode(ctx context.Context, node string) error { + output, err := exec.CommandContext(ctx, node, "--version").Output() + if err != nil { + return fmt.Errorf("%w: the expo setup needs Node.js %d or later", apns.ErrUnavailable, minimumNodeMajor) + } + + return supportedNode(string(bytes.TrimSpace(output))) +} + +// toolchain is how node and npm are run. +type toolchain struct { + node string + npm []string + // bin is put first on PATH when node is a downloaded one. + bin string +} + +func (t toolchain) env() []string { + env := os.Environ() + if t.bin != "" { + env = append(env, "PATH="+t.bin+string(os.PathListSeparator)+os.Getenv("PATH")) + } + + return env +} + +// toolchain uses the configured node, then node and npm from PATH, then the +// Node.js downloaded into dir. A configured node is never replaced. +func (a *Adapter) toolchain(ctx context.Context, dir string, log func(string, ...any)) (toolchain, error) { + if a.Node != "" { + if err := checkNode(ctx, a.Node); err != nil { + return toolchain{}, err + } + + return toolchain{node: a.Node, npm: []string{a.npm()}}, nil + } + if checkNode(ctx, "node") == nil { + if _, err := exec.LookPath(a.npm()); err == nil { + return toolchain{node: "node", npm: []string{a.npm()}}, nil + } + } + + node, npm, err := a.downloadNode(ctx, filepath.Join(dir, "runtime"), log) + if err != nil { + return toolchain{}, fmt.Errorf("%w: the expo setup needs Node.js %d or later, and downloading it failed: %v", apns.ErrUnavailable, minimumNodeMajor, err) + } + if err := checkNode(ctx, node); err != nil { + return toolchain{}, err + } + + return toolchain{node: node, npm: []string{node, npm}, bin: filepath.Dir(node)}, nil +} + +func supportedNode(version string) error { + match := nodeVersion.FindStringSubmatch(version) + if match == nil { + return fmt.Errorf("%w: could not read the Node.js version %q", apns.ErrUnavailable, version) + } + if major, _ := strconv.Atoi(match[1]); major < minimumNodeMajor { + return fmt.Errorf("%w: the expo setup needs Node.js %d or later, found %s", apns.ErrUnavailable, minimumNodeMajor, version) + } + + return nil +} + +// install writes the embedded files into dir and runs npm ci when the +// lockfile changed or the dependencies are missing. The lockfile pins the +// exact package hashes, and --ignore-scripts keeps install scripts from +// running. +func (a *Adapter) install(ctx context.Context, dir string, tools toolchain) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + changed := false + for _, name := range []string{"helper.js", "package.json", "package-lock.json"} { + contents, err := helperFiles.ReadFile("helper/" + name) + if err != nil { + return err + } + path := filepath.Join(dir, name) + if current, err := os.ReadFile(path); err == nil && bytes.Equal(current, contents) { + continue + } + if err := os.WriteFile(path, contents, 0o600); err != nil { + return err + } + changed = true + } + + if _, err := os.Stat(filepath.Join(dir, "node_modules", "@expo", "apple-utils", "package.json")); err == nil && !changed { + return nil + } + _, _, stderr := a.terminal() + arguments := append(append([]string{}, tools.npm[1:]...), "ci", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error") + command := exec.CommandContext(ctx, tools.npm[0], arguments...) + command.Dir = dir + command.Env = tools.env() + command.Stdout, command.Stderr = stderr, stderr + if err := command.Run(); err != nil { + // A failed install must not look complete on the next run. + _ = os.Remove(filepath.Join(dir, "package-lock.json")) + + return fmt.Errorf("%w: npm ci failed in %s: %v", apns.ErrUnavailable, dir, err) + } + + return nil +} diff --git a/templates/cli/internal/apns/expo/adapter_test.go b/templates/cli/internal/apns/expo/adapter_test.go new file mode 100644 index 0000000000..73e7894ff2 --- /dev/null +++ b/templates/cli/internal/apns/expo/adapter_test.go @@ -0,0 +1,335 @@ +package expo + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +func TestSupportedNode(t *testing.T) { + for version, supported := range map[string]bool{ + "v18.0.0": true, + "v22.11.0": true, + "v16.20.2": false, + "v8.9.0": false, + "garbage": false, + } { + err := supportedNode(version) + if (err == nil) != supported { + t.Errorf("supportedNode(%q) = %v", version, err) + } + if err != nil && !errors.Is(err, apns.ErrUnavailable) { + t.Errorf("supportedNode(%q) is not ErrUnavailable: %v", version, err) + } + } +} + +func TestDecodeResult(t *testing.T) { + key, err := decodeResult([]byte(`{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"-----BEGIN PRIVATE KEY-----","appleId":"dev@example.com"}`)) + if err != nil || key.KeyID != "KEY1234567" || key.TeamID != "ABCDE12345" || key.AppleID != "dev@example.com" { + t.Errorf("key = %+v, %v", key, err) + } + + _, err = decodeResult([]byte(`{"error":"max-keys","keys":[{"id":"OLD1234567","name":"Old","canRevoke":true}]}`)) + var maxKeys *apns.MaxKeysError + if !errors.As(err, &maxKeys) || len(maxKeys.Keys) != 1 || maxKeys.Keys[0].ID != "OLD1234567" || maxKeys.Keys[0].Name != "Old" { + t.Errorf("max keys = %v", err) + } + + _, err = decodeResult([]byte(`{"error":"invalid-credentials","message":"Invalid username and password combination"}`)) + if !errors.Is(err, apns.ErrInvalidCredentials) { + t.Errorf("credentials = %v", err) + } + + if _, err := decodeResult([]byte(`{"error":"failed","message":"Apple is down"}`)); err == nil || err.Error() != "Apple is down" { + t.Errorf("failure = %v", err) + } + if _, err := decodeResult([]byte(`{}`)); err == nil { + t.Error("an empty result was accepted") + } +} + +// fakeTools writes node and npm stand-ins that log their arguments. The node +// stand-in answers --version and writes result to the --out file. +func fakeTools(t *testing.T, version, result string) (string, string, string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell stand-ins") + } + bin := t.TempDir() + log := filepath.Join(bin, "log") + node := `#!/bin/sh +if [ "$1" = "--version" ]; then echo "` + version + `"; exit 0; fi +echo "node $* EXPO_NO_KEYCHAIN=$EXPO_NO_KEYCHAIN EXPO_APPLE_ID=$EXPO_APPLE_ID" >> "` + log + `" +while [ $# -gt 0 ]; do + if [ "$1" = "--out" ]; then printf '%s' '` + result + `' > "$2"; fi + shift +done +` + npm := `#!/bin/sh +echo "npm $*" >> "` + log + `" +mkdir -p node_modules/@expo/apple-utils && echo '{}' > node_modules/@expo/apple-utils/package.json +` + for name, script := range map[string]string{"node": node, "npm": npm} { + if err := os.WriteFile(filepath.Join(bin, name), []byte(script), 0o700); err != nil { + t.Fatal(err) + } + } + + return filepath.Join(bin, "node"), filepath.Join(bin, "npm"), log +} + +func TestCreateKeyInstallsOnceAndRunsTheHelper(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{ + TeamID: "ABCDE12345", + Name: "Appwrite Push", + Environment: apns.EnvironmentAll, + AppleID: "dev@example.com", + SessionDir: filepath.Join(t.TempDir(), "expo"), + } + + for range 2 { + key, err := adapter.CreateKey(context.Background(), request) + if err != nil || key.KeyID != "KEY1234567" || key.P8 != "pem" { + t.Fatalf("key = %+v, %v", key, err) + } + } + + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + if len(lines) != 3 || !strings.HasPrefix(lines[0], "npm ci --ignore-scripts") { + t.Fatalf("calls:\n%s", calls) + } + for _, line := range lines[1:] { + if !strings.Contains(line, "helper.js --name Appwrite Push --team-id ABCDE12345 --out ") || + !strings.HasSuffix(line, " EXPO_NO_KEYCHAIN=1 EXPO_APPLE_ID=dev@example.com") { + t.Errorf("helper call = %s", line) + } + } + for _, name := range []string{"helper.js", "package.json", "package-lock.json"} { + if _, err := os.Stat(filepath.Join(request.SessionDir, name)); err != nil { + t.Errorf("%s was not installed: %v", name, err) + } + } +} + +// Without a team, the helper gets no --team-id and apple-utils picks the +// account's only team or asks, the flow the CLI uses when no team is known. +func TestCreateKeyWithoutATeamNotesTheEnvironment(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + var logged []string + request := apns.Request{ + Name: "Appwrite Push", + Environment: apns.EnvironmentSandbox, + SessionDir: t.TempDir(), + Log: func(format string, args ...any) { logged = append(logged, format) }, + } + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + if len(logged) != 1 || !strings.Contains(logged[0], "both APNs environments") { + t.Errorf("logged %v", logged) + } + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + helper := lines[len(lines)-1] + if !strings.Contains(helper, "helper.js --name Appwrite Push --out ") || strings.Contains(helper, "--team-id") { + t.Errorf("helper call = %s", helper) + } +} + +func TestCreateKeyNeedsARecentNode(t *testing.T) { + node, npm, log := fakeTools(t, "v16.20.2", `{}`) + adapter := &Adapter{Node: node, Npm: npm} + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: t.TempDir()} + + if _, err := adapter.CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(log); err == nil { + t.Error("ran npm or the helper with an old Node.js") + } + + missing := &Adapter{Node: filepath.Join(t.TempDir(), "node")} + if _, err := missing.CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("missing node err = %v", err) + } +} + +func TestCreateKeyPassesResetToTheHelper(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: t.TempDir(), Reset: true} + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + if helper := lines[len(lines)-1]; !strings.Contains(helper, " --reset true ") { + t.Errorf("helper call = %s", helper) + } +} + +type oneAnswer struct { + answer string + asked []string +} + +func (o *oneAnswer) Ask(question string, secret bool) (string, error) { + o.asked = append(o.asked, question) + + return o.answer, nil +} + +func (o *oneAnswer) Choose(string, []string) (int, error) { + return 0, errors.New("not expected") +} + +// A reset asks for the Apple ID once and hands it to apple-utils, whose sign +// out would otherwise ask, forget it, and leave the sign-in to ask again. +func TestResetAsksForTheAppleIDOnce(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &oneAnswer{answer: "dev@example.com"} + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: t.TempDir(), Reset: true, Asker: asker} + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + if strings.Join(asker.asked, "|") != "Apple ID (email)" { + t.Errorf("asked %v", asker.asked) + } + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + if helper := lines[len(lines)-1]; !strings.HasSuffix(helper, "EXPO_APPLE_ID=dev@example.com") || !strings.Contains(helper, " --reset true") { + t.Errorf("helper call = %s", helper) + } + + // With the Apple ID known, nothing is asked. + known := &oneAnswer{} + request.Asker, request.AppleID = known, "dev@example.com" + if _, err := adapter.CreateKey(context.Background(), request); err != nil || len(known.asked) != 0 { + t.Errorf("asked %v, %v", known.asked, err) + } +} + +// choosing answers Choose with choice and records the question. +type choosing struct { + asked []string + choice int +} + +func (c *choosing) Ask(question string, _ bool) (string, error) { + return "", errors.New("not expected: " + question) +} + +func (c *choosing) Choose(question string, _ []string) (int, error) { + c.asked = append(c.asked, question) + + return c.choice, nil +} + +func helperCalls(t *testing.T, log string) []string { + t.Helper() + calls, _ := os.ReadFile(log) + var helper []string + for _, line := range strings.Split(strings.TrimSpace(string(calls)), "\n") { + if strings.Contains(line, "helper.js") { + helper = append(helper, line) + } + } + + return helper +} + +func TestPrepareAppIDWithoutAskingAppliesInOneRun(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"teamId":"ABCDE12345","registered":true,"push":true,"created":true,"pushTurnedOn":true}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{TeamID: "ABCDE12345", AppleID: "dev@example.com", SessionDir: t.TempDir()} + + result, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app"}) + if err != nil || result != (apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true, Created: true, PushTurnedOn: true}) { + t.Fatalf("result = %+v, %v", result, err) + } + calls := helperCalls(t, log) + if len(calls) != 1 || !strings.Contains(calls[0], "helper.js --action app-id --mode apply --bundle-id io.example.app --name io example app --team-id ABCDE12345 --out ") { + t.Errorf("helper calls = %v", calls) + } +} + +func TestPrepareAppIDOnlyChecksAReadyAppID(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"teamId":"ABCDE12345","registered":true,"push":true}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &choosing{} + request := apns.Request{AppleID: "dev@example.com", SessionDir: t.TempDir(), Asker: asker} + + result, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}) + if err != nil || !result.Registered || !result.Push || result.Created { + t.Fatalf("result = %+v, %v", result, err) + } + if calls := helperCalls(t, log); len(calls) != 1 || !strings.Contains(calls[0], "--mode check") || len(asker.asked) != 0 { + t.Errorf("helper calls = %v, asked %v", calls, asker.asked) + } +} + +func TestPrepareAppIDAsksBeforeApplying(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"teamId":"ABCDE12345","registered":false,"push":false}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &choosing{} + request := apns.Request{AppleID: "dev@example.com", SessionDir: t.TempDir(), Asker: asker, Reset: true} + + if _, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}); err != nil { + t.Fatal(err) + } + calls := helperCalls(t, log) + if len(calls) != 2 || !strings.Contains(calls[0], "--mode check") || !strings.Contains(calls[0], "--reset true") || + !strings.Contains(calls[1], "--mode apply") || !strings.Contains(calls[1], "--team-id ABCDE12345") || strings.Contains(calls[1], "--reset") || + !strings.Contains(calls[1], "--saved-only true") || !strings.HasSuffix(calls[1], "EXPO_APPLE_ID=dev@example.com") { + t.Errorf("helper calls = %v", calls) + } + if len(asker.asked) != 1 || !strings.HasPrefix(asker.asked[0], "io.example.app is not registered as an App ID on team ABCDE12345") { + t.Errorf("asked %v", asker.asked) + } + + // Declining runs nothing more. + declined := &choosing{choice: 1} + request.Asker = declined + before := len(helperCalls(t, log)) + result, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}) + if err != nil || result.Registered || len(helperCalls(t, log)) != before+1 { + t.Errorf("result = %+v, %v, calls %v", result, err, helperCalls(t, log)) + } +} + +func TestPrepareAppIDWithTheSavedSignInOnly(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"error":"sign-in-needed"}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{SessionDir: t.TempDir(), SavedSignInOnly: true} + + // apple-utils keeps sessions per Apple ID: without one there is none. + if _, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}); !errors.Is(err, apns.ErrSignInNeeded) { + t.Fatalf("err = %v", err) + } + if calls := helperCalls(t, log); len(calls) != 0 { + t.Fatalf("ran the helper without an Apple ID: %v", calls) + } + + request.AppleID = "dev@example.com" + if _, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}); !errors.Is(err, apns.ErrSignInNeeded) { + t.Fatalf("err = %v", err) + } + if calls := helperCalls(t, log); len(calls) != 1 || !strings.Contains(calls[0], "--saved-only true") { + t.Errorf("helper calls = %v", calls) + } +} diff --git a/templates/cli/internal/apns/expo/helper/helper.js b/templates/cli/internal/apns/expo/helper/helper.js new file mode 100644 index 0000000000..6fc8ea96a9 --- /dev/null +++ b/templates/cli/internal/apns/expo/helper/helper.js @@ -0,0 +1,163 @@ +'use strict'; + +// Creates a team-scoped APNs key, or prepares the app's App ID, through the +// Apple Developer portal with @expo/apple-utils, the library eas-cli uses for +// the same jobs. +// +// apple-utils asks for the Apple ID, the password and the two-factor code +// itself, so the CLI runs this script with the terminal attached. Its logging +// goes to stdout, so the result is written to the file named by --out instead. + +const fs = require('fs'); +const { + Auth, + BundleId, + CapabilityType, + CapabilityTypeOption, + InvalidUserCredentialsError, + Keys, +} = require('@expo/apple-utils'); + +function parseArguments(argv) { + const values = {}; + for (let index = 0; index < argv.length; index += 2) { + values[argv[index].replace(/^--/, '')] = argv[index + 1]; + } + if (!values.out || !values.name) { + throw new Error( + 'usage: helper.js [--action app-id --bundle-id --mode check|apply [--saved-only true]] [--team-id ] [--reset true] --name --out ' + ); + } + + return values; +} + +// Apple words a full team as "maximum allowed number of Keys" or, since 2026, +// "maximum allowed number of team scoped Keys ...", which apple-utils does not +// turn into MaxKeysCreatedError, so the message itself is checked too. +const maxKeysMessage = /maximum allowed number of (?:[a-z ]+ )?keys/i; + +function isMaxKeysError(error) { + return ( + error instanceof Keys.MaxKeysCreatedError || + maxKeysMessage.test(error?.rawDump?.resultString ?? '') || + maxKeysMessage.test(error?.message ?? '') + ); +} + +// signIn returns the signed-in auth state, or null when --saved-only finds no +// saved sign-in for EXPO_APPLE_ID. +async function signIn({ 'team-id': teamId, reset, 'saved-only': savedOnly }) { + // --reset deletes apple-utils' saved session for this Apple ID first, so the + // sign-in starts over, two-factor included. + if (reset === 'true') { + await Auth.logoutAsync({}); + } + let authState; + if (savedOnly === 'true') { + authState = await Auth.tryRestoringAuthStateFromUserCredentialsAsync( + { username: process.env.EXPO_APPLE_ID, teamId }, + { autoResolveProvider: true } + ); + if (!authState) { + return null; + } + } else { + // Without a team ID, apple-utils asks which team to use when there are several. + authState = await Auth.loginAsync(teamId ? { teamId } : {}, { autoResolveProvider: true }); + } + const context = authState.context; + if (teamId && context.teamId !== teamId) { + throw new Error(`Signed in to Apple team ${context.teamId}, not ${teamId}`); + } + + return authState; +} + +async function createKey(context, { name }) { + let key; + try { + key = await Keys.createKeyAsync(context, { name, isApns: true }); + } catch (error) { + if (!isMaxKeysError(error)) { + throw error; + } + const keys = await Keys.getKeysAsync(context); + + return { + error: 'max-keys', + keys: keys.map(({ id, name, canRevoke }) => ({ id, name, canRevoke })), + }; + } + const p8 = await Keys.downloadKeyAsync(context, { id: key.id }); + + return { keyId: key.id, teamId: context.teamId, p8 }; +} + +// prepareAppId reports whether --bundle-id is an App ID with Push +// Notifications, and with --mode apply registers it and turns push on. +async function prepareAppId(context, { 'bundle-id': identifier, name, mode }) { + let bundleId = await BundleId.findAsync(context, { identifier }); + const result = { + teamId: context.teamId, + registered: Boolean(bundleId), + push: false, + created: false, + pushTurnedOn: false, + }; + if (bundleId) { + result.push = Boolean(await bundleId.hasCapabilityAsync(CapabilityType.PUSH_NOTIFICATIONS)); + } + if (mode !== 'apply') { + return result; + } + if (!bundleId) { + bundleId = await BundleId.createAsync(context, { name, identifier }); + result.registered = true; + result.created = true; + } + if (!result.push) { + await bundleId.updateBundleIdCapabilityAsync({ + capabilityType: CapabilityType.PUSH_NOTIFICATIONS, + option: CapabilityTypeOption.ON, + }); + result.push = true; + result.pushTurnedOn = true; + } + + return result; +} + +async function run(options) { + const authState = await signIn(options); + if (!authState) { + return { error: 'sign-in-needed' }; + } + const result = + options.action === 'app-id' + ? await prepareAppId(authState.context, options) + : await createKey(authState.context, options); + + // The Apple ID lets later runs reuse this sign-in without asking. + return { ...result, appleId: authState.username }; +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + let result; + try { + result = await run(options); + } catch (error) { + const kind = error instanceof InvalidUserCredentialsError ? 'invalid-credentials' : 'failed'; + result = { error: kind, message: error?.message ?? String(error) }; + } + fs.writeFileSync(options.out, JSON.stringify(result), { mode: 0o600 }); + if (result.error) { + process.exitCode = 1; + } +} + +main().catch(error => { + console.error(error?.message ?? error); + process.exitCode = 1; +}); diff --git a/templates/cli/internal/apns/expo/helper/package-lock.json b/templates/cli/internal/apns/expo/helper/package-lock.json new file mode 100644 index 0000000000..cd61fd039f --- /dev/null +++ b/templates/cli/internal/apns/expo/helper/package-lock.json @@ -0,0 +1,25 @@ +{ + "name": "appwrite-apple-helper", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "appwrite-apple-helper", + "dependencies": { + "@expo/apple-utils": "2.2.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@expo/apple-utils": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@expo/apple-utils/-/apple-utils-2.2.1.tgz", + "integrity": "sha512-Jr0yawuiJ83Lu76krKJYysXfvSC678YK8nIBHUoNjrOAQT392LttbfsoPA+8/zDBZOrfo+8eXgBq0a51b7wBVQ==", + "license": "MIT", + "bin": { + "apple-utils": "bin.js" + } + } + } +} diff --git a/templates/cli/internal/apns/expo/helper/package.json b/templates/cli/internal/apns/expo/helper/package.json new file mode 100644 index 0000000000..2e4c5e4598 --- /dev/null +++ b/templates/cli/internal/apns/expo/helper/package.json @@ -0,0 +1,11 @@ +{ + "name": "appwrite-apple-helper", + "private": true, + "description": "Creates an APNs key through the Apple Developer portal for appwrite init apns --create-key", + "engines": { + "node": ">=18" + }, + "dependencies": { + "@expo/apple-utils": "2.2.1" + } +} diff --git a/templates/cli/internal/apns/expo/runtime.go b/templates/cli/internal/apns/expo/runtime.go new file mode 100644 index 0000000000..fb7e357d4c --- /dev/null +++ b/templates/cli/internal/apns/expo/runtime.go @@ -0,0 +1,278 @@ +package expo + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +// When the machine has no Node.js the expo setup can use, it downloads this +// release, the Active LTS, into the session folder. Nothing is installed +// system-wide. Each build is checked against its SHA-256 from +// https://nodejs.org/dist/v24.21.0/SHASUMS256.txt before it is unpacked. +const nodeRelease = "v24.21.0" + +var nodeChecksums = map[string]string{ + "node-v24.21.0-darwin-arm64.tar.gz": "bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057", + "node-v24.21.0-darwin-x64.tar.gz": "1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097", + "node-v24.21.0-linux-arm64.tar.gz": "724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5", + "node-v24.21.0-linux-x64.tar.gz": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff", + "node-v24.21.0-win-arm64.zip": "8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921", + "node-v24.21.0-win-x64.zip": "158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541", +} + +const defaultNodeDist = "https://nodejs.org/dist" + +// nodeBuild names the official build for goos/goarch, without its extension, +// and the archive format it comes in. +func nodeBuild(goos, goarch string) (string, string, error) { + platform := map[string]string{"darwin": "darwin", "linux": "linux", "windows": "win"}[goos] + arch := map[string]string{"amd64": "x64", "arm64": "arm64"}[goarch] + if platform == "" || arch == "" { + return "", "", fmt.Errorf("no Node.js build to download for %s/%s", goos, goarch) + } + extension := ".tar.gz" + if goos == "windows" { + extension = ".zip" + } + + return "node-" + nodeRelease + "-" + platform + "-" + arch, extension, nil +} + +// nodePaths are the node binary and npm's entry script inside an unpacked +// build. npm is run through node, so its shell wrappers are not needed. +func nodePaths(root, goos string) (string, string) { + if goos == "windows" { + return filepath.Join(root, "node.exe"), filepath.Join(root, "node_modules", "npm", "bin", "npm-cli.js") + } + + return filepath.Join(root, "bin", "node"), filepath.Join(root, "lib", "node_modules", "npm", "bin", "npm-cli.js") +} + +// downloadNode makes sure the pinned Node.js is unpacked under dir and returns +// the node binary and npm's entry script. +func (a *Adapter) downloadNode(ctx context.Context, dir string, log func(string, ...any)) (string, string, error) { + goos, goarch := a.platform() + name, extension, err := nodeBuild(goos, goarch) + if err != nil { + return "", "", err + } + root := filepath.Join(dir, name) + node, npm := nodePaths(root, goos) + if _, err := os.Stat(npm); err == nil { + return node, npm, nil + } + archive := name + extension + checksum, ok := nodeChecksums[archive] + if !ok { + return "", "", fmt.Errorf("no checksum for %s", archive) + } + + dist := a.NodeDist + if dist == "" { + dist = defaultNodeDist + } + log("Node.js %d or later was not found. Downloading Node.js %s (about 50 MB, 200 MB unpacked) into %s ...", minimumNodeMajor, nodeRelease, dir) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", "", err + } + downloaded, err := a.fetch(ctx, dist+"/"+nodeRelease+"/"+archive, dir, checksum) + if err != nil { + return "", "", err + } + defer os.Remove(downloaded) + + // Unpack next to the final folder and move it into place, so a failed or + // interrupted unpack never looks like a usable runtime. + staging, err := os.MkdirTemp(dir, ".node-") + if err != nil { + return "", "", err + } + defer os.RemoveAll(staging) + if extension == ".zip" { + err = unzip(downloaded, staging) + } else { + err = untar(downloaded, staging) + } + if err != nil { + return "", "", fmt.Errorf("could not unpack %s: %w", archive, err) + } + // Publishing is a rename, which fails when another run published first; + // that runtime is complete, so it is used rather than replaced. An + // existing runtime is never removed, as another run may be using it. + if err := os.Rename(filepath.Join(staging, name), root); err != nil { + if _, statErr := os.Stat(npm); statErr == nil { + return node, npm, nil + } + + return "", "", fmt.Errorf("could not unpack %s: %w", archive, err) + } + if _, err := os.Stat(npm); err != nil { + return "", "", fmt.Errorf("%s has no npm", archive) + } + + return node, npm, nil +} + +func (a *Adapter) platform() (string, string) { + if a.GOOS != "" { + return a.GOOS, a.GOARCH + } + + return runtime.GOOS, runtime.GOARCH +} + +// fetch downloads url into a file in dir and checks its SHA-256. +func (a *Adapter) fetch(ctx context.Context, url, dir, checksum string) (string, error) { + client := a.HTTP + if client == nil { + client = &http.Client{Timeout: 10 * time.Minute} + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + response, err := client.Do(request) + if err != nil { + return "", fmt.Errorf("could not download Node.js: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("could not download Node.js from %s (HTTP %d)", url, response.StatusCode) + } + + file, err := os.CreateTemp(dir, ".node-download-") + if err != nil { + return "", err + } + hash := sha256.New() + _, err = io.Copy(io.MultiWriter(file, hash), response.Body) + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + os.Remove(file.Name()) + + return "", fmt.Errorf("could not download Node.js: %w", err) + } + if got := hex.EncodeToString(hash.Sum(nil)); got != checksum { + os.Remove(file.Name()) + + return "", fmt.Errorf("the Node.js download from %s does not match its checksum (got %s)", url, got) + } + + return file.Name(), nil +} + +// inside returns where name unpacks under root, refusing anything that would +// land outside it. +func inside(root, name string) (string, error) { + clean := filepath.Clean(filepath.FromSlash(name)) + if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("unsafe path %q in the archive", name) + } + + return filepath.Join(root, clean), nil +} + +func writeFile(path string, mode os.FileMode, contents io.Reader) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode&0o755|0o600) + if err != nil { + return err + } + _, err = io.Copy(file, contents) + if closeErr := file.Close(); err == nil { + err = closeErr + } + + return err +} + +// untar unpacks a .tar.gz into root. Symbolic links are skipped: they are +// only npm's and npx's shell entry points, and npm is run through node. +func untar(archive, root string) error { + file, err := os.Open(archive) + if err != nil { + return err + } + defer file.Close() + compressed, err := gzip.NewReader(file) + if err != nil { + return err + } + reader := tar.NewReader(compressed) + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + path, err := inside(root, header.Name) + if err != nil { + return err + } + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(path, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := writeFile(path, header.FileInfo().Mode(), reader); err != nil { + return err + } + } + } +} + +// unzip unpacks a .zip into root, with the same rules as untar. +func unzip(archive, root string) error { + reader, err := zip.OpenReader(archive) + if err != nil { + return err + } + defer reader.Close() + for _, entry := range reader.File { + path, err := inside(root, entry.Name) + if err != nil { + return err + } + if entry.FileInfo().IsDir() { + if err := os.MkdirAll(path, 0o755); err != nil { + return err + } + + continue + } + if !entry.FileInfo().Mode().IsRegular() { + continue + } + contents, err := entry.Open() + if err != nil { + return err + } + err = writeFile(path, entry.FileInfo().Mode(), contents) + contents.Close() + if err != nil { + return err + } + } + + return nil +} diff --git a/templates/cli/internal/apns/expo/runtime_test.go b/templates/cli/internal/apns/expo/runtime_test.go new file mode 100644 index 0000000000..5d7c2319e2 --- /dev/null +++ b/templates/cli/internal/apns/expo/runtime_test.go @@ -0,0 +1,237 @@ +package expo + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +func TestNodeBuild(t *testing.T) { + for _, test := range []struct{ goos, goarch, name, extension string }{ + {"darwin", "arm64", "node-v24.21.0-darwin-arm64", ".tar.gz"}, + {"darwin", "amd64", "node-v24.21.0-darwin-x64", ".tar.gz"}, + {"linux", "amd64", "node-v24.21.0-linux-x64", ".tar.gz"}, + {"windows", "arm64", "node-v24.21.0-win-arm64", ".zip"}, + } { + name, extension, err := nodeBuild(test.goos, test.goarch) + if err != nil || name != test.name || extension != test.extension { + t.Errorf("nodeBuild(%s, %s) = %s, %s, %v", test.goos, test.goarch, name, extension, err) + } + if _, ok := nodeChecksums[name+extension]; !ok { + t.Errorf("no checksum for %s%s", name, extension) + } + } + if _, _, err := nodeBuild("plan9", "386"); err == nil { + t.Error("found a build for plan9/386") + } +} + +type entry struct { + name string + mode int64 + contents string + link string +} + +func tarball(t *testing.T, entries []entry) []byte { + t.Helper() + var buffer bytes.Buffer + compressed := gzip.NewWriter(&buffer) + writer := tar.NewWriter(compressed) + for _, item := range entries { + header := &tar.Header{Name: item.name, Mode: item.mode, Size: int64(len(item.contents)), Typeflag: tar.TypeReg} + if item.link != "" { + header = &tar.Header{Name: item.name, Mode: 0o777, Linkname: item.link, Typeflag: tar.TypeSymlink} + } + if err := writer.WriteHeader(header); err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte(item.contents)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := compressed.Close(); err != nil { + t.Fatal(err) + } + + return buffer.Bytes() +} + +// fakeNodeBuild is a Node.js build whose node is a shell script: it answers +// --version, acts as npm when given npm-cli.js, and otherwise runs as the +// helper, writing a key to the --out file. +func fakeNodeBuild(t *testing.T) (string, []byte) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell stand-ins") + } + name, _, err := nodeBuild(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skip(err) + } + node := `#!/bin/sh +if [ "$1" = "--version" ]; then echo "v24.21.0"; exit 0; fi +case "$1" in + *npm-cli.js) mkdir -p node_modules/@expo/apple-utils && echo '{}' > node_modules/@expo/apple-utils/package.json; exit 0 ;; +esac +while [ $# -gt 0 ]; do + if [ "$1" = "--out" ]; then printf '%s' '{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}' > "$2"; fi + shift +done +` + + return name, tarball(t, []entry{ + {name: name + "/bin/node", mode: 0o755, contents: node}, + {name: name + "/lib/node_modules/npm/bin/npm-cli.js", mode: 0o644, contents: "// npm"}, + {name: name + "/bin/npm", link: "../lib/node_modules/npm/bin/npm-cli.js"}, + }) +} + +// serve answers the Node.js download, counting requests, and makes archive +// the expected build for this machine. +func serve(t *testing.T, name string, archive []byte) (*httptest.Server, *atomic.Int32) { + t.Helper() + sum := sha256.Sum256(archive) + previous := nodeChecksums[name+".tar.gz"] + nodeChecksums[name+".tar.gz"] = hex.EncodeToString(sum[:]) + t.Cleanup(func() { nodeChecksums[name+".tar.gz"] = previous }) + + requests := &atomic.Int32{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.URL.Path != "/"+nodeRelease+"/"+name+".tar.gz" { + w.WriteHeader(http.StatusNotFound) + + return + } + _, _ = w.Write(archive) + })) + t.Cleanup(server.Close) + + return server, requests +} + +// withoutNode leaves only mkdir on PATH, so neither node nor npm is found. +func withoutNode(t *testing.T) { + t.Helper() + bin := t.TempDir() + if err := os.Symlink("/bin/mkdir", filepath.Join(bin, "mkdir")); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) +} + +func TestCreateKeyDownloadsNodeWhenMissing(t *testing.T) { + name, archive := fakeNodeBuild(t) + server, requests := serve(t, name, archive) + withoutNode(t) + + adapter := &Adapter{NodeDist: server.URL, HTTP: server.Client(), Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + var logged []string + request := apns.Request{ + TeamID: "ABCDE12345", Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: t.TempDir(), + Log: func(format string, args ...any) { logged = append(logged, format) }, + } + + for range 2 { + key, err := adapter.CreateKey(context.Background(), request) + if err != nil || key.KeyID != "KEY1234567" { + t.Fatalf("key = %+v, %v", key, err) + } + } + if requests.Load() != 1 { + t.Errorf("downloaded Node.js %d times", requests.Load()) + } + if len(logged) != 1 || !strings.Contains(logged[0], "Downloading Node.js %s") { + t.Errorf("logged %v", logged) + } + root := filepath.Join(request.SessionDir, "runtime", name) + if info, err := os.Stat(filepath.Join(root, "bin", "node")); err != nil || info.Mode().Perm()&0o100 == 0 { + t.Errorf("node = %v, %v", info, err) + } + if _, err := os.Lstat(filepath.Join(root, "bin", "npm")); err == nil { + t.Error("unpacked a symbolic link") + } +} + +func TestDownloadNodeRejectsABadChecksum(t *testing.T) { + name, archive := fakeNodeBuild(t) + server, _ := serve(t, name, archive) + nodeChecksums[name+".tar.gz"] = strings.Repeat("0", 64) + withoutNode(t) + + adapter := &Adapter{NodeDist: server.URL, HTTP: server.Client()} + dir := t.TempDir() + _, err := adapter.CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: dir}) + if err == nil || !strings.Contains(err.Error(), "does not match its checksum") { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "runtime", name)); err == nil { + t.Error("unpacked a download that failed its checksum") + } +} + +func TestUntarRefusesPathsOutsideTheFolder(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "root") + archive := filepath.Join(parent, "evil.tar.gz") + if err := os.WriteFile(archive, tarball(t, []entry{ + {name: "../evil", mode: 0o644, contents: "x"}, + }), 0o600); err != nil { + t.Fatal(err) + } + + if err := untar(archive, root); err == nil || !strings.Contains(err.Error(), "unsafe path") { + t.Errorf("err = %v", err) + } + if _, err := os.Stat(filepath.Join(parent, "evil")); err == nil { + t.Error("wrote outside the folder") + } +} + +func TestConcurrentFirstRunsShareOneRuntime(t *testing.T) { + name, archive := fakeNodeBuild(t) + server, _ := serve(t, name, archive) + dir := t.TempDir() + + const runs = 4 + errs := make(chan error, runs) + for range runs { + go func() { + adapter := &Adapter{NodeDist: server.URL, HTTP: server.Client()} + node, _, err := adapter.downloadNode(context.Background(), dir, func(string, ...any) {}) + if err == nil { + _, err = os.Stat(node) + } + errs <- err + }() + } + for range runs { + if err := <-errs; err != nil { + t.Error(err) + } + } + if _, err := os.Stat(filepath.Join(dir, name, "lib", "node_modules", "npm", "bin", "npm-cli.js")); err != nil { + t.Errorf("runtime is incomplete after concurrent runs: %v", err) + } + leftovers, _ := filepath.Glob(filepath.Join(dir, ".node-*")) + if len(leftovers) != 0 { + t.Errorf("left staging files behind: %v", leftovers) + } +} diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go new file mode 100644 index 0000000000..9743caea1d --- /dev/null +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -0,0 +1,489 @@ +// Package fastlane is the APNs key setup built on fastlane's spaceship, the +// open-source Apple Developer portal client. +// +// It runs a lane from a Fastfile embedded in the binary with the fastlane the +// person already has installed and brings no Ruby of its own: the project's +// Bundler fastlane when its Gemfile lists one, otherwise fastlane from PATH +// (a gem or Homebrew installation). It needs fastlane 2.225.0 or +// later, the first release whose spaceship signs in with SRP. +package fastlane + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +//go:embed helper/Fastfile +var fastfile []byte + +// fastlane 2.225.0 (October 2024) is the first that signs in with SRP, which +// Apple has required since then; earlier versions get "503 Service +// Temporarily Unavailable" when signing in. +var minimumVersion = [3]int{2, 225, 0} + +// Adapter creates APNs keys, and prepares App IDs, with an installed fastlane. +type Adapter struct { + // Fastlane is the command to run. Empty means the project's Bundler + // fastlane when there is one, otherwise "fastlane" from PATH. + Fastlane string + // Bundle is Bundler's command. Empty means "bundle" from PATH. + Bundle string + // Brew is Homebrew's command, offered to install a missing fastlane. + // Empty means "brew" from PATH. + Brew string + // WorkDir is the project folder whose Gemfile may list fastlane. Empty + // means the current directory. + WorkDir string + // spaceship prompts for the two-factor code, and for a team when the + // account has several, on the terminal. Nil means the process's own. + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// New returns the adapter using fastlane from PATH and the terminal. +func New() *Adapter { + return &Adapter{} +} + +// Name is how people pick this setup. +func (a *Adapter) Name() string { + return "fastlane" +} + +type result struct { + KeyID string `json:"keyId"` + TeamID string `json:"teamId"` + P8 string `json:"p8"` + Error string `json:"error"` + Message string `json:"message"` + Keys []struct { + ID string `json:"id"` + Name string `json:"name"` + CanRevoke bool `json:"canRevoke"` + } `json:"keys"` + // The prepare_app_id lane's result. + Registered bool `json:"registered"` + Push bool `json:"push"` + Created bool `json:"created"` + PushTurnedOn bool `json:"pushTurnedOn"` +} + +// CreateKey runs the embedded create_apns_key lane. +func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { + arguments := []string{"name:" + request.Name, "environment:" + string(request.Environment)} + contents, appleID, err := a.runLane(ctx, request, "create_apns_key", arguments...) + if err != nil { + return apns.Key{}, err + } + key, err := decodeResult(contents) + key.AppleID = appleID + + return key, err +} + +// PrepareAppID runs the embedded prepare_app_id lane: it checks the App ID, +// and after the question, or straight away without one, registers it and +// turns on Push Notifications. +func (a *Adapter) PrepareAppID(ctx context.Context, request apns.Request, appID apns.AppIDRequest) (apns.AppIDResult, error) { + // spaceship keeps its saved sign-in per Apple ID, so without one there is + // nothing to restore. + if request.SavedSignInOnly && strings.TrimSpace(request.AppleID) == "" { + return apns.AppIDResult{}, apns.ErrSignInNeeded + } + run := func(mode string) (apns.AppIDResult, error) { + arguments := []string{"mode:" + mode, "bundle_id:" + appID.BundleID, "name:" + apns.AppIDName(appID.BundleID)} + if request.SavedSignInOnly { + arguments = append(arguments, "saved_only:true") + } + contents, appleID, err := a.runLane(ctx, request, "prepare_app_id", arguments...) + if err != nil { + return apns.AppIDResult{}, err + } + // The apply run reuses the check's sign-in instead of asking again. + request.AppleID = appleID + + return decodeAppIDResult(contents) + } + if !appID.Ask { + return run("apply") + } + found, err := run("check") + if err != nil || (found.Registered && found.Push) { + return found, err + } + change, err := apns.ConfirmAppIDChange(request.Asker, appID.BundleID, found.TeamID, found.Registered) + if err != nil || !change { + return found, err + } + // The check signed in (again, for a reset) and saved the session, so the + // change reuses it instead of asking again. + request.Reset = false + request.TeamID = found.TeamID + request.SavedSignInOnly = true + + return run("apply") +} + +// runLane runs a lane of the embedded Fastfile and returns the result it +// wrote, with the Apple ID it ran as. The Apple ID and password are asked here when the request has none, +// and handed to fastlane as FASTLANE_USER and FASTLANE_PASSWORD, so fastlane +// never offers to keep them in the Keychain. With SavedSignInOnly nothing is +// asked: the lane uses spaceship's saved session for the Apple ID or reports +// that there is none. +func (a *Adapter) runLane(ctx context.Context, request apns.Request, lane string, arguments ...string) ([]byte, string, error) { + runner, err := a.runner() + if err != nil { + return nil, "", err + } + err = runner.check(ctx) + // Installing is only offered for fastlane from PATH, never replacing a + // configured command, and never during a check that must not ask. + if errors.Is(err, errNotInstalled) && a.Fastlane == "" && !request.SavedSignInOnly { + if err = a.offerInstall(ctx, request); err == nil { + err = runner.check(ctx) + } + } + if err != nil { + return nil, "", err + } + if runner.gemfile != "" && request.Log != nil { + request.Log("Using fastlane from %s with bundle exec.", runner.gemfile) + } + + appleID := strings.TrimSpace(request.AppleID) + if appleID == "" { + answer, err := request.Asker.Ask("Apple ID (email)", false) + if err != nil { + return nil, "", err + } + appleID = strings.TrimSpace(answer) + } + password := request.Password + if password == "" && !request.SavedSignInOnly { + answer, err := request.Asker.Ask("Password for "+appleID, true) + if err != nil { + return nil, "", err + } + password = answer + } + + // fastlane runs the Fastfile in ./fastlane of its working directory. + scratch, err := os.MkdirTemp("", "appwrite-apns-fastlane-") + if err != nil { + return nil, "", err + } + defer os.RemoveAll(scratch) + if err := os.MkdirAll(filepath.Join(scratch, "fastlane"), 0o700); err != nil { + return nil, "", err + } + if err := os.WriteFile(filepath.Join(scratch, "fastlane", "Fastfile"), fastfile, 0o600); err != nil { + return nil, "", err + } + out := filepath.Join(scratch, "result.json") + + arguments = append([]string{lane, "out:" + out}, arguments...) + if request.TeamID != "" { + arguments = append(arguments, "team_id:"+request.TeamID) + } + if request.Reset { + arguments = append(arguments, "reset:true") + } + command := runner.command(ctx, arguments...) + command.Dir = scratch + stdin, stdout, stderr := a.terminal() + // A saved sign-in asks nothing, so fastlane's log is shown only if the lane fails. + var log bytes.Buffer + if request.SavedSignInOnly { + stdout, stderr = &log, &log + } + command.Stdin, command.Stdout, command.Stderr = stdin, stdout, stderr + command.Env = append(command.Env, + "FASTLANE_USER="+appleID, + "FASTLANE_SKIP_UPDATE_CHECK=1", + "FASTLANE_OPT_OUT_USAGE=1", + "FASTLANE_HIDE_CHANGELOG=1", + ) + if password != "" { + command.Env = append(command.Env, "FASTLANE_PASSWORD="+password) + } + runErr := command.Run() + if runErr != nil && log.Len() > 0 { + _, _, terminalErr := a.terminal() + _, _ = log.WriteTo(terminalErr) + } + + contents, err := os.ReadFile(out) + if err != nil { + if runErr != nil { + return nil, "", fmt.Errorf("the fastlane lane failed: %w", runErr) + } + + return nil, "", fmt.Errorf("the fastlane lane returned no result: %w", err) + } + + return contents, appleID, nil +} + +func decodeAppIDResult(contents []byte) (apns.AppIDResult, error) { + var decoded result + if err := json.Unmarshal(contents, &decoded); err != nil { + return apns.AppIDResult{}, fmt.Errorf("the fastlane lane returned an unreadable result: %w", err) + } + if err := decoded.err(); err != nil { + return apns.AppIDResult{}, err + } + + return apns.AppIDResult{ + TeamID: decoded.TeamID, + Registered: decoded.Registered, + Push: decoded.Push, + Created: decoded.Created, + PushTurnedOn: decoded.PushTurnedOn, + }, nil +} + +// err is the error the lane reported, other than a full team. +func (r result) err() error { + switch r.Error { + case "": + return nil + case "sign-in-needed": + return apns.ErrSignInNeeded + case "invalid-credentials": + return fmt.Errorf("%w: %s", apns.ErrInvalidCredentials, r.Message) + default: + if r.Message == "" { + return errors.New(r.Error) + } + + return errors.New(r.Message) + } +} + +func decodeResult(contents []byte) (apns.Key, error) { + var decoded result + if err := json.Unmarshal(contents, &decoded); err != nil { + return apns.Key{}, fmt.Errorf("the fastlane lane returned an unreadable result: %w", err) + } + switch decoded.Error { + case "": + case "max-keys": + keys := make([]apns.ExistingKey, len(decoded.Keys)) + for index, key := range decoded.Keys { + keys[index] = apns.ExistingKey{ID: key.ID, Name: key.Name, CanRevoke: key.CanRevoke} + } + + return apns.Key{}, &apns.MaxKeysError{Keys: keys} + default: + return apns.Key{}, decoded.err() + } + if decoded.KeyID == "" || decoded.P8 == "" { + return apns.Key{}, errors.New("the fastlane lane returned no key") + } + + return apns.Key{KeyID: decoded.KeyID, TeamID: decoded.TeamID, P8: decoded.P8}, nil +} + +func (a *Adapter) terminal() (io.Reader, io.Writer, io.Writer) { + stdin, stdout, stderr := a.Stdin, a.Stdout, a.Stderr + if stdin == nil { + stdin = os.Stdin + } + if stdout == nil { + stdout = os.Stdout + } + if stderr == nil { + stderr = os.Stderr + } + + return stdin, stdout, stderr +} + +// runner is how fastlane is started: a command, the arguments before the +// lane's, and the environment it needs. +type runner struct { + name string + prefix []string + env []string + gemfile string +} + +var gemfileFastlane = regexp.MustCompile(`(?m)^\s*gem\s+['"]fastlane['"]`) + +// runner picks the configured command, then the project's Bundler fastlane, +// then fastlane from PATH. A Bundler fastlane must run with bundle exec and +// the project's Gemfile, since the lane runs in a temporary folder. When the +// project pins fastlane but Bundler is missing, it stops rather than run a +// fastlane other than the pinned one. +func (a *Adapter) runner() (runner, error) { + base := append(os.Environ(), "FASTLANE_SKIP_UPDATE_CHECK=1", "FASTLANE_OPT_OUT_USAGE=1") + if a.Fastlane != "" { + return runner{name: a.Fastlane, env: base}, nil + } + bundle := a.Bundle + if bundle == "" { + bundle = "bundle" + } + if gemfile := projectGemfile(a.WorkDir); gemfile != "" { + if _, err := exec.LookPath(bundle); err != nil { + return runner{}, fmt.Errorf("%w: %s pins fastlane, but Bundler is not installed. Install it with gem install bundler, then run bundle install there", apns.ErrUnavailable, gemfile) + } + + return runner{name: bundle, prefix: []string{"exec", "fastlane"}, env: append(base, "BUNDLE_GEMFILE="+gemfile), gemfile: gemfile}, nil + } + + return runner{name: "fastlane", env: base}, nil +} + +// projectGemfile returns the Gemfile in dir, or in dir/ios as Flutter and +// React Native projects keep it, when it lists fastlane. +func projectGemfile(dir string) string { + if dir == "" { + dir, _ = os.Getwd() + } + for _, candidate := range []string{filepath.Join(dir, "Gemfile"), filepath.Join(dir, "ios", "Gemfile")} { + contents, err := os.ReadFile(candidate) + if err != nil || !gemfileFastlane.Match(contents) { + continue + } + if absolute, err := filepath.Abs(candidate); err == nil { + return absolute + } + } + + return "" +} + +func (r runner) command(ctx context.Context, arguments ...string) *exec.Cmd { + command := exec.CommandContext(ctx, r.name, append(append([]string{}, r.prefix...), arguments...)...) + command.Env = append([]string{}, r.env...) + + return command +} + +// errNotInstalled means there is no fastlane on PATH, which installing one +// can fix. +var errNotInstalled = errors.New("fastlane is not installed") + +func (r runner) check(ctx context.Context) error { + if r.gemfile == "" && r.name == "fastlane" { + if _, err := exec.LookPath(r.name); err != nil { + return fmt.Errorf("%w: %w. %s", apns.ErrUnavailable, errNotInstalled, installHint) + } + } + output, err := r.command(ctx, "--version").Output() + if err != nil { + if r.gemfile != "" { + return fmt.Errorf("%w: bundle exec fastlane failed with %s. Run bundle install there first", apns.ErrUnavailable, r.gemfile) + } + + return fmt.Errorf("%w: %s --version failed: %v", apns.ErrUnavailable, r.name, err) + } + upgrade := "brew upgrade fastlane, or gem update fastlane, depending on how it was installed" + if r.gemfile != "" { + upgrade = bundlerUpgrade(filepath.Dir(r.gemfile), runtime.GOOS) + } + + return supportedVersion(string(output), upgrade) +} + +// bundlerUpgrade says how to upgrade a project's Bundler fastlane. On Unix +// it is one command to paste, with the folder quoted for a POSIX shell. On +// Windows no quoting is safe in every shell (PowerShell expands $ inside +// double quotes, cmd expands %, and Windows PowerShell has no &&), so the +// folder is named rather than put in a command. +func bundlerUpgrade(folder, goos string) string { + if goos == "windows" { + return "bundle update fastlane, run from the folder " + folder + } + + return "cd " + shellQuote(folder) + " && bundle update fastlane" +} + +// shellQuote quotes path for a POSIX shell: in single quotes, with any single +// quote closed, escaped and reopened. A path of only safe characters is left +// as it is. +func shellQuote(path string) string { + if path != "" && strings.Trim(path, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/._-") == "" { + return path + } + + return "'" + strings.ReplaceAll(path, "'", `'\''`) + "'" +} + +const installHint = "Install it with brew install fastlane (Homebrew) or gem install fastlane, " + + "or add gem \"fastlane\" to your project's Gemfile and run bundle install. " + + "Or use --provider appwrite, which needs nothing installed" + +// offerInstall asks to install fastlane with Homebrew, when there is one, and +// runs it on the terminal. Without Homebrew, a terminal or a yes, it returns +// how to install fastlane instead. +func (a *Adapter) offerInstall(ctx context.Context, request apns.Request) error { + notInstalled := fmt.Errorf("%w: %w. %s", apns.ErrUnavailable, errNotInstalled, installHint) + brew := a.Brew + if brew == "" { + brew = "brew" + } + if _, err := exec.LookPath(brew); err != nil || request.Asker == nil { + return notInstalled + } + choice, err := request.Asker.Choose("fastlane is not installed. Install it with Homebrew now?", + []string{"Yes, run brew install fastlane", "No"}) + if err != nil || choice != 0 { + return notInstalled + } + + if request.Log != nil { + request.Log("Running brew install fastlane ...") + } + command := exec.CommandContext(ctx, brew, "install", "fastlane") + command.Stdin, command.Stdout, command.Stderr = a.terminal() + if err := command.Run(); err != nil { + return fmt.Errorf("%w: brew install fastlane failed: %v. %s", apns.ErrUnavailable, err, installHint) + } + + return nil +} + +var versionPattern = regexp.MustCompile(`fastlane (\d+)\.(\d+)\.(\d+)`) + +func supportedVersion(output, upgrade string) error { + match := versionPattern.FindStringSubmatch(output) + if match == nil { + return fmt.Errorf("%w: could not read the fastlane version", apns.ErrUnavailable) + } + var version [3]int + for index := range version { + version[index], _ = strconv.Atoi(match[index+1]) + } + for index := range version { + if version[index] != minimumVersion[index] { + if version[index] < minimumVersion[index] { + return fmt.Errorf("%w: the fastlane setup needs fastlane %s or later, found %s. Upgrade it with %s", apns.ErrUnavailable, versionString(minimumVersion), versionString(version), upgrade) + } + + break + } + } + + return nil +} + +func versionString(version [3]int) string { + return fmt.Sprintf("%d.%d.%d", version[0], version[1], version[2]) +} diff --git a/templates/cli/internal/apns/fastlane/adapter_test.go b/templates/cli/internal/apns/fastlane/adapter_test.go new file mode 100644 index 0000000000..0ba41ab71d --- /dev/null +++ b/templates/cli/internal/apns/fastlane/adapter_test.go @@ -0,0 +1,542 @@ +package fastlane + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +func TestSupportedVersion(t *testing.T) { + for output, supported := range map[string]bool{ + "fastlane installation at path:\n/opt/homebrew/bin/fastlane\n-----------------------------\nfastlane 2.228.0\n": true, + "fastlane 2.225.0": true, + "fastlane 3.0.0": true, + "fastlane 2.224.9": false, + "fastlane 1.300.0": false, + "something else": false, + } { + err := supportedVersion(output, "brew upgrade fastlane") + if (err == nil) != supported { + t.Errorf("supportedVersion(%q) = %v", output, err) + } + if err != nil && !errors.Is(err, apns.ErrUnavailable) { + t.Errorf("supportedVersion(%q) is not ErrUnavailable: %v", output, err) + } + } +} + +func TestDecodeResult(t *testing.T) { + key, err := decodeResult([]byte(`{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"-----BEGIN PRIVATE KEY-----"}`)) + if err != nil || key.KeyID != "KEY1234567" || key.TeamID != "ABCDE12345" { + t.Errorf("key = %+v, %v", key, err) + } + + _, err = decodeResult([]byte(`{"error":"max-keys","keys":[{"id":"OLD1234567","name":"Old","canRevoke":true}]}`)) + var maxKeys *apns.MaxKeysError + if !errors.As(err, &maxKeys) || len(maxKeys.Keys) != 1 || maxKeys.Keys[0].ID != "OLD1234567" || !maxKeys.Keys[0].CanRevoke { + t.Errorf("max keys = %v", err) + } + + _, err = decodeResult([]byte(`{"error":"invalid-credentials","message":"Invalid username and password combination"}`)) + if !errors.Is(err, apns.ErrInvalidCredentials) { + t.Errorf("credentials = %v", err) + } + + if _, err := decodeResult([]byte(`{"error":"failed","message":"Apple is down"}`)); err == nil || err.Error() != "Apple is down" { + t.Errorf("failure = %v", err) + } + if _, err := decodeResult([]byte(`{}`)); err == nil { + t.Error("an empty result was accepted") + } +} + +// fakeFastlane writes a fastlane stand-in that answers --version with version +// and, for the lane, logs its arguments and environment, checks the Fastfile +// is in place and writes result to the out: file. +func fakeFastlane(t *testing.T, version, result string) (string, string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell stand-in") + } + bin := t.TempDir() + log := filepath.Join(bin, "log") + script := `#!/bin/sh +if [ "$1" = "--version" ]; then printf 'fastlane installation at path:\n/usr/local/bin/fastlane\nfastlane ` + version + `\n'; exit 0; fi +test -f fastlane/Fastfile || { echo "no Fastfile" >> "` + log + `"; exit 1; } +password=unset +[ -n "$FASTLANE_PASSWORD" ] && password=set +echo "fastlane $* FASTLANE_USER=$FASTLANE_USER password=$password" >> "` + log + `" +for argument in "$@"; do + case "$argument" in + out:*) printf '%s' '` + result + `' > "${argument#out:}" ;; + esac +done +` + path := filepath.Join(bin, "fastlane") + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + + return path, log +} + +type scriptedAsker struct { + answers map[string]string + asked []string +} + +func (s *scriptedAsker) Ask(question string, secret bool) (string, error) { + s.asked = append(s.asked, question) + for prefix, answer := range s.answers { + if strings.HasPrefix(question, prefix) { + return answer, nil + } + } + + return "", errors.New("no answer for " + question) +} + +func (s *scriptedAsker) Choose(string, []string) (int, error) { + return 0, errors.New("not expected") +} + +func TestCreateKeyRunsTheLane(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &scriptedAsker{answers: map[string]string{"Apple ID": "dev@example.com", "Password for": "secret"}} + + key, err := adapter.CreateKey(context.Background(), apns.Request{ + TeamID: "ABCDE12345", + Name: "Appwrite Push", + Environment: apns.EnvironmentSandbox, + Asker: asker, + }) + if err != nil || key.KeyID != "KEY1234567" || key.P8 != "pem" || key.AppleID != "dev@example.com" { + t.Fatalf("key = %+v, %v", key, err) + } + if strings.Join(asker.asked, "|") != "Apple ID (email)|Password for dev@example.com" { + t.Errorf("asked %v", asker.asked) + } + calls, _ := os.ReadFile(log) + call := strings.TrimSpace(string(calls)) + if !strings.HasPrefix(call, "fastlane create_apns_key out:") || + !strings.HasSuffix(call, "name:Appwrite Push environment:sandbox team_id:ABCDE12345 FASTLANE_USER=dev@example.com password=set") { + t.Errorf("call = %s", call) + } +} + +func TestCreateKeyUsesTheCredentialsInTheRequest(t *testing.T) { + fastlane, _ := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &scriptedAsker{} + + _, err := adapter.CreateKey(context.Background(), apns.Request{ + Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", Asker: asker, + }) + if err != nil { + t.Fatal(err) + } + if len(asker.asked) != 0 { + t.Errorf("asked %v", asker.asked) + } +} + +func TestCreateKeyNeedsARecentFastlane(t *testing.T) { + old, log := fakeFastlane(t, "2.220.0", `{}`) + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: &scriptedAsker{}} + + if _, err := (&Adapter{Fastlane: old}).CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) || + !strings.Contains(err.Error(), "found 2.220.0") { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(log); err == nil { + t.Error("ran the lane with an old fastlane") + } + + missing := &Adapter{Fastlane: filepath.Join(t.TempDir(), "fastlane")} + if _, err := missing.CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("missing fastlane err = %v", err) + } +} + +func TestProjectGemfile(t *testing.T) { + root := t.TempDir() + if gemfile := projectGemfile(root); gemfile != "" { + t.Errorf("found %s in an empty folder", gemfile) + } + + if err := os.WriteFile(filepath.Join(root, "Gemfile"), []byte("source \"https://rubygems.org\"\ngem \"cocoapods\"\n"), 0o600); err != nil { + t.Fatal(err) + } + if gemfile := projectGemfile(root); gemfile != "" { + t.Errorf("used a Gemfile without fastlane: %s", gemfile) + } + + if err := os.MkdirAll(filepath.Join(root, "ios"), 0o700); err != nil { + t.Fatal(err) + } + ios := filepath.Join(root, "ios", "Gemfile") + if err := os.WriteFile(ios, []byte("source 'https://rubygems.org'\n\n gem 'fastlane', '~> 2.228'\n"), 0o600); err != nil { + t.Fatal(err) + } + if gemfile := projectGemfile(root); gemfile != ios { + t.Errorf("projectGemfile = %q, want %q", gemfile, ios) + } +} + +func TestCreateKeyRunsTheProjectsBundlerFastlane(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell stand-in") + } + project := t.TempDir() + gemfile := filepath.Join(project, "Gemfile") + if err := os.WriteFile(gemfile, []byte("gem \"fastlane\"\n"), 0o600); err != nil { + t.Fatal(err) + } + bin := t.TempDir() + log := filepath.Join(bin, "log") + bundle := filepath.Join(bin, "bundle") + script := `#!/bin/sh +echo "bundle $* BUNDLE_GEMFILE=$BUNDLE_GEMFILE" >> "` + log + `" +[ "$1 $2" = "exec fastlane" ] || exit 1 +if [ "$3" = "--version" ]; then echo "fastlane 2.228.0"; exit 0; fi +test -f fastlane/Fastfile || exit 1 +for argument in "$@"; do + case "$argument" in + out:*) printf '%s' '{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}' > "${argument#out:}" ;; + esac +done +` + if err := os.WriteFile(bundle, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + adapter := &Adapter{Bundle: bundle, WorkDir: project, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + var logged []string + request := apns.Request{ + Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", + Log: func(format string, args ...any) { logged = append(logged, fmt.Sprintf(format, args...)) }, + } + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + if len(lines) != 2 || lines[0] != "bundle exec fastlane --version BUNDLE_GEMFILE="+gemfile || + !strings.HasPrefix(lines[1], "bundle exec fastlane create_apns_key out:") || !strings.HasSuffix(lines[1], "BUNDLE_GEMFILE="+gemfile) { + t.Errorf("calls:\n%s", calls) + } + if len(logged) != 1 || logged[0] != "Using fastlane from "+gemfile+" with bundle exec." { + t.Errorf("logged %v", logged) + } +} + +func TestCreateKeyWillNotBypassAPinnedFastlane(t *testing.T) { + project := t.TempDir() + gemfile := filepath.Join(project, "Gemfile") + if err := os.WriteFile(gemfile, []byte("gem \"fastlane\"\n"), 0o600); err != nil { + t.Fatal(err) + } + adapter := &Adapter{Bundle: filepath.Join(t.TempDir(), "bundle"), WorkDir: project} + + _, err := adapter.CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret"}) + if !errors.Is(err, apns.ErrUnavailable) || !strings.Contains(err.Error(), gemfile+" pins fastlane, but Bundler is not installed") { + t.Fatalf("err = %v", err) + } +} + +// choosingAsker answers every choice with choice and records the questions. +type choosingAsker struct { + choice int + asked []string +} + +func (c *choosingAsker) Ask(question string, secret bool) (string, error) { + return "", errors.New("not expected: " + question) +} + +func (c *choosingAsker) Choose(question string, options []string) (int, error) { + c.asked = append(c.asked, question) + + return c.choice, nil +} + +// withoutFastlane leaves PATH with only bin, so fastlane is not found unless +// something puts it there. +func withoutFastlane(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell stand-ins") + } + bin := t.TempDir() + t.Setenv("PATH", bin) + + return bin +} + +// fakeBrew writes a brew stand-in into bin that logs its arguments and, for +// install fastlane, copies fastlane into bin. +func fakeBrew(t *testing.T, bin, fastlane string) string { + t.Helper() + log := filepath.Join(t.TempDir(), "brew.log") + script := `#!/bin/sh +echo "brew $*" >> "` + log + `" +[ "$1 $2" = "install fastlane" ] && /bin/cp "` + fastlane + `" "` + bin + `/fastlane" +` + if err := os.WriteFile(filepath.Join(bin, "brew"), []byte(script), 0o700); err != nil { + t.Fatal(err) + } + + return log +} + +func TestCreateKeyExplainsHowToInstallFastlane(t *testing.T) { + withoutFastlane(t) + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: &choosingAsker{}} + + _, err := (&Adapter{}).CreateKey(context.Background(), request) + if !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("err = %v", err) + } + for _, part := range []string{"fastlane is not installed", "brew install fastlane", "gem install fastlane", "--provider appwrite"} { + if !strings.Contains(err.Error(), part) { + t.Errorf("message is missing %q: %v", part, err) + } + } +} + +func TestCreateKeyInstallsFastlaneWithHomebrew(t *testing.T) { + fastlane, _ := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + bin := withoutFastlane(t) + log := fakeBrew(t, bin, fastlane) + asker := &choosingAsker{choice: 0} + var logged []string + request := apns.Request{ + Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", Asker: asker, + Log: func(format string, args ...any) { logged = append(logged, format) }, + } + + key, err := (&Adapter{Stdout: &strings.Builder{}, Stderr: &strings.Builder{}}).CreateKey(context.Background(), request) + if err != nil || key.KeyID != "KEY1234567" { + t.Fatalf("key = %+v, %v", key, err) + } + if strings.Join(asker.asked, "|") != "fastlane is not installed. Install it with Homebrew now?" { + t.Errorf("asked %v", asker.asked) + } + if calls, _ := os.ReadFile(log); strings.TrimSpace(string(calls)) != "brew install fastlane" { + t.Errorf("brew calls = %q", calls) + } + if len(logged) != 1 || logged[0] != "Running brew install fastlane ..." { + t.Errorf("logged %v", logged) + } +} + +func TestCreateKeyDoesNotInstallFastlaneWhenDeclined(t *testing.T) { + fastlane, _ := fakeFastlane(t, "2.228.0", `{}`) + bin := withoutFastlane(t) + log := fakeBrew(t, bin, fastlane) + + _, err := (&Adapter{}).CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: &choosingAsker{choice: 1}}) + if !errors.Is(err, apns.ErrUnavailable) || !strings.Contains(err.Error(), "brew install fastlane") { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(log); err == nil { + t.Error("ran brew after the offer was declined") + } +} + +func TestOldFastlaneSaysHowToUpgrade(t *testing.T) { + if err := supportedVersion("fastlane 2.220.0", "cd /app && bundle update fastlane"); err == nil || + !strings.Contains(err.Error(), "found 2.220.0. Upgrade it with cd /app && bundle update fastlane") { + t.Errorf("err = %v", err) + } +} + +func TestAConfiguredFastlaneIsNeverInstalled(t *testing.T) { + fastlane, _ := fakeFastlane(t, "2.228.0", `{}`) + bin := withoutFastlane(t) + log := fakeBrew(t, bin, fastlane) + asker := &choosingAsker{choice: 0} + + _, err := (&Adapter{Fastlane: "fastlane"}).CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: asker}) + if !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("err = %v", err) + } + if len(asker.asked) != 0 { + t.Errorf("offered to install: %v", asker.asked) + } + if _, err := os.Stat(log); err == nil { + t.Error("ran brew for a configured command") + } +} + +func TestTheBundlerUpgradeCommandRuns(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell stand-in") + } + project := t.TempDir() + gemfile := filepath.Join(project, "Gemfile") + if err := os.WriteFile(gemfile, []byte("gem \"fastlane\"\n"), 0o600); err != nil { + t.Fatal(err) + } + bundle := filepath.Join(t.TempDir(), "bundle") + if err := os.WriteFile(bundle, []byte("#!/bin/sh\necho \"fastlane 2.220.0\"\n"), 0o700); err != nil { + t.Fatal(err) + } + + _, err := (&Adapter{Bundle: bundle, WorkDir: project}).CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret"}) + if err == nil || !strings.Contains(err.Error(), "Upgrade it with cd "+project+" && bundle update fastlane") { + t.Errorf("err = %v", err) + } +} + +func TestBundlerUpgrade(t *testing.T) { + for _, test := range []struct{ folder, goos, want string }{ + {"/Users/dev/app", "darwin", "cd /Users/dev/app && bundle update fastlane"}, + {"/Users/dev/My App", "darwin", "cd '/Users/dev/My App' && bundle update fastlane"}, + {"/Users/dev/it's", "linux", `cd '/Users/dev/it'\''s' && bundle update fastlane`}, + {`/Users/dev/back\slash`, "linux", `cd '/Users/dev/back\slash' && bundle update fastlane`}, + {`/Users/dev/$(whoami)`, "linux", `cd '/Users/dev/$(whoami)' && bundle update fastlane`}, + {`C:\Users\dev\$(Get-Date) App`, "windows", `bundle update fastlane, run from the folder C:\Users\dev\$(Get-Date) App`}, + } { + if got := bundlerUpgrade(test.folder, test.goos); got != test.want { + t.Errorf("bundlerUpgrade(%q, %s) = %s, want %s", test.folder, test.goos, got, test.want) + } + } +} + +// The quoted command must work when pasted into a shell, for a folder whose +// name has spaces and a quote. +func TestTheQuotedCdRunsInAShell(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell") + } + folder := filepath.Join(t.TempDir(), "My Project's App") + if err := os.Mkdir(folder, 0o700); err != nil { + t.Fatal(err) + } + output, err := exec.Command("/bin/sh", "-c", "cd "+shellQuote(folder)+" && pwd -P").Output() + resolved, _ := filepath.EvalSymlinks(folder) + if err != nil || strings.TrimSpace(string(output)) != resolved { + t.Errorf("cd landed in %q, %v; want %q", output, err, resolved) + } +} + +func TestCreateKeyPassesResetToTheLane(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", Reset: true} + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + if calls, _ := os.ReadFile(log); !strings.Contains(string(calls), " reset:true ") { + t.Errorf("lane call = %s", calls) + } +} + +func laneCalls(t *testing.T, log string) []string { + t.Helper() + calls, _ := os.ReadFile(log) + text := strings.TrimSpace(string(calls)) + if text == "" { + return nil + } + + return strings.Split(text, "\n") +} + +func TestPrepareAppIDWithoutAskingAppliesInOneRun(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"teamId":"ABCDE12345","registered":true,"push":true,"created":true,"pushTurnedOn":true}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{TeamID: "ABCDE12345", AppleID: "dev@example.com", Password: "secret", Asker: &choosingAsker{}} + + result, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app"}) + if err != nil || result != (apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true, Created: true, PushTurnedOn: true}) { + t.Fatalf("result = %+v, %v", result, err) + } + calls := laneCalls(t, log) + if len(calls) != 1 || !strings.HasPrefix(calls[0], "fastlane prepare_app_id out:") || + !strings.HasSuffix(calls[0], "mode:apply bundle_id:io.example.app name:io example app team_id:ABCDE12345 FASTLANE_USER=dev@example.com password=set") { + t.Errorf("lane calls = %v", calls) + } +} + +func TestPrepareAppIDOnlyChecksAReadyAppID(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"teamId":"ABCDE12345","registered":true,"push":true}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &choosingAsker{} + request := apns.Request{AppleID: "dev@example.com", Password: "secret", Asker: asker} + + result, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}) + if err != nil || !result.Registered || !result.Push { + t.Fatalf("result = %+v, %v", result, err) + } + if calls := laneCalls(t, log); len(calls) != 1 || !strings.Contains(calls[0], "mode:check") || len(asker.asked) != 0 { + t.Errorf("lane calls = %v, asked %v", calls, asker.asked) + } +} + +func TestPrepareAppIDAsksBeforeApplying(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"teamId":"ABCDE12345","registered":true,"push":false}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &choosingAsker{} + request := apns.Request{AppleID: "dev@example.com", Password: "secret", Asker: asker, Reset: true} + + if _, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}); err != nil { + t.Fatal(err) + } + calls := laneCalls(t, log) + if len(calls) != 2 || !strings.Contains(calls[0], "mode:check") || !strings.Contains(calls[0], "reset:true") || + !strings.Contains(calls[1], "mode:apply") || !strings.Contains(calls[1], "team_id:ABCDE12345") || strings.Contains(calls[1], "reset:true") || + !strings.Contains(calls[1], "saved_only:true") { + t.Errorf("lane calls = %v", calls) + } + if strings.Join(asker.asked, "|") != "Push Notifications is off for io.example.app, so APNs refuses pushes for it. Turn it on?" { + t.Errorf("asked %v", asker.asked) + } +} + +func TestPrepareAppIDWithTheSavedSignInOnly(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"error":"sign-in-needed"}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{SavedSignInOnly: true, Asker: &choosingAsker{}} + + // spaceship keeps sessions per Apple ID: without one there is none. + if _, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}); !errors.Is(err, apns.ErrSignInNeeded) { + t.Fatalf("err = %v", err) + } + if calls := laneCalls(t, log); len(calls) != 0 { + t.Fatalf("ran the lane without an Apple ID: %v", calls) + } + + // With one, the lane checks the saved session; no password is asked. + request.AppleID = "dev@example.com" + if _, err := adapter.PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}); !errors.Is(err, apns.ErrSignInNeeded) { + t.Fatalf("err = %v", err) + } + if calls := laneCalls(t, log); len(calls) != 1 || !strings.Contains(calls[0], "saved_only:true") || !strings.HasSuffix(calls[0], "password=unset") { + t.Errorf("lane calls = %v", calls) + } +} + +func TestASavedSignInCheckNeverOffersToInstallFastlane(t *testing.T) { + bin := withoutFastlane(t) + fastlane, _ := fakeFastlane(t, "2.228.0", `{}`) + fakeBrew(t, bin, fastlane) + asker := &choosingAsker{} + request := apns.Request{AppleID: "dev@example.com", SavedSignInOnly: true, Asker: asker} + + _, err := (&Adapter{}).PrepareAppID(context.Background(), request, apns.AppIDRequest{BundleID: "io.example.app", Ask: true}) + if !errors.Is(err, apns.ErrUnavailable) || len(asker.asked) != 0 { + t.Errorf("err = %v, asked %v", err, asker.asked) + } +} diff --git a/templates/cli/internal/apns/fastlane/helper/Fastfile b/templates/cli/internal/apns/fastlane/helper/Fastfile new file mode 100644 index 0000000000..6deaaf0d83 --- /dev/null +++ b/templates/cli/internal/apns/fastlane/helper/Fastfile @@ -0,0 +1,114 @@ +# Creates a team-scoped APNs key, or prepares the app's App ID, for +# `appwrite init apns --provider fastlane` with spaceship, fastlane's Apple +# Developer portal client, so any fastlane installation (gem, Homebrew or +# bundler) can run it. +# +# spaceship asks for the two-factor code, and for a team when the account has +# several, on the terminal. The Apple ID and password come from FASTLANE_USER +# and FASTLANE_PASSWORD. fastlane logs to stdout, so the result is written as +# JSON to the file named by out: instead. +require 'json' + +lane :create_apns_key do |options| + write_result(options) { create_apns_key_with_spaceship(options) } +end + +lane :prepare_app_id do |options| + write_result(options) { prepare_app_id_with_spaceship(options) } +end + +def write_result(options) + result = begin + yield + rescue StandardError => error + kind = error.kind_of?(Spaceship::InvalidUserCredentialsError) ? 'invalid-credentials' : 'failed' + { error: kind, message: error.message } + end + File.open(options[:out], 'w', 0o600) { |file| file.write(JSON.generate(result)) } +end + +# sign_in returns the portal client for the team, or nil when saved_only:true +# finds no saved sign-in for FASTLANE_USER. +def sign_in(options) + require 'spaceship' + + user = ENV.fetch('FASTLANE_USER', nil) + # reset:true deletes spaceship's saved session for this Apple ID first, so + # the sign-in starts over, two-factor included. + if options[:reset].to_s == 'true' + cookie = Spaceship::PortalClient.new.tap { |client| client.user = user }.persistent_cookie_path + File.delete(cookie) if cookie && File.exist?(cookie) + end + if options[:saved_only].to_s == 'true' + client = Spaceship::PortalClient.new + client.user = user + return nil unless client.has_valid_session + + Spaceship::Portal.client = client + else + Spaceship::Portal.login(user, ENV.fetch('FASTLANE_PASSWORD', nil)) + end + team_id = options[:team_id].to_s + if team_id.empty? + Spaceship::Portal.select_team + else + Spaceship::Portal.select_team(team_id: team_id) + end + Spaceship::Portal.client +end + +def create_apns_key_with_spaceship(options) + client = sign_in(options) + + # Key.create always asks for every environment, so the client is called + # directly with the one requested. + environment = options[:environment].to_s.empty? ? 'all' : options[:environment].to_s + begin + created = client.create_key!( + name: options[:name], + service_configs: { + Spaceship::Portal::Key::APNS_ID => { scope: 'team', environment: environment, identifiers: {} } + } + ) + rescue StandardError => error + # Worded with or without "team scoped" before "Keys". + raise unless error.message =~ /maximum allowed number of (?:[a-z ]+ )?keys/i + + keys = client.list_keys.map do |key| + { id: key['keyId'], name: key['keyName'], canRevoke: key['canRevoke'] == true } + end + return { error: 'max-keys', keys: keys } + end + + key_id = created['keyId'] + { keyId: key_id, teamId: client.team_id, p8: client.download_key(id: key_id) } +end + +# Reports whether bundle_id: is an App ID with Push Notifications, and with +# mode:apply registers it and turns push on. +def prepare_app_id_with_spaceship(options) + client = sign_in(options) + return { error: 'sign-in-needed' } unless client + + push_type = Spaceship::ConnectAPI::BundleIdCapability::Type::PUSH_NOTIFICATIONS + Spaceship::ConnectAPI.client = Spaceship::ConnectAPI::Client.new(portal_client: client) + identifier = options[:bundle_id].to_s + bundle = Spaceship::ConnectAPI::BundleId.find(identifier, includes: 'bundleIdCapabilities') + push = !bundle.nil? && (bundle.bundle_id_capabilities || []).any? { |capability| capability.is_type?(push_type) } + result = { teamId: client.team_id, registered: !bundle.nil?, push: push, created: false, pushTurnedOn: false } + return result unless options[:mode].to_s == 'apply' + + if bundle.nil? + bundle = Spaceship::ConnectAPI::BundleId.create( + name: options[:name].to_s, platform: 'IOS', identifier: identifier, seed_id: client.team_id + ) + result[:registered] = true + result[:created] = true + end + unless result[:push] + bundle.update_capability(push_type, enabled: true) + result[:push] = true + result[:pushTurnedOn] = true + end + result +end diff --git a/templates/cli/internal/cmd/init.go b/templates/cli/internal/cmd/init.go index ec6a874449..14075ad5a4 100644 --- a/templates/cli/internal/cmd/init.go +++ b/templates/cli/internal/cmd/init.go @@ -38,6 +38,7 @@ func newInitCommand() *cobra.Command { newInitFunctionCommand(), newInitSiteCommand(), newInitSkillCommand(), + newInitApnsCommand(), newInitBucketCommand(), newInitTeamCommand(), newInitTopicCommand(), diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go new file mode 100644 index 0000000000..6424e17be7 --- /dev/null +++ b/templates/cli/internal/cmd/initpush.go @@ -0,0 +1,1177 @@ +//go:build !browser + +package cmd + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/appwrite" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/expo" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/fastlane" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/app" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/client" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/config" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/output" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/prompt" + "github.com/spf13/cobra" +) + +const ( + apnsKeyPage = "https://developer.apple.com/account/resources/authkeys/add" + apnsKeysListPage = "https://developer.apple.com/account/resources/authkeys/list" + appIDsPage = "https://developer.apple.com/account/resources/identifiers/list" + downloadTimeout = 15 * time.Minute + downloadPoll = time.Second +) + +var ( + errAutomaticKey = errors.New("automatic key creation chosen") + apnsKeyFileName = regexp.MustCompile(`^AuthKey_([A-Z0-9]{10})\.p8$`) + apnsKeyID = regexp.MustCompile(`^[A-Z0-9]{10}$`) +) + +type apnsOptions struct { + keyPath string + keyID string + teamID string + bundleID string + createKey bool + reset bool + environment string + setup string + // registerAppID registers the bundle ID as an App ID with Push + // Notifications, without asking. + registerAppID bool +} + +type pushSetup struct { + api *client.Client + prompter prompt.Prompter + out io.Writer + root string + keyDirs []string + downloads string + open func(string) + ctx context.Context + getenv func(string) string + // apnsAdapters are the ways --create-key can create an APNs key, and + // apnsSessions is where they keep a signed-in session. + apnsAdapters *apns.Registry + apnsSessions string + // appleID is the Apple ID a key was just created with, so the App ID + // check reuses that sign-in. + appleID string +} + +func newPushSetup(command *cobra.Command) (*pushSetup, error) { + context, err := newPushContext() + if err != nil { + return nil, err + } + + downloads := "" + if home, err := os.UserHomeDir(); err == nil { + downloads = filepath.Join(home, "Downloads") + } + + var keyDirs []string + for _, directory := range []string{context.local.Dirname(), "."} { + if absolute, err := filepath.Abs(directory); err == nil { + keyDirs = appendUnique(keyDirs, absolute) + } + } + + sessions := "" + if prefs, err := config.GlobalPath(app.ExecutableName); err == nil { + sessions = filepath.Join(filepath.Dir(prefs), "apns") + } + + return &pushSetup{ + api: context.api, + prompter: context.prompter, + out: command.OutOrStdout(), + root: ".", + keyDirs: keyDirs, + downloads: downloads, + open: openBrowser, + ctx: command.Context(), + getenv: os.Getenv, + apnsAdapters: apnsAdapters(), + apnsSessions: sessions, + }, nil +} + +func newInitApnsCommand() *cobra.Command { + options := apnsOptions{} + command := &cobra.Command{ + Use: "apns", + Short: "Set up Apple Push Notification service for Appwrite Push", + RunE: func(command *cobra.Command, args []string) error { + setup, err := newPushSetup(command) + if err != nil { + return err + } + detected, err := detectApple(setup.root) + if err != nil { + return err + } + + return setup.apns(options, detected) + }, + } + + flags := command.Flags() + flags.StringVar(&options.keyPath, "key-path", "", "Path to an APNs auth key (.p8)") + flags.StringVar(&options.keyID, "key-id", "", "APNs key ID") + flags.StringVar(&options.teamID, "team-id", "", "Apple Developer team ID") + flags.StringVar(&options.bundleID, "bundle-id", "", "iOS app bundle ID") + flags.BoolVar(&options.createKey, "create-key", false, "Create the APNs key by signing in with your Apple ID (experimental)") + flags.StringVar(&options.environment, "environment", string(apns.EnvironmentAll), "APNs environment to set up: production, sandbox or all") + flags.StringVar(&options.setup, "provider", "", "How --create-key creates the key: appwrite, expo or fastlane. Defaults to $"+apnsSetupVariable+", then "+defaultApnsSetup) + flags.BoolVar(&options.reset, "reset", false, "Sign in to Apple again instead of reusing the saved sign-in, and create the key (implies --create-key)") + flags.BoolVar(&options.registerAppID, "register-app-id", false, "Register the bundle ID as an App ID with Push Notifications in the Apple Developer portal, signing in with your Apple ID (experimental)") + command.MarkFlagsMutuallyExclusive("create-key", "key-path") + command.MarkFlagsMutuallyExclusive("reset", "key-path") + command.MarkFlagsMutuallyExclusive("reset", "key-id") + command.MarkFlagsMutuallyExclusive("create-key", "key-id") + + return command +} + +func (s *pushSetup) pick(value string, detected []string, question, flag string, validate func(string) error) (string, error) { + if value != "" { + if validate != nil { + if err := validate(value); err != nil { + return "", fmt.Errorf("%s: %w", flag, err) + } + } + + return value, nil + } + if len(detected) == 1 { + return detected[0], nil + } + if len(detected) > 1 { + return s.prompter.Choice(prompt.Choice{ + Message: question, + Options: prompt.Options(detected...), + Flag: flag, + }) + } + + return s.prompter.Text(prompt.Text{ + Message: question, + Flag: flag, + Validate: validate, + }) +} + +func requiredValue(name string) func(string) error { + return func(value string) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s is required", name) + } + + return nil + } +} + +func matchesPattern(pattern *regexp.Regexp, description string) func(string) error { + return func(value string) error { + if !pattern.MatchString(value) { + return fmt.Errorf("%s", description) + } + + return nil + } +} + +func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { + output.Log(s.out, "Setting up APNs ...") + // --reset is for signing in again, which only creating a key does. + if options.reset { + options.createKey = true + } + + environment, err := apnsEnvironment(options.environment) + if err != nil { + return err + } + // A setup chosen by name must exist before anything is written. + if options.setup != "" || s.env(apnsSetupVariable) != "" { + if _, err := s.apnsAdapter(options.setup); err != nil { + return err + } + } + wantProduction := environment != apns.EnvironmentSandbox + wantSandbox := environment != apns.EnvironmentProduction + + bundleID, err := s.pick(options.bundleID, detected.BundleIDs, + "Which iOS bundle ID should receive push notifications?", "--bundle-id", apns.CheckBundleID) + if err != nil { + return err + } + + existing, err := s.providers("apns") + if err != nil { + return err + } + production := findProvider(existing, func(provider messagingProvider) bool { + return provider.credential("bundleId") == bundleID && !provider.sandbox() + }) + sandbox := findProvider(existing, func(provider messagingProvider) bool { + return provider.credential("bundleId") == bundleID && provider.sandbox() + }) + setUp := func(provider *messagingProvider, wanted bool) bool { + return !wanted || (provider != nil && provider.Enabled) + } + if setUp(production, wantProduction) && setUp(sandbox, wantSandbox) && options.keyPath == "" && !options.reset && !app.Flags().Force { + output.Success(s.out, "APNs%s is already set up for %s.", environmentLabel(environment), bundleID) + output.Hint(s.out, "Pass --force or --key-path to replace the key.") + s.configureXcode(detected, bundleID) + s.ensureApplePlatform(bundleID) + err := s.prepareAppID(provisionedTeam(production, sandbox, detected.teams(bundleID)), bundleID, options, false) + s.apnsNextSteps() + + return err + } + + teams := detected.teams(bundleID) + for _, provider := range []*messagingProvider{production, sandbox} { + if provider != nil && len(teams) == 0 && appleTeamID.MatchString(provider.credential("teamId")) { + teams = []string{provider.credential("teamId")} + } + } + pickTeam := func() (string, error) { + return s.pick(options.teamID, teams, + "What is your Apple Developer team ID?", "--team-id", + matchesPattern(appleTeamID, "a team ID is 10 uppercase letters or digits")) + } + // Without a known team, signing in with the Apple ID chooses it, so the + // question waits until the key has come from somewhere else. + teamID := "" + if options.teamID != "" || len(teams) > 0 || options.keyPath != "" { + if teamID, err = pickTeam(); err != nil { + return err + } + } + + keyPath := options.keyPath + createdKeyID := "" + if keyPath == "" { + var keyTeam string + keyPath, createdKeyID, keyTeam, err = s.apnsKey(teamID, options, environment) + if err != nil { + return err + } + if teamID == "" { + teamID = keyTeam + } + } + if teamID == "" { + if teamID, err = pickTeam(); err != nil { + return err + } + } + + key, err := readApnsKey(keyPath) + if err != nil { + return err + } + if options.keyPath != "" { + s.checkKeyLocation(expandHome(keyPath)) + } + + keyID := options.keyID + // A created key's ID comes from Apple; any other would not match the .p8. + if createdKeyID != "" { + if keyID != "" && keyID != createdKeyID { + output.Warn(s.out, "Using the created key's ID %s instead of --key-id %s.", createdKeyID, keyID) + } + keyID = createdKeyID + } + if keyID == "" { + if match := apnsKeyFileName.FindStringSubmatch(filepath.Base(keyPath)); match != nil { + keyID = match[1] + } + } + keyID, err = s.pick(keyID, nil, "What is the key ID of this APNs key?", "--key-id", + matchesPattern(apnsKeyID, "a key ID is 10 uppercase letters or digits")) + if err != nil { + return err + } + + for _, target := range []struct { + sandbox bool + wanted bool + name string + existing *messagingProvider + }{ + {false, wantProduction, "APNs (" + bundleID + ")", production}, + {true, wantSandbox, "APNs sandbox (" + bundleID + ")", sandbox}, + } { + if !target.wanted { + continue + } + body := map[string]any{ + "name": target.name, + "authKey": key, + "authKeyId": keyID, + "teamId": teamID, + "bundleId": bundleID, + "sandbox": target.sandbox, + "enabled": true, + } + if err := s.upsertProvider("apns", target.existing, body); err != nil { + return err + } + } + + s.configureXcode(detected, bundleID) + output.Success(s.out, "APNs%s is set up for %s.", environmentLabel(environment), bundleID) + + s.ensureApplePlatform(bundleID) + // A key created a moment ago left a fresh sign-in to check the App ID with. + err = s.prepareAppID(teamID, bundleID, options, createdKeyID != "") + s.apnsNextSteps() + + return err +} + +// ensureApplePlatform adds the bundle ID as an Apple platform of the project: without one, the +// API refuses the app's requests as an unknown origin, device registration included. +func (s *pushSetup) ensureApplePlatform(bundleID string) { + manual := func() { + output.Hint(s.out, "Add an Apple platform with the bundle ID %s to the project in the Appwrite console, or the app's requests are refused as an unknown origin.", bundleID) + } + var list struct { + Platforms []struct { + Type string `json:"type"` + Key string `json:"key"` + BundleIdentifier string `json:"bundleIdentifier"` + } `json:"platforms"` + } + if err := s.api.Call("GET", "/project/platforms", nil, &list); err != nil { + output.Warn(s.out, "Could not check the project's platforms: %s.", err) + manual() + + return + } + for _, platform := range list.Platforms { + if strings.HasPrefix(platform.Type, "apple") && (platform.BundleIdentifier == bundleID || platform.Key == bundleID) { + return + } + } + body := map[string]any{"platformId": "unique()", "name": "iOS (" + bundleID + ")", "bundleIdentifier": bundleID} + if err := s.api.Call("POST", "/project/platforms/apple", body, nil); err != nil { + output.Warn(s.out, "Could not add the Apple platform %s: %s.", bundleID, err) + manual() + + return + } + output.Success(s.out, "Added the Apple platform %s to the project.", bundleID) +} + +// apnsNextSteps says what else push on iOS needs beyond what init apns sets up. +func (s *pushSetup) apnsNextSteps() { + if appwrite, err := s.providers("appwrite"); err == nil && findProvider(appwrite, func(provider messagingProvider) bool { return provider.Enabled }) == nil { + output.Hint(s.out, "Enable the Appwrite push provider in the project: messages reach the app over it, and APNs only wakes the app.") + } + output.Hint(s.out, "For messages while the app is in the background or swiped away, add a Notification Service Extension whose class extends AppwriteNotificationService, and put the app and the extension in one App Group, named by AppwritePushAppGroup in both Info.plists.") +} + +// apnsKey finds or creates the APNs key and returns its path. With +// --create-key, signing in with the Apple ID is the only way, and a failure +// stops the command. Otherwise it is offered alongside the browser flow, and +// when it fails the other ways of providing the key remain. +func (s *pushSetup) apnsKey(teamID string, options apnsOptions, environment apns.Environment) (string, string, string, error) { + create := options.createKey + explicit := create + automatic := "" + if s.apnsAdapters != nil && len(s.apnsAdapters.Names()) > 0 { + automatic = "Sign in with your Apple ID and create one (experimental)" + } + for { + if !create { + path, _, err := s.acquireKey("APNs auth key (.p8)", automatic, "Create one in the Apple Developer portal", "--key-path", apnsKeyPage, + "In the Apple Developer portal, create a key with Apple Push Notifications service (APNs) enabled and download it.", + func(path string) bool { + _, err := readApnsKey(path) + + return strings.HasSuffix(path, ".p8") && err == nil + }) + if !errors.Is(err, errAutomaticKey) { + return path, "", "", err + } + } + create = false + + path, keyID, keyTeam, err := s.createApnsKey(teamID, options, environment) + var maxKeys *apns.MaxKeysError + if errors.As(err, &maxKeys) { + output.Warn(s.out, "Apple allows two APNs keys per team, and this team has no free slot. Keys on the team:") + for _, key := range maxKeys.Keys { + output.Log(s.out, " %s (%s)", key.Name, key.ID) + } + output.Hint(s.out, "One key works for every app on the team. Reuse a .p8 you saved earlier, or revoke a key you no longer use at %s.", apnsKeysListPage) + + return "", "", "", errors.New("no APNs key can be created on this team. Pass --key-path with an existing .p8 instead") + } + if err == nil { + return path, keyID, keyTeam, nil + } + // --create-key asked for a new key: falling back to a .p8 already in + // the project folder would hide that none was created. + if explicit { + return "", "", "", fmt.Errorf("could not create the APNs key with your Apple ID: %w", err) + } + output.Warn(s.out, "Could not create the APNs key with your Apple ID: %s.", err) + output.Log(s.out, "Provide the key another way instead.") + automatic = "" + } +} + +const ( + // apnsSetupVariable picks the APNs key setup when --provider is not + // passed, so a broken one can be swapped without a release. + apnsSetupVariable = "APPWRITE_APNS_SETUP" + defaultApnsSetup = "appwrite" + // The Apple ID and password, when set, answer the sign-in questions. + appleIDVariable = "APPWRITE_APPLE_ID" + applePasswordVariable = "APPWRITE_APPLE_PASSWORD" +) + +// e2eAppleHosts is set only in the CLI e2e build, which points Apple's sign-in +// and Developer portal at the mock API. +var e2eAppleHosts func() appwrite.Hosts + +// apnsAdapters lists the APNs key setups this build offers. Each lives in its +// own package under internal/apns and is added here. +func apnsAdapters() *apns.Registry { + adapter := appwrite.New() + if e2eAppleHosts != nil { + adapter.Hosts = e2eAppleHosts() + } + + return apns.NewRegistry(adapter, expo.New(), fastlane.New()) +} + +// apnsAdapter picks the setup: --provider, then APPWRITE_APNS_SETUP, then +// appwrite. +func (s *pushSetup) apnsAdapter(setup string) (apns.Adapter, error) { + if setup == "" { + setup = s.env(apnsSetupVariable) + } + if setup == "" { + setup = defaultApnsSetup + } + if s.apnsAdapters == nil { + return nil, fmt.Errorf("APNs key setup %q is not available: this build has none", setup) + } + + return s.apnsAdapters.Get(setup) +} + +func (s *pushSetup) env(name string) string { + if s.getenv == nil { + return "" + } + + return s.getenv(name) +} + +func (s *pushSetup) context() context.Context { + if s.ctx == nil { + return context.Background() + } + + return s.ctx +} + +// apnsEnvironment reads --environment. Empty means both. +func apnsEnvironment(value string) (apns.Environment, error) { + environment, err := apns.ParseEnvironment(value) + if err != nil { + return "", fmt.Errorf("--environment must be production, sandbox or all, not %q", value) + } + + return environment, nil +} + +// environmentLabel names a single environment in messages, and nothing for +// both. +func environmentLabel(environment apns.Environment) string { + if environment == apns.EnvironmentAll { + return "" + } + + return " (" + string(environment) + ")" +} + +// apnsAsker asks an APNs setup's questions through the CLI's prompts, so they +// look like every other question and fail with a clear message without a +// terminal. +type apnsAsker struct { + prompter prompt.Prompter +} + +func (a apnsAsker) Ask(question string, secret bool) (string, error) { + return a.prompter.Text(prompt.Text{Message: question, Secret: secret, Validate: requiredValue("an answer")}) +} + +func (a apnsAsker) Choose(question string, options []string) (int, error) { + choices := make([]prompt.Option, len(options)) + for index, label := range options { + choices[index] = prompt.Option{Label: label, Value: strconv.Itoa(index)} + } + value, err := a.prompter.Choice(prompt.Choice{Message: question, Options: choices}) + if err != nil { + return 0, err + } + + return strconv.Atoi(value) +} + +// appleRequest is how every Apple Developer portal step signs in: the Apple ID +// and password from the environment when set, the saved session unless reset. +func (s *pushSetup) appleRequest(adapter apns.Adapter, teamID string, reset bool) apns.Request { + request := apns.Request{ + TeamID: teamID, + AppleID: s.env(appleIDVariable), + Password: s.env(applePasswordVariable), + Reset: reset, + Asker: apnsAsker{s.prompter}, + Log: func(format string, args ...any) { + output.Log(s.out, format, args...) + }, + } + if s.apnsSessions != "" { + request.SessionDir = filepath.Join(s.apnsSessions, adapter.Name()) + } + + return request +} + +// logAppleSignIn says how the coming sign-in to Apple will happen. +func (s *pushSetup) logAppleSignIn(adapter apns.Adapter, request apns.Request) { + output.Log(s.out, "Signing in to the Apple Developer portal (%s).", adapter.Name()) + if request.Reset { + output.Log(s.out, "Signing in again: the saved Apple sign-in is not used (--reset).") + } + if request.AppleID != "" { + output.Log(s.out, "Using the Apple ID from %s.", appleIDVariable) + } + if request.Password != "" { + output.Log(s.out, "Using the Apple ID password from %s.", applePasswordVariable) + } +} + +// prepareAppID makes sure the bundle ID is an App ID with Push Notifications +// on the team: APNs refuses pushes for any other bundle ID (TopicDisallowed), +// and Xcode registers it only for a device build with the capability added. +// +// Every init apns runs it. It uses the sign-in a new key just left, or a +// saved one, and asks before each change; without either it asks whether to +// sign in, and skips when there is no terminal. --register-app-id signs in +// when needed, makes the changes without asking and fails the command when +// it cannot. +func (s *pushSetup) prepareAppID(teamID, bundleID string, options apnsOptions, signedIn bool) error { + explicit := options.registerAppID + fail := func(format string, args ...any) error { + message := fmt.Sprintf(format, args...) + if explicit { + return errors.New(message) + } + output.Warn(s.out, "%s.", message) + output.Hint(s.out, "APNs accepts pushes only for a bundle ID registered as an App ID with Push Notifications. Check it at %s.", appIDsPage) + + return nil + } + adapter, err := s.apnsAdapter(options.setup) + if err != nil { + return fail("Could not check the App ID for %s: %s", bundleID, err) + } + + output.Log(s.out, "Checking the App ID for %s ...", bundleID) + // A key created a moment ago left a fresh, saved sign-in: the check reuses + // it for the same Apple ID, and --reset is not repeated. + request := s.appleRequest(adapter, teamID, options.reset && !options.createKey) + if signedIn && request.AppleID == "" { + request.AppleID = s.appleID + } + request.SavedSignInOnly = signedIn || !explicit + if !request.SavedSignInOnly { + s.logAppleSignIn(adapter, request) + } + appID := apns.AppIDRequest{BundleID: bundleID, Ask: !explicit} + result, err := apns.PrepareAppID(s.context(), adapter, request, appID) + // Just signed in, or asked for explicitly: sign in again without asking. + if errors.Is(err, apns.ErrSignInNeeded) && (signedIn || explicit) { + request.SavedSignInOnly = false + s.logAppleSignIn(adapter, request) + result, err = apns.PrepareAppID(s.context(), adapter, request, appID) + } + if errors.Is(err, apns.ErrSignInNeeded) { + answer, askErr := s.prompter.Choice(prompt.Choice{ + Message: "Sign in with your Apple ID to check the App ID for " + bundleID + "?", + Description: "APNs refuses pushes for a bundle ID that is not registered as an App ID with Push Notifications.", + Options: []prompt.Option{ + {Label: "Sign in and check", Value: "check"}, + {Label: "Skip", Value: "skip"}, + }, + }) + if askErr != nil || answer != "check" { + reason := "" + if askErr != nil { + reason = ": there is no saved Apple sign-in, and no terminal to sign in" + } + output.Hint(s.out, "Skipped the App ID check%s. Run `appwrite init apns --register-app-id` in a terminal, or check %s at %s.", reason, bundleID, appIDsPage) + + return nil + } + request.SavedSignInOnly = false + s.logAppleSignIn(adapter, request) + result, err = apns.PrepareAppID(s.context(), adapter, request, appID) + } + var unanswered *prompt.NonInteractiveError + if errors.As(err, &unanswered) { + return fail("Could not check the App ID for %s: %s", bundleID, needsTerminal(unanswered)) + } + if err != nil { + return fail("Could not check the App ID for %s: %s", bundleID, err) + } + + switch { + case result.Created: + output.Success(s.out, "Registered %s as an App ID with Push Notifications on team %s.", bundleID, result.TeamID) + case result.PushTurnedOn: + output.Success(s.out, "Turned on Push Notifications for the App ID %s.", bundleID) + case result.Registered && result.Push: + output.Success(s.out, "%s is registered as an App ID with Push Notifications.", bundleID) + case !result.Registered: + output.Warn(s.out, "%s is not registered as an App ID on team %s: APNs refuses pushes for it until it is.", bundleID, result.TeamID) + output.Hint(s.out, "Run `appwrite init apns --register-app-id`, or register it at %s.", appIDsPage) + default: + output.Warn(s.out, "Push Notifications is off for the App ID %s: APNs refuses pushes for it until it is on.", bundleID) + output.Hint(s.out, "Run `appwrite init apns --register-app-id`, or turn it on at %s.", appIDsPage) + } + + return nil +} + +// needsTerminal says that signing in to Apple asked a question without a terminal. +func needsTerminal(unanswered *prompt.NonInteractiveError) string { + return fmt.Sprintf("signing in to Apple asked %q, which needs an interactive terminal. %s and %s answer the Apple ID and password, but the two-factor code always needs one", unanswered.Message, appleIDVariable, applePasswordVariable) +} + +// provisionedTeam is the team of an existing APNs provider for the bundle ID, +// else the one detected in the project, else none (signing in picks it). +func provisionedTeam(production, sandbox *messagingProvider, detected []string) string { + for _, provider := range []*messagingProvider{production, sandbox} { + if provider != nil && appleTeamID.MatchString(provider.credential("teamId")) { + return provider.credential("teamId") + } + } + if len(detected) == 1 { + return detected[0] + } + + return "" +} + +// createApnsKey creates a key through the Apple Developer portal and saves it +// into the project folder before anything else happens, because Apple lets a +// key be downloaded only once. +func (s *pushSetup) createApnsKey(teamID string, options apnsOptions, environment apns.Environment) (string, string, string, error) { + adapter, err := s.apnsAdapter(options.setup) + if err != nil { + return "", "", "", err + } + request := s.appleRequest(adapter, teamID, options.reset) + s.logAppleSignIn(adapter, request) + request.Name = "Appwrite Push " + time.Now().Format("20060102150405") + request.Environment = environment + key, err := apns.CreateKey(s.context(), adapter, request) + if err == nil { + s.appleID = key.AppleID + } + var unanswered *prompt.NonInteractiveError + if errors.As(err, &unanswered) { + return "", "", "", fmt.Errorf("%s. Without a terminal, pass --key-path with an existing .p8 instead of --create-key", needsTerminal(unanswered)) + } + if err != nil { + return "", "", "", err + } + + directory := s.root + if len(s.keyDirs) > 0 { + directory = s.keyDirs[0] + } + path := filepath.Join(directory, "AuthKey_"+key.KeyID+".p8") + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return "", "", "", fmt.Errorf("could not save the new key %s: %w", key.KeyID, err) + } + _, err = file.WriteString(key.P8) + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + return "", "", "", fmt.Errorf("could not save the new key %s: %w", key.KeyID, err) + } + + output.Success(s.out, "Created APNs key %s on team %s and saved it to %s.", key.KeyID, key.TeamID, s.display(path)) + output.Hint(s.out, "Apple lets you download this key only once. Keep the file somewhere safe and out of version control.") + s.checkKeyLocation(path) + + return path, key.KeyID, key.TeamID, nil +} + +func (s *pushSetup) acquireKey(what, automatic, browserLabel, flag, page, instructions string, accept func(string) bool) (string, bool, error) { + found := s.projectKeys(accept) + if len(found) == 1 { + output.Log(s.out, "Using %s from the project folder.", s.display(found[0])) + s.checkKeyLocation(found[0]) + + return found[0], true, nil + } + if len(found) > 1 { + options := make([]prompt.Option, 0, len(found)) + for _, path := range found { + options = append(options, prompt.Option{Label: s.display(path), Value: path}) + } + path, err := s.prompter.Choice(prompt.Choice{ + Message: fmt.Sprintf("Which %s should be used?", what), + Options: options, + Flag: flag, + }) + if err != nil { + return "", false, err + } + s.checkKeyLocation(path) + + return path, true, nil + } + + methods := []prompt.Option{ + {Label: browserLabel, Value: "browser"}, + {Label: "Use a file I already have", Value: "file"}, + } + if automatic != "" { + first := prompt.Option{Label: automatic, Value: "automatic"} + methods = append([]prompt.Option{first}, methods...) + } + method, err := s.prompter.Choice(prompt.Choice{ + Message: fmt.Sprintf("How would you like to provide the %s?", what), + Options: methods, + Default: methods[0].Value, + Flag: flag, + }) + if err != nil { + return "", false, err + } + if method == "automatic" { + return "", false, errAutomaticKey + } + + if method == "file" { + path, err := s.prompter.Text(prompt.Text{ + Message: fmt.Sprintf("Path to the %s", what), + Flag: flag, + Validate: func(path string) error { + if _, err := os.Stat(expandHome(path)); err != nil { + return fmt.Errorf("no file at %s", path) + } + + return nil + }, + }) + if err != nil { + return "", false, err + } + s.checkKeyLocation(expandHome(path)) + + return path, false, nil + } + + directories := append([]string{}, s.keyDirs...) + if s.downloads != "" { + if info, err := os.Stat(s.downloads); err == nil && info.IsDir() { + directories = appendUnique(directories, s.downloads) + } + } + if len(directories) == 0 { + return "", false, fmt.Errorf("no folder to watch for the key. Pass %s instead", flag) + } + + output.Log(s.out, "%s", instructions) + output.Log(s.out, "Opening %s", page) + s.open(page) + if len(s.keyDirs) > 0 { + output.Log(s.out, "Save the file into %s, or let your browser download it to %s. Waiting (Ctrl-C to cancel) ...", + s.keyDirs[0], s.downloads) + } else { + output.Log(s.out, "Waiting for the file in %s (Ctrl-C to cancel) ...", s.downloads) + } + + path, err := waitForKeyFile(directories, time.Now(), accept, downloadTimeout, downloadPoll) + if err != nil { + return "", false, fmt.Errorf("%w. Pass %s instead", err, flag) + } + output.Log(s.out, "Found %s", path) + s.checkKeyLocation(path) + + return path, false, nil +} + +func (s *pushSetup) projectKeys(accept func(string) bool) []string { + var found []string + for _, directory := range s.keyDirs { + entries, err := os.ReadDir(directory) + if err != nil { + continue + } + for _, entry := range entries { + path := filepath.Join(directory, entry.Name()) + if !entry.IsDir() && accept(path) { + found = appendUnique(found, path) + } + } + } + + return found +} + +func (s *pushSetup) display(path string) string { + for _, directory := range s.keyDirs { + if relative, err := filepath.Rel(directory, path); err == nil && !strings.HasPrefix(relative, "..") { + return relative + } + } + + return path +} + +func (s *pushSetup) checkKeyLocation(path string) { + absolute, err := filepath.Abs(path) + if err != nil { + return + } + git, err := exec.LookPath("git") + if err != nil { + return + } + check := exec.Command(git, "-C", filepath.Dir(absolute), "check-ignore", "-q", filepath.Base(absolute)) + if err := check.Run(); err != nil { + var exit *exec.ExitError + if errors.As(err, &exit) && exit.ExitCode() == 1 { + output.Warn(s.out, "%s is inside a git repository and not ignored. Add it to .gitignore so the key is never committed.", s.display(absolute)) + } + } +} + +func waitForKeyFile(directories []string, since time.Time, accept func(string) bool, timeout, poll time.Duration) (string, error) { + seen := map[string]time.Time{} + for _, directory := range directories { + entries, err := os.ReadDir(directory) + if err != nil { + continue + } + for _, entry := range entries { + if info, err := entry.Info(); err == nil && info.ModTime().Before(since) { + seen[filepath.Join(directory, entry.Name())] = info.ModTime() + } + } + } + + deadline := time.Now().Add(timeout) + for { + for _, directory := range directories { + entries, err := os.ReadDir(directory) + if err != nil { + continue + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + path := filepath.Join(directory, entry.Name()) + if previous, ok := seen[path]; ok && !info.ModTime().After(previous) { + continue + } + if accept(path) { + return path, nil + } + } + } + if time.Now().After(deadline) { + return "", fmt.Errorf("no matching file appeared in %s within %s", strings.Join(directories, " or "), timeout) + } + time.Sleep(poll) + } +} + +func expandHome(path string) string { + if strings.HasPrefix(path, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, path[2:]) + } + } + + return path +} + +func readApnsKey(path string) (string, error) { + contents, err := os.ReadFile(expandHome(path)) + if err != nil { + return "", err + } + block, _ := pem.Decode(contents) + if block == nil || block.Type != "PRIVATE KEY" { + return "", fmt.Errorf("%s is not an APNs auth key: expected a PEM 'PRIVATE KEY' block", path) + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return "", fmt.Errorf("%s is not an APNs auth key: %w", path, err) + } + ecKey, ok := key.(*ecdsa.PrivateKey) + if !ok || ecKey.Curve != elliptic.P256() { + return "", fmt.Errorf("%s is not an APNs auth key: expected a P-256 elliptic curve key", path) + } + + return string(contents), nil +} + +type messagingProvider struct { + ID string `json:"$id"` + Name string `json:"name"` + Provider string `json:"provider"` + Enabled bool `json:"enabled"` + Credentials map[string]any `json:"credentials"` + Options map[string]any `json:"options"` +} + +func (p messagingProvider) credential(name string) string { + value, _ := p.Credentials[name].(string) + + return value +} + +func (p messagingProvider) sandbox() bool { + value, _ := p.Options["sandbox"].(bool) + + return value +} + +func findProvider(providers []messagingProvider, match func(messagingProvider) bool) *messagingProvider { + for index := range providers { + if match(providers[index]) { + return &providers[index] + } + } + + return nil +} + +func (s *pushSetup) providers(kind string) ([]messagingProvider, error) { + query := url.Values{"queries[]": []string{ + `{"method":"equal","attribute":"provider","values":["` + kind + `"]}`, + `{"method":"limit","values":[100]}`, + }} + var list struct { + Providers []messagingProvider `json:"providers"` + } + if err := s.api.Call("GET", "/messaging/providers?"+query.Encode(), nil, &list); err != nil { + return nil, fmt.Errorf("could not list messaging providers: %w", err) + } + + return list.Providers, nil +} + +func (s *pushSetup) upsertProvider(kind string, existing *messagingProvider, body map[string]any) error { + var saved messagingProvider + if existing == nil { + body["providerId"] = "unique()" + if err := s.api.Call("POST", "/messaging/providers/"+kind, body, &saved); err != nil { + return fmt.Errorf("could not create %s: %w", body["name"], err) + } + output.Success(s.out, "Created provider %s ( %s )", saved.Name, saved.ID) + + return nil + } + + if err := s.api.Call("PATCH", "/messaging/providers/"+kind+"/"+url.PathEscape(existing.ID), body, &saved); err != nil { + return fmt.Errorf("could not update %s: %w", existing.Name, err) + } + output.Success(s.out, "Updated provider %s ( %s )", saved.Name, saved.ID) + + return nil +} + +func (s *pushSetup) configureXcode(detected appleApp, bundleID string) { + targets := detected.targets(bundleID) + inXcode, entitlements, infoPlists := false, 0, 0 + for _, target := range targets { + if target.Project == "" { + continue + } + inXcode = true + for _, path := range target.Entitlements { + entitlements++ + changed, err := editPlist(path, func(contents string) string { + return ensurePlistString(contents, "aps-environment", "development") + }) + s.reportEdit(path, "aps-environment", changed, err) + } + for _, path := range target.InfoPlists { + infoPlists++ + changed, err := editPlist(path, func(contents string) string { + return ensurePlistArrayValue(contents, "UIBackgroundModes", "remote-notification") + }) + s.reportEdit(path, "the remote-notification background mode", changed, err) + } + } + + if inXcode && entitlements == 0 { + output.Hint(s.out, "In Xcode, open Signing & Capabilities and add Push Notifications, so the app gets an aps-environment entitlement.") + } + if inXcode && infoPlists == 0 { + output.Hint(s.out, "In Xcode, add Background Modes and tick Remote notifications.") + } + if detected.ExpoConfig != "" { + output.Hint(s.out, "Expo regenerates ios/ on prebuild. In %s, set expo.ios.entitlements[\"aps-environment\"] to \"development\" and add \"remote-notification\" to expo.ios.infoPlist.UIBackgroundModes.", + filepath.Base(detected.ExpoConfig)) + } +} + +func (s *pushSetup) reportEdit(path, what string, changed bool, err error) { + switch { + case err != nil: + output.Warn(s.out, "Could not add %s to %s: %s", what, path, err) + case changed: + output.Success(s.out, "Added %s to %s", what, path) + } +} + +func editPlist(path string, edit func(string) string) (bool, error) { + contents, err := os.ReadFile(path) + if err != nil { + return false, err + } + original := string(contents) + if !strings.Contains(original, ""); index >= 0 && !strings.Contains(contents, "") { + indent, _ := lineIndent(contents, index) + contents = contents[:index] + "\n" + indent + "" + contents[index+len(""):] + } + end := strings.LastIndex(contents, "") + if end < 0 { + return contents, -1, "", "" + } + closing, lineStart := lineIndent(contents, end) + + unit := "\t" + if key := plistKeyLine.FindStringSubmatch(contents); key != nil && strings.HasPrefix(key[1], closing) && len(key[1]) > len(closing) { + unit = key[1][len(closing):] + } + + return contents, lineStart, closing + unit, unit +} + +var plistKeyLine = regexp.MustCompile(`(?m)^([ \t]*)`) + +func ensurePlistString(contents, key, value string) string { + if strings.Contains(contents, ""+key+"") { + return contents + } + contents, at, indent, _ := topLevelDict(contents) + if at < 0 { + return contents + } + + return contents[:at] + indent + "" + key + "\n" + indent + "" + value + "\n" + contents[at:] +} + +func ensurePlistArrayValue(contents, key, value string) string { + marker := "" + key + "" + entry := "" + value + "" + if index := strings.Index(contents, marker); index >= 0 { + after := index + len(marker) + rest := contents[after:] + if empty := strings.Index(rest, ""); empty >= 0 && strings.TrimSpace(rest[:empty]) == "" { + keyIndent, _ := lineIndent(contents, index) + _, _, _, unit := topLevelDict(contents) + at := after + empty + + return contents[:at] + "\n" + keyIndent + unit + entry + "\n" + keyIndent + "" + contents[at+len(""):] + } + open := strings.Index(rest, "") + closing := strings.Index(rest, "") + if open < 0 || closing < open || strings.TrimSpace(rest[:open]) != "" { + return contents + } + if strings.Contains(rest[open:closing], entry) { + return contents + } + keyIndent, _ := lineIndent(contents, index) + _, _, _, unit := topLevelDict(contents) + _, lineStart := lineIndent(contents, after+closing) + if lineStart > 0 && contents[lineStart-1] != '\n' { + return contents + } + + return contents[:lineStart] + keyIndent + unit + entry + "\n" + contents[lineStart:] + } + + contents, at, indent, unit := topLevelDict(contents) + if at < 0 { + return contents + } + + return contents[:at] + indent + marker + "\n" + indent + "\n" + indent + unit + entry + "\n" + indent + "\n" + contents[at:] +} diff --git a/templates/cli/internal/cmd/initpush_test.go b/templates/cli/internal/cmd/initpush_test.go new file mode 100644 index 0000000000..167c0142fd --- /dev/null +++ b/templates/cli/internal/cmd/initpush_test.go @@ -0,0 +1,1270 @@ +package cmd + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/apnstest" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/client" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/prompt" +) + +const testPbxproj = `// !$*UTF8*$! +{ + objects = { + +/* Begin PBXNativeTarget section */ + A00000000000000000000001 /* MyApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = B00000000000000000000001 /* Build configuration list for PBXNativeTarget "MyApp" */; + name = MyApp; + productType = "com.apple.product-type.application"; + }; + A00000000000000000000002 /* MyAppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = B00000000000000000000002 /* Build configuration list for PBXNativeTarget "MyAppTests" */; + name = MyAppTests; + productType = "com.apple.product-type.bundle.unit-test"; + }; + A00000000000000000000003 /* Widget */ = { + isa = PBXNativeTarget; + buildConfigurationList = B00000000000000000000003 /* Build configuration list for PBXNativeTarget "Widget" */; + name = Widget; + productType = "com.apple.product-type.app-extension"; + }; + A00000000000000000000004 /* Admin */ = { + isa = PBXNativeTarget; + buildConfigurationList = B00000000000000000000004 /* Build configuration list for PBXNativeTarget "Admin" */; + name = Admin; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin XCBuildConfiguration section */ + C00000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements; + DEVELOPMENT_TEAM = ABCDE12345; + INFOPLIST_FILE = MyApp/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "My App"; + }; + name = Debug; + }; + C00000000000000000000002 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + DEVELOPMENT_TEAM = ABCDE12345; + PRODUCT_BUNDLE_IDENTIFIER = com.example.MyAppTests; + }; + name = Debug; + }; + C00000000000000000000003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Widget/Widget.entitlements; + DEVELOPMENT_TEAM = ABCDE12345; + INFOPLIST_FILE = Widget/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.example.widget; + }; + name = Debug; + }; + C00000000000000000000004 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Admin/Admin.entitlements; + DEVELOPMENT_TEAM = FGHIJ67890; + INFOPLIST_FILE = Admin/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.example.admin; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + B00000000000000000000001 /* Build configuration list for PBXNativeTarget "MyApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00000000000000000000001 /* Debug */, + ); + }; + B00000000000000000000002 /* Build configuration list for PBXNativeTarget "MyAppTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00000000000000000000002 /* Debug */, + ); + }; + B00000000000000000000003 /* Build configuration list for PBXNativeTarget "Widget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00000000000000000000003 /* Debug */, + ); + }; + B00000000000000000000004 /* Build configuration list for PBXNativeTarget "Admin" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00000000000000000000004 /* Debug */, + ); + }; +/* End XCConfigurationList section */ + }; +} +` + +const myAppBundle = "org.reactjs.native.example.My-App" + +func writeFile(t *testing.T, path, contents string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestDetectAppleReadsTheAppTarget(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "ios", "MyApp.xcodeproj", "project.pbxproj"), testPbxproj) + writeFile(t, filepath.Join(root, "node_modules", "dep", "Dep.xcodeproj", "project.pbxproj"), + strings.ReplaceAll(testPbxproj, "ABCDE12345", "ZZZZZ99999")) + + app, err := detectApple(root) + if err != nil { + t.Fatal(err) + } + + if strings.Join(app.BundleIDs, ",") != "com.example.admin,"+myAppBundle { + t.Errorf("bundle IDs = %v", app.BundleIDs) + } + if strings.Join(app.teams(myAppBundle), ",") != "ABCDE12345" || strings.Join(app.teams("com.example.admin"), ",") != "FGHIJ67890" { + t.Errorf("teams = %v / %v", app.teams(myAppBundle), app.teams("com.example.admin")) + } + targets := app.targets(myAppBundle) + if len(targets) != 1 { + t.Fatalf("targets = %+v", app.Targets) + } + if strings.Join(targets[0].Entitlements, ",") != filepath.Join(root, "ios", "MyApp", "MyApp.entitlements") { + t.Errorf("entitlements = %v", targets[0].Entitlements) + } + if strings.Join(targets[0].InfoPlists, ",") != filepath.Join(root, "ios", "MyApp", "Info.plist") { + t.Errorf("Info.plist = %v", targets[0].InfoPlists) + } +} + +func TestDetectReadsExpoConfig(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "app.json"), `{"expo": { + "ios": {"bundleIdentifier": "com.example.expo", "appleTeamId": "ABCDE12345"} +}}`) + + apple, _ := detectApple(root) + + if apple.ExpoConfig == "" || len(apple.BundleIDs) != 1 || apple.BundleIDs[0] != "com.example.expo" || apple.TeamIDs[0] != "ABCDE12345" { + t.Errorf("apple = %+v", apple) + } +} + +func TestEnsurePlistEdits(t *testing.T) { + empty := "\n\n\n\n" + got := ensurePlistString(empty, "aps-environment", "development") + want := "\n\n\n\taps-environment\n\tdevelopment\n\n\n" + if got != want { + t.Errorf("empty dict:\n%s", got) + } + if ensurePlistString(got, "aps-environment", "production") != got { + t.Error("an existing aps-environment was changed") + } + + info := "\n\n\tCFBundleName\n\tApp\n\tUIBackgroundModes\n\t\n\t\tfetch\n\t\n\n\n" + got = ensurePlistArrayValue(info, "UIBackgroundModes", "remote-notification") + if !strings.Contains(got, "\t\n\t\tfetch\n\t\tremote-notification\n\t") { + t.Errorf("existing array:\n%s", got) + } + if ensurePlistArrayValue(got, "UIBackgroundModes", "remote-notification") != got { + t.Error("remote-notification was added twice") + } + + expo := "\n \n CFBundleName\n App\n \n\n" + got = ensurePlistArrayValue(expo, "UIBackgroundModes", "remote-notification") + if !strings.Contains(got, " App\n UIBackgroundModes\n \n remote-notification\n \n \n") { + t.Errorf("two-space plist:\n%s", got) + } + got = ensurePlistArrayValue(strings.Replace(got, "remote-notification", "fetch", 1), "UIBackgroundModes", "remote-notification") + if !strings.Contains(got, " fetch\n remote-notification\n ") { + t.Errorf("two-space array:\n%s", got) + } + + selfClosing := "\n\n\tUIBackgroundModes\n\t\n\n\n" + got = ensurePlistArrayValue(selfClosing, "UIBackgroundModes", "remote-notification") + if got != "\n\n\tUIBackgroundModes\n\t\n\t\tremote-notification\n\t\n\n\n" { + t.Errorf("self-closing array:\n%s", got) + } + + bare := "\n\n\tNested\n\t\n\t\n\n\n" + got = ensurePlistArrayValue(bare, "UIBackgroundModes", "remote-notification") + if !strings.HasSuffix(got, "\t\n\tUIBackgroundModes\n\t\n\t\tremote-notification\n\t\n\n\n") { + t.Errorf("new array:\n%s", got) + } +} + +func writeKey(t *testing.T, path string, key any) { + t.Helper() + encoded, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + writeFile(t, path, string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encoded}))) +} + +func testApnsKey(t *testing.T, directory string) string { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(directory, "AuthKey_KEY1234567.p8") + writeKey(t, path, key) + + return path +} + +func TestReadApnsKeyRejectsOtherKeys(t *testing.T) { + directory := t.TempDir() + if _, err := readApnsKey(testApnsKey(t, directory)); err != nil { + t.Errorf("P-256 key rejected: %s", err) + } + + rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048) + rsaPath := filepath.Join(directory, "rsa.p8") + writeKey(t, rsaPath, rsaKey) + if _, err := readApnsKey(rsaPath); err == nil || !strings.Contains(err.Error(), "P-256") { + t.Errorf("RSA key accepted: %v", err) + } +} + +func TestWaitForKeyFileIgnoresEarlierFiles(t *testing.T) { + directory := t.TempDir() + old := testApnsKey(t, directory) + past := time.Now().Add(-time.Hour) + if err := os.Chtimes(old, past, past); err != nil { + t.Fatal(err) + } + + started := time.Now() + fresh := filepath.Join(directory, "AuthKey_NEW1234567.p8") + go func() { + time.Sleep(50 * time.Millisecond) + writeFile(t, filepath.Join(directory, "notes.txt"), "not a key") + data, _ := os.ReadFile(old) + writeFile(t, fresh, string(data)) + }() + + path, err := waitForKeyFile([]string{t.TempDir(), directory}, started, func(path string) bool { + return apnsKeyFileName.MatchString(filepath.Base(path)) + }, 5*time.Second, 10*time.Millisecond) + if err != nil { + t.Fatal(err) + } + if path != fresh { + t.Errorf("picked %s", path) + } +} + +type fakeMessaging struct { + platforms []map[string]any + providers []map[string]any + requests []string + identities []map[string]any + failCreate bool + failList int +} + +func (f *fakeMessaging) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.requests = append(f.requests, r.Method+" "+r.URL.Path) + body, _ := io.ReadAll(r.Body) + var params map[string]any + _ = json.Unmarshal(body, ¶ms) + + switch { + case r.Method == "GET" && r.URL.Path == "/project/platforms": + _ = json.NewEncoder(w).Encode(map[string]any{"total": len(f.platforms), "platforms": f.platforms}) + case r.Method == "POST" && r.URL.Path == "/project/platforms/apple": + platform := map[string]any{"$id": "platform" + string(rune('0'+len(f.platforms))), "type": "apple", "name": params["name"], "bundleIdentifier": params["bundleIdentifier"]} + f.platforms = append(f.platforms, platform) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(platform) + case r.Method == "GET" && r.URL.Path == "/account/identities": + _ = json.NewEncoder(w).Encode(map[string]any{"total": len(f.identities), "identities": f.identities}) + case r.Method == "GET" && r.URL.Path == "/account": + _ = json.NewEncoder(w).Encode(map[string]any{"email": "dev@example.com"}) + case r.Method == "GET" && r.URL.Path == "/messaging/providers" && f.failList > 0: + f.failList-- + w.WriteHeader(http.StatusServiceUnavailable) + _ = json.NewEncoder(w).Encode(map[string]any{"message": "unavailable", "code": 503}) + case r.Method == "POST" && f.failCreate: + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{"message": "provider rejected", "code": 400}) + case r.Method == "GET" && r.URL.Path == "/messaging/providers": + kind := "" + for _, query := range r.URL.Query()["queries[]"] { + if strings.Contains(query, `"provider"`) { + kind = strings.Split(strings.Split(query, `"values":["`)[1], `"`)[0] + } + } + matching := []map[string]any{} + for _, provider := range f.providers { + if provider["provider"] == kind { + matching = append(matching, provider) + } + } + _ = json.NewEncoder(w).Encode(map[string]any{"total": len(matching), "providers": matching}) + case r.Method == "POST": + kind := strings.TrimPrefix(r.URL.Path, "/messaging/providers/") + provider := providerDocument(kind, "id"+string(rune('0'+len(f.providers))), params) + f.providers = append(f.providers, provider) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(provider) + case r.Method == "PATCH": + parts := strings.Split(r.URL.Path, "/") + id := parts[len(parts)-1] + for index, provider := range f.providers { + if provider["$id"] == id { + f.providers[index] = providerDocument(provider["provider"].(string), id, params) + _ = json.NewEncoder(w).Encode(f.providers[index]) + + return + } + } + w.WriteHeader(http.StatusNotFound) + default: + w.WriteHeader(http.StatusNotFound) + } +} + +func providerDocument(kind, id string, params map[string]any) map[string]any { + credentials := map[string]any{} + options := map[string]any{} + for _, name := range []string{"authKey", "authKeyId", "teamId", "bundleId"} { + if value, ok := params[name]; ok { + credentials[name] = value + } + } + if sandbox, ok := params["sandbox"]; ok { + options["sandbox"] = sandbox + } + + return map[string]any{ + "$id": id, "name": params["name"], "provider": kind, "type": "push", + "enabled": params["enabled"], "credentials": credentials, "options": options, + } +} + +func newTestPushSetup(t *testing.T, server *httptest.Server, prompter prompt.Prompter, root string) (*pushSetup, *bytes.Buffer) { + t.Helper() + out := &bytes.Buffer{} + + return &pushSetup{ + api: client.New(server.URL, "test").SetProject("project"), + prompter: prompter, + out: out, + root: root, + keyDirs: []string{root}, + downloads: t.TempDir(), + open: func(string) {}, + }, out +} + +func TestInitApnsCreatesBothEnvironmentsAndEditsXcode(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + writeFile(t, filepath.Join(root, "ios", "MyApp.xcodeproj", "project.pbxproj"), testPbxproj) + writeFile(t, filepath.Join(root, "ios", "MyApp", "MyApp.entitlements"), "\n\n\n") + writeFile(t, filepath.Join(root, "ios", "MyApp", "Info.plist"), "\n\n\n\n") + untouched := map[string]string{} + for _, path := range []string{"Admin/Admin.entitlements", "Admin/Info.plist", "Widget/Widget.entitlements", "Widget/Info.plist"} { + untouched[path] = "\n\n\n\n" + writeFile(t, filepath.Join(root, "ios", path), untouched[path]) + } + keyPath := testApnsKey(t, t.TempDir()) + + scripted := &prompt.Scripted{} + setup, _ := newTestPushSetup(t, server, scripted, root) + detected, _ := detectApple(root) + if err := setup.apns(apnsOptions{keyPath: keyPath, bundleID: myAppBundle}, detected); err != nil { + t.Fatal(err) + } + + if len(scripted.Asked) != 0 { + t.Errorf("asked %v", scripted.Asked) + } + if len(messaging.providers) != 2 { + t.Fatalf("providers = %v", messaging.providers) + } + for index, sandbox := range []bool{false, true} { + provider := messaging.providers[index] + credentials := provider["credentials"].(map[string]any) + if credentials["bundleId"] != myAppBundle || credentials["teamId"] != "ABCDE12345" || + credentials["authKeyId"] != "KEY1234567" || provider["options"].(map[string]any)["sandbox"] != sandbox || + provider["enabled"] != true { + t.Errorf("provider %d = %v", index, provider) + } + } + + entitlements, _ := os.ReadFile(filepath.Join(root, "ios", "MyApp", "MyApp.entitlements")) + if !strings.Contains(string(entitlements), "aps-environment\n\tdevelopment") { + t.Errorf("entitlements:\n%s", entitlements) + } + info, _ := os.ReadFile(filepath.Join(root, "ios", "MyApp", "Info.plist")) + if !strings.Contains(string(info), "remote-notification") { + t.Errorf("Info.plist:\n%s", info) + } + for path, original := range untouched { + if contents, _ := os.ReadFile(filepath.Join(root, "ios", path)); string(contents) != original { + t.Errorf("%s was edited:\n%s", path, contents) + } + } + + previous := map[string]any{} + for _, provider := range messaging.providers { + previous[provider["$id"].(string)] = provider["credentials"].(map[string]any)["authKey"] + } + rotated, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + rotatedPath := filepath.Join(t.TempDir(), "AuthKey_ROTATE1234.p8") + writeKey(t, rotatedPath, rotated) + if err := setup.apns(apnsOptions{keyPath: rotatedPath, bundleID: myAppBundle}, detected); err != nil { + t.Fatal(err) + } + if len(messaging.providers) != 2 { + t.Fatalf("providers after rotation = %v", messaging.providers) + } + for _, provider := range messaging.providers { + id := provider["$id"].(string) + credentials := provider["credentials"].(map[string]any) + old, kept := previous[id] + if !kept || credentials["authKeyId"] != "ROTATE1234" || credentials["authKey"] == old { + t.Errorf("provider %s after rotation = %v", id, credentials) + } + } + + messaging.requests = nil + if err := setup.apns(apnsOptions{bundleID: myAppBundle}, detected); err != nil { + t.Fatal(err) + } + // A rerun only reads: the providers, the platform the first run added, and the Appwrite push provider. + if strings.Join(messaging.requests, ",") != "GET /messaging/providers,GET /project/platforms,GET /messaging/providers" { + t.Errorf("already set up requests = %v", messaging.requests) + } + if len(messaging.platforms) != 1 || messaging.platforms[0]["bundleIdentifier"] != myAppBundle { + t.Errorf("platforms = %v", messaging.platforms) + } +} + +func TestInitApnsAddsTheApplePlatformAndSaysWhatElseIsNeeded(t *testing.T) { + web := map[string]any{"$id": "web", "type": "web", "hostname": "example.com"} + messaging := &fakeMessaging{platforms: []map[string]any{web}} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, root) + err := setup.apns(apnsOptions{keyPath: testApnsKey(t, root), keyID: "ABCDE12345", teamID: "ABCDE12345", bundleID: "com.example.app", environment: "sandbox"}, appleApp{}) + if err != nil { + t.Fatal(err) + } + if len(messaging.platforms) != 2 || messaging.platforms[1]["bundleIdentifier"] != "com.example.app" { + t.Fatalf("platforms = %v", messaging.platforms) + } + text := out.String() + for _, part := range []string{"Added the Apple platform com.example.app", "Enable the Appwrite push provider", "AppwriteNotificationService"} { + if !strings.Contains(text, part) { + t.Errorf("output is missing %q:\n%s", part, text) + } + } +} + +func TestInitApnsUsesAKeyInTheProjectFolder(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + testApnsKey(t, root) + writeFile(t, filepath.Join(root, "notes.p8"), "not a key") + + scripted := &prompt.Scripted{} + setup, out := newTestPushSetup(t, server, scripted, root) + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + + if len(scripted.Asked) != 0 { + t.Errorf("asked %v", scripted.Asked) + } + if !strings.Contains(out.String(), "Using AuthKey_KEY1234567.p8 from the project folder.") { + t.Errorf("output:\n%s", out.String()) + } + if len(messaging.providers) != 2 || messaging.providers[0]["credentials"].(map[string]any)["authKeyId"] != "KEY1234567" { + t.Errorf("providers = %v", messaging.providers) + } +} + +func TestBrowserFlowFindsAKeySavedIntoTheProjectFolder(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + setup, _ := newTestPushSetup(t, server, &prompt.Scripted{}, root) + go func() { + time.Sleep(100 * time.Millisecond) + testApnsKey(t, root) + }() + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(messaging.providers) != 2 { + t.Errorf("providers = %v", messaging.providers) + } +} + +func TestKeysInAGitRepositoryMustBeIgnored(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not installed") + } + root := t.TempDir() + if err := exec.Command("git", "-C", root, "init", "-q").Run(); err != nil { + t.Skip("git init failed") + } + key := testApnsKey(t, root) + + out := &bytes.Buffer{} + setup := &pushSetup{out: out, keyDirs: []string{root}} + setup.checkKeyLocation(key) + if !strings.Contains(out.String(), "AuthKey_KEY1234567.p8 is inside a git repository and not ignored") { + t.Errorf("output:\n%s", out.String()) + } + + writeFile(t, filepath.Join(root, ".gitignore"), "*.p8\n") + out.Reset() + setup.checkKeyLocation(key) + if out.Len() != 0 { + t.Errorf("warned for an ignored key:\n%s", out.String()) + } +} + +// useAdapter makes adapter the only APNs key setup, under the default name, +// as an account with one team (APPLE12345) would see it. +func useAdapter(setup *pushSetup, adapter *apnstest.Adapter) *apnstest.Adapter { + adapter.AdapterName = defaultApnsSetup + if adapter.TeamID == "" { + adapter.TeamID = "APPLE12345" + } + setup.apnsAdapters = apns.NewRegistry(adapter) + + return adapter +} +func TestInitApnsCreatesTheKeyWithTheAppleID(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + scripted := &prompt.Scripted{} + setup, out := newTestPushSetup(t, server, scripted, root) + appleKey := useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + options := apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true} + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + + if len(scripted.Asked) != 0 { + t.Errorf("asked %v", scripted.Asked) + } + if len(appleKey.Requests) != 1 || appleKey.Requests[0].TeamID != "ABCDE12345" || !strings.HasPrefix(appleKey.Requests[0].Name, "Appwrite Push ") { + t.Errorf("helper calls = %v", appleKey.Requests) + } + saved := filepath.Join(root, "AuthKey_NEWKEY1234.p8") + info, err := os.Stat(saved) + if err != nil { + t.Fatalf("key was not saved: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("key mode = %v", info.Mode().Perm()) + } + if len(messaging.providers) != 2 { + t.Fatalf("providers = %v", messaging.providers) + } + for _, provider := range messaging.providers { + credentials := provider["credentials"].(map[string]any) + if credentials["authKeyId"] != "NEWKEY1234" || credentials["teamId"] != "ABCDE12345" { + t.Errorf("provider = %v", provider) + } + } + if !strings.Contains(out.String(), "Created APNs key NEWKEY1234 on team ABCDE12345 and saved it to AuthKey_NEWKEY1234.p8.") { + t.Errorf("output:\n%s", out.String()) + } + + // A second run finds both providers and does not spend another key slot. + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appleKey.Requests) != 1 { + t.Errorf("created another key on rerun: %v", appleKey.Requests) + } +} + +func TestInitApnsOffersTheAppleIDWhenNoKeyIsFound(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{Choices: map[string]string{ + "How would you like to provide the APNs auth key (.p8)?": "automatic", + }} + setup, _ := newTestPushSetup(t, server, scripted, t.TempDir()) + appleKey := useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appleKey.Requests) != 1 || len(messaging.providers) != 2 { + t.Errorf("calls = %v, providers = %v", appleKey.Requests, messaging.providers) + } +} + +func TestInitApnsStopsWhenCreateKeyFails(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + // A key already in the project folder must not stand in for the one + // --create-key was asked to create. + testApnsKey(t, root) + scripted := &prompt.Scripted{} + setup, _ := newTestPushSetup(t, server, scripted, root) + useAdapter(setup, &apnstest.Adapter{Err: errors.New("could not reach Apple")}) + + err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true}, appleApp{}) + if err == nil || err.Error() != "could not create the APNs key with your Apple ID: could not reach Apple" { + t.Fatalf("err = %v", err) + } + if len(scripted.Asked) != 0 || len(messaging.providers) != 0 { + t.Errorf("asked %v, providers %v", scripted.Asked, messaging.providers) + } +} + +// choiceSequence answers the same choice differently each time it is asked. +type choiceSequence struct { + *prompt.Scripted + answers []string +} + +func (c *choiceSequence) Choice(question prompt.Choice) (string, error) { + c.Scripted.Asked = append(c.Scripted.Asked, question.Message) + answer := c.answers[0] + c.answers = c.answers[1:] + + return answer, nil +} + +func TestInitApnsFallsBackWhenTheChosenAppleSignInFails(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + existing := testApnsKey(t, t.TempDir()) + scripted := &choiceSequence{ + Scripted: &prompt.Scripted{Texts: map[string]string{"Path to the APNs auth key (.p8)": existing}}, + answers: []string{"automatic", "file"}, + } + setup, out := newTestPushSetup(t, server, scripted, t.TempDir()) + useAdapter(setup, &apnstest.Adapter{Err: errors.New("could not reach Apple")}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Could not create the APNs key with your Apple ID: could not reach Apple.") { + t.Errorf("output:\n%s", out.String()) + } + if len(messaging.providers) != 2 || messaging.providers[0]["credentials"].(map[string]any)["authKeyId"] != "KEY1234567" { + t.Errorf("providers = %v", messaging.providers) + } +} + +func TestInitApnsStopsWhenTheTeamHasNoFreeKeySlot(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, root) + appleKey := &apnstest.Adapter{Err: &apns.MaxKeysError{Keys: []apns.ExistingKey{ + {ID: "OLDKEY1234", Name: "Firebase", CanRevoke: true}, + {ID: "OLDKEY5678", Name: "Expo", CanRevoke: true}, + }}} + useAdapter(setup, appleKey) + + err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), "--key-path") { + t.Fatalf("err = %v", err) + } + for _, line := range []string{"Firebase (OLDKEY1234)", "Expo (OLDKEY5678)", apnsKeysListPage} { + if !strings.Contains(out.String(), line) { + t.Errorf("output is missing %q:\n%s", line, out.String()) + } + } + if len(messaging.providers) != 0 { + t.Errorf("providers = %v", messaging.providers) + } + if entries, _ := os.ReadDir(root); len(entries) != 0 { + t.Errorf("wrote %v", entries) + } +} + +func TestInitApnsTakesTheTeamFromTheAppleSignIn(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{} + setup, _ := newTestPushSetup(t, server, scripted, t.TempDir()) + appleKey := useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", createKey: true}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(scripted.Asked) != 0 { + t.Errorf("asked %v", scripted.Asked) + } + if len(appleKey.Requests) != 1 || appleKey.Requests[0].TeamID != "" { + t.Errorf("helper calls = %v", appleKey.Requests) + } + for _, provider := range messaging.providers { + if provider["credentials"].(map[string]any)["teamId"] != "APPLE12345" { + t.Errorf("provider = %v", provider) + } + } +} + +func TestInitApnsAsksForTheTeamAfterAKeyFile(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + existing := testApnsKey(t, t.TempDir()) + scripted := &prompt.Scripted{ + Choices: map[string]string{"How would you like to provide the APNs auth key (.p8)?": "file"}, + Texts: map[string]string{ + "Path to the APNs auth key (.p8)": existing, + "What is your Apple Developer team ID?": "ABCDE12345", + }, + } + setup, _ := newTestPushSetup(t, server, scripted, t.TempDir()) + useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app"}, appleApp{}); err != nil { + t.Fatal(err) + } + want := []string{"How would you like to provide the APNs auth key (.p8)?", "Path to the APNs auth key (.p8)", "What is your Apple Developer team ID?"} + if strings.Join(scripted.Asked, "|") != strings.Join(want, "|") { + t.Errorf("asked %v", scripted.Asked) + } + if messaging.providers[0]["credentials"].(map[string]any)["teamId"] != "ABCDE12345" { + t.Errorf("providers = %v", messaging.providers) + } +} + +func TestInitApnsSetsUpOneEnvironment(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, root) + appleKey := useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + options := apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true, environment: "sandbox"} + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appleKey.Requests) != 1 || appleKey.Requests[0].Environment != apns.EnvironmentSandbox { + t.Errorf("helper calls = %v", appleKey.Requests) + } + if len(messaging.providers) != 1 || messaging.providers[0]["options"].(map[string]any)["sandbox"] != true { + t.Fatalf("providers = %v", messaging.providers) + } + if !strings.Contains(out.String(), "APNs (sandbox) is set up for com.example.app.") { + t.Errorf("output:\n%s", out.String()) + } + + // The sandbox provider is all a sandbox run needs. + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appleKey.Requests) != 1 || !strings.Contains(out.String(), "APNs (sandbox) is already set up for com.example.app.") { + t.Errorf("calls = %v, output:\n%s", appleKey.Requests, out.String()) + } + + // Production still needs its own provider; the key already in the folder + // serves it. + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", environment: "production"}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(messaging.providers) != 2 || messaging.providers[1]["options"].(map[string]any)["sandbox"] != false { + t.Errorf("providers = %v", messaging.providers) + } +} + +func TestInitApnsRejectsAnUnknownEnvironment(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, _ := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + err := setup.apns(apnsOptions{bundleID: "com.example.app", environment: "staging"}, appleApp{}) + if err == nil || err.Error() != `--environment must be production, sandbox or all, not "staging"` { + t.Fatalf("err = %v", err) + } + if len(messaging.requests) != 0 { + t.Errorf("requests = %v", messaging.requests) + } +} + +func TestInitApnsPicksTheSetupFromTheFlagThenTheEnvironment(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + appwrite := &apnstest.Adapter{AdapterName: "appwrite", KeyID: "APPWRITE01", TeamID: "ABCDE12345"} + expo := &apnstest.Adapter{AdapterName: "expo", KeyID: "EXPOKEY001", TeamID: "ABCDE12345"} + setup.apnsAdapters = apns.NewRegistry(appwrite, expo) + env := map[string]string{ + "APPWRITE_APNS_SETUP": "expo", + "APPWRITE_APPLE_ID": "dev@example.com", + "APPWRITE_APPLE_PASSWORD": "secret", + } + setup.getenv = func(name string) string { return env[name] } + options := apnsOptions{bundleID: "com.example.app", createKey: true} + + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(expo.Requests) != 1 || len(appwrite.Requests) != 0 { + t.Fatalf("expo = %d, appwrite = %d requests", len(expo.Requests), len(appwrite.Requests)) + } + request := expo.Requests[0] + if request.AppleID != "dev@example.com" || request.Password != "secret" || request.Asker == nil { + t.Errorf("request = %+v", request) + } + for _, line := range []string{"Signing in to the Apple Developer portal (expo).", "Using the Apple ID from APPWRITE_APPLE_ID.", "Using the Apple ID password from APPWRITE_APPLE_PASSWORD."} { + if !strings.Contains(out.String(), line) { + t.Errorf("output is missing %q:\n%s", line, out.String()) + } + } + if strings.Contains(out.String(), "secret") { + t.Errorf("printed the password:\n%s", out.String()) + } + + // Each run starts from no providers and a fresh folder: a key file is + // never overwritten. + fresh := func() { + messaging.providers = nil + setup.keyDirs = []string{t.TempDir()} + } + fresh() + options.setup = "appwrite" + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appwrite.Requests) != 1 || len(expo.Requests) != 1 { + t.Errorf("--provider did not win over %s", apnsSetupVariable) + } + + delete(env, "APPWRITE_APNS_SETUP") + options.setup = "" + fresh() + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appwrite.Requests) != 2 { + t.Errorf("the default is not %s", defaultApnsSetup) + } +} + +func TestInitApnsRejectsAMalformedBundleID(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, _ := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + err := setup.apns(apnsOptions{bundleID: "not a bundle id!", environment: "sandbox"}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), "is not a bundle ID") { + t.Fatalf("err = %v", err) + } + if len(messaging.requests) != 0 { + t.Errorf("requests = %v", messaging.requests) + } +} + +func TestInitApnsRejectsAnUnknownSetupBeforeAnyRequest(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, _ := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + setup.apnsAdapters = apns.NewRegistry(&apnstest.Adapter{AdapterName: "appwrite"}) + err := setup.apns(apnsOptions{bundleID: "com.example.app", environment: "all", setup: "nope"}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), `unknown APNs key setup "nope"`) { + t.Fatalf("err = %v", err) + } + if len(messaging.requests) != 0 { + t.Errorf("requests = %v", messaging.requests) + } +} + +func TestInitApnsReportsAnUnknownSetup(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, _ := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + setup.apnsAdapters = apns.NewRegistry(&apnstest.Adapter{AdapterName: "appwrite"}) + err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true, setup: "fastlane"}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), `unknown APNs key setup "fastlane": use one of appwrite`) { + t.Fatalf("err = %v", err) + } + if len(messaging.providers) != 0 { + t.Errorf("providers = %v", messaging.providers) + } +} + +func TestInitApnsWithoutSetupsOnlyOffersTheOtherWays(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + var offered []prompt.Option + scripted := &prompt.Scripted{Choices: map[string]string{"How would you like to provide the APNs auth key (.p8)?": "file"}, Texts: map[string]string{"Path to the APNs auth key (.p8)": testApnsKey(t, t.TempDir())}} + setup, _ := newTestPushSetup(t, server, optionsRecorder{scripted, &offered}, t.TempDir()) + setup.apnsAdapters = apns.NewRegistry() + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + for _, option := range offered { + if option.Value == "automatic" { + t.Errorf("offered the Apple sign-in with no setup built in: %v", offered) + } + } + + messaging.providers = nil + err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), "this build has none") { + t.Errorf("err = %v", err) + } +} + +// optionsRecorder records the options of the key question. +type optionsRecorder struct { + *prompt.Scripted + options *[]prompt.Option +} + +func (r optionsRecorder) Choice(question prompt.Choice) (string, error) { + if question.Message == "How would you like to provide the APNs auth key (.p8)?" { + *r.options = question.Options + } + + return r.Scripted.Choice(question) +} + +func TestInitApnsUsesTheCreatedKeysID(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{Choices: map[string]string{"How would you like to provide the APNs auth key (.p8)?": "automatic"}} + setup, out := newTestPushSetup(t, server, scripted, t.TempDir()) + useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", keyID: "WRONGKEY12"}, appleApp{}); err != nil { + t.Fatal(err) + } + for _, provider := range messaging.providers { + if provider["credentials"].(map[string]any)["authKeyId"] != "NEWKEY1234" { + t.Errorf("provider = %v", provider) + } + } + if !strings.Contains(out.String(), "Using the created key's ID NEWKEY1234 instead of --key-id WRONGKEY12.") { + t.Errorf("output:\n%s", out.String()) + } + + command := newInitApnsCommand() + if err := command.ParseFlags([]string{"--create-key", "--key-id", "WRONGKEY12"}); err != nil { + t.Fatal(err) + } + if err := command.ValidateFlagGroups(); err == nil || !strings.Contains(err.Error(), "key-id") { + t.Errorf("--create-key with --key-id: %v", err) + } +} + +// askingAdapter asks one question before creating a key, as a sign-in does. +type askingAdapter struct{} + +func (askingAdapter) Name() string { return defaultApnsSetup } + +func (askingAdapter) PrepareAppID(_ context.Context, request apns.Request, _ apns.AppIDRequest) (apns.AppIDResult, error) { + return apns.AppIDResult{TeamID: request.TeamID, Registered: true, Push: true}, nil +} + +func (askingAdapter) CreateKey(_ context.Context, request apns.Request) (apns.Key, error) { + if _, err := request.Asker.Ask("Enter the 6-digit code sent to +1 •••12", false); err != nil { + return apns.Key{}, err + } + + return apns.Key{KeyID: "NEWKEY1234", TeamID: request.TeamID, P8: apnstest.P8()}, nil +} + +func TestInitApnsExplainsASignInWithoutATerminal(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, _ := newTestPushSetup(t, server, prompt.NonInteractive{}, t.TempDir()) + setup.apnsAdapters = apns.NewRegistry(askingAdapter{}) + + err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true}, appleApp{}) + if err == nil { + t.Fatal("no error without a terminal") + } + message := err.Error() + for _, part := range []string{`asked "Enter the 6-digit code sent to +1 •••12"`, "APPWRITE_APPLE_ID", "the two-factor code always needs one", "instead of --create-key"} { + if !strings.Contains(message, part) { + t.Errorf("message is missing %q: %s", part, message) + } + } + if strings.Contains(message, "Pass --key-path instead") { + t.Errorf("suggests a flag --create-key rejects: %s", message) + } +} + +func TestInitApnsResetSignsInAgainAndCreatesAKey(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + adapter := useAdapter(setup, &apnstest.Adapter{KeyID: "FIRSTKEY12"}) + options := apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true} + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + + // Already set up, yet --reset signs in again and creates a key, without + // --create-key or --force. + setup.keyDirs = []string{t.TempDir()} + adapter.KeyID = "SECONDKEY1" + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", reset: true}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(adapter.Requests) != 2 || adapter.Requests[0].Reset || !adapter.Requests[1].Reset { + t.Fatalf("requests = %+v", adapter.Requests) + } + for _, provider := range messaging.providers { + if provider["credentials"].(map[string]any)["authKeyId"] != "SECONDKEY1" { + t.Errorf("provider = %v", provider) + } + } + if !strings.Contains(out.String(), "Signing in again: the saved Apple sign-in is not used (--reset).") { + t.Errorf("output:\n%s", out.String()) + } + + command := newInitApnsCommand() + if err := command.ParseFlags([]string{"--reset", "--key-path", "AuthKey_KEY1234567.p8"}); err != nil { + t.Fatal(err) + } + if err := command.ValidateFlagGroups(); err == nil || !strings.Contains(err.Error(), "key-path") { + t.Errorf("--reset with --key-path: %v", err) + } +} + +const appIDSignInQuestion = "Sign in with your Apple ID to check the App ID for com.example.app?" + +func TestInitApnsChecksTheAppIDAfterCreatingAKey(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{} + setup, out := newTestPushSetup(t, server, scripted, t.TempDir()) + adapter := useAdapter(setup, &apnstest.Adapter{ + KeyID: "NEWKEY1234", + AppIDResult: apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true, Created: true, PushTurnedOn: true}, + }) + + options := apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", reset: true} + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(adapter.AppIDRequests) != 1 || adapter.AppIDRequests[0] != (apns.AppIDRequest{BundleID: "com.example.app", Ask: true}) { + t.Fatalf("App ID calls = %v", adapter.AppIDRequests) + } + // The key's saved sign-in is reused for its Apple ID: no question, and + // --reset does not sign in a second time. + if signIn := adapter.AppIDSignIns[0]; signIn.TeamID != "ABCDE12345" || signIn.Reset || !signIn.SavedSignInOnly || signIn.AppleID != "dev@example.com" { + t.Errorf("App ID sign-in = %+v", signIn) + } + if len(scripted.Asked) != 0 { + t.Errorf("asked %v", scripted.Asked) + } + if !strings.Contains(out.String(), "Registered com.example.app as an App ID with Push Notifications on team ABCDE12345.") { + t.Errorf("output:\n%s", out.String()) + } +} + +func TestInitApnsChecksTheAppIDWithTheSavedSignIn(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{} + setup, out := newTestPushSetup(t, server, scripted, t.TempDir()) + adapter := useAdapter(setup, &apnstest.Adapter{ + AppIDResult: apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true}, + }) + + keyPath := testApnsKey(t, t.TempDir()) + if err := setup.apns(apnsOptions{keyPath: keyPath, bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(adapter.AppIDRequests) != 1 || !adapter.AppIDRequests[0].Ask || !adapter.AppIDSignIns[0].SavedSignInOnly { + t.Fatalf("App ID calls = %v, sign-ins = %v", adapter.AppIDRequests, adapter.AppIDSignIns) + } + if len(scripted.Asked) != 0 || !strings.Contains(out.String(), "com.example.app is registered as an App ID with Push Notifications.") { + t.Errorf("asked %v, output:\n%s", scripted.Asked, out.String()) + } + + // An already set-up project is checked on every run. + if err := setup.apns(apnsOptions{bundleID: "com.example.app"}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(adapter.AppIDRequests) != 2 || adapter.AppIDSignIns[1].TeamID != "ABCDE12345" { + t.Errorf("App ID calls = %v, sign-ins = %v", adapter.AppIDRequests, adapter.AppIDSignIns) + } +} + +func TestInitApnsAsksBeforeSigningInToCheckTheAppID(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{} + setup, out := newTestPushSetup(t, server, scripted, t.TempDir()) + adapter := useAdapter(setup, &apnstest.Adapter{ + SignInNeeded: true, + AppIDResult: apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true, PushTurnedOn: true}, + }) + keyPath := testApnsKey(t, t.TempDir()) + + // No saved sign-in and no answer: skipped, and the setup still succeeds. + if err := setup.apns(apnsOptions{keyPath: keyPath, bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(messaging.providers) != 2 || len(adapter.AppIDRequests) != 1 || strings.Join(scripted.Asked, "|") != appIDSignInQuestion { + t.Fatalf("providers = %v, App ID calls = %v, asked %v", messaging.providers, adapter.AppIDRequests, scripted.Asked) + } + if !strings.Contains(out.String(), "Skipped the App ID check. Run `appwrite init apns --register-app-id`") { + t.Errorf("output:\n%s", out.String()) + } + + // Agreeing signs in and checks. + scripted.Choices = map[string]string{appIDSignInQuestion: "check"} + if err := setup.apns(apnsOptions{bundleID: "com.example.app"}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(adapter.AppIDRequests) != 3 || adapter.AppIDSignIns[2].SavedSignInOnly || !adapter.AppIDRequests[2].Ask { + t.Fatalf("App ID calls = %v, sign-ins = %v", adapter.AppIDRequests, adapter.AppIDSignIns) + } + if !strings.Contains(out.String(), "Turned on Push Notifications for the App ID com.example.app.") { + t.Errorf("output:\n%s", out.String()) + } +} + +func TestInitApnsRegisterAppIDSignsInAndDoesNotAsk(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{} + setup, out := newTestPushSetup(t, server, scripted, t.TempDir()) + adapter := useAdapter(setup, &apnstest.Adapter{ + SignInNeeded: true, + AppIDResult: apns.AppIDResult{TeamID: "ABCDE12345", Registered: true, Push: true, Created: true, PushTurnedOn: true}, + }) + keyPath := testApnsKey(t, t.TempDir()) + options := apnsOptions{keyPath: keyPath, bundleID: "com.example.app", teamID: "ABCDE12345", registerAppID: true} + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(adapter.AppIDRequests) != 1 || adapter.AppIDRequests[0].Ask || adapter.AppIDSignIns[0].SavedSignInOnly || len(scripted.Asked) != 0 { + t.Fatalf("App ID calls = %v, sign-ins = %v, asked %v", adapter.AppIDRequests, adapter.AppIDSignIns, scripted.Asked) + } + if !strings.Contains(out.String(), "Registered com.example.app as an App ID with Push Notifications on team ABCDE12345.") { + t.Errorf("output:\n%s", out.String()) + } + + // Asked for explicitly, a failure fails the command. + adapter.AppIDErr = errors.New("could not reach Apple") + err := setup.apns(apnsOptions{bundleID: "com.example.app", registerAppID: true}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), "Could not check the App ID for com.example.app: could not reach Apple") { + t.Errorf("err = %v", err) + } +} + +func TestInitApnsSignsInAgainWhenTheNewKeysSignInIsNotSaved(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{} + setup, _ := newTestPushSetup(t, server, scripted, t.TempDir()) + adapter := useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234", SignInNeeded: true}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true}, appleApp{}); err != nil { + t.Fatal(err) + } + // The person just signed in for the key: no "sign in to check?" question. + if len(adapter.AppIDSignIns) != 2 || !adapter.AppIDSignIns[0].SavedSignInOnly || adapter.AppIDSignIns[1].SavedSignInOnly || len(scripted.Asked) != 0 { + t.Errorf("sign-ins = %+v, asked %v", adapter.AppIDSignIns, scripted.Asked) + } +} diff --git a/templates/cli/internal/cmd/initpushdetect.go b/templates/cli/internal/cmd/initpushdetect.go new file mode 100644 index 0000000000..90b009f417 --- /dev/null +++ b/templates/cli/internal/cmd/initpushdetect.go @@ -0,0 +1,319 @@ +//go:build !browser + +package cmd + +import ( + "encoding/json" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +type appleTarget struct { + Project string + BundleID string + TeamID string + Entitlements []string + InfoPlists []string +} + +type appleApp struct { + Projects []string + Targets []appleTarget + BundleIDs []string + TeamIDs []string + ExpoConfig string +} + +func (a appleApp) targets(bundleID string) []appleTarget { + var targets []appleTarget + for _, target := range a.Targets { + if target.BundleID == bundleID { + targets = append(targets, target) + } + } + + return targets +} + +func (a appleApp) teams(bundleID string) []string { + var teams []string + for _, target := range a.targets(bundleID) { + if target.TeamID != "" { + teams = appendUnique(teams, target.TeamID) + } + } + if len(teams) == 0 && len(a.TeamIDs) == 1 { + return a.TeamIDs + } + + return teams +} + +const detectDepth = 4 + +var skippedDirectories = map[string]bool{ + "node_modules": true, + "Pods": true, + ".git": true, + "build": true, + "DerivedData": true, + ".dart_tool": true, + ".gradle": true, + "vendor": true, + ".expo": true, +} + +func walkProject(root string, visit func(path string, entry fs.DirEntry) error) error { + root = filepath.Clean(root) + base := strings.Count(root, string(filepath.Separator)) + + return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + if path == root { + return err + } + + return nil + } + if entry.IsDir() && path != root { + if skippedDirectories[entry.Name()] { + return filepath.SkipDir + } + if strings.Count(path, string(filepath.Separator))-base > detectDepth { + return filepath.SkipDir + } + } + + return visit(path, entry) + }) +} + +const applicationProductType = "com.apple.product-type.application" + +var ( + pbxObject = regexp.MustCompile(`(?ms)^\t\t([0-9A-Fa-f]{24})(?: /\*.*?\*/)? = \{\n(.*?)\n\t\t\};`) + pbxIsa = regexp.MustCompile(`(?m)^\t\t\tisa = (\w+);`) + pbxProductType = regexp.MustCompile(`(?m)^\t\t\tproductType = "?([^";]+)"?;`) + pbxConfigList = regexp.MustCompile(`(?m)^\t\t\tbuildConfigurationList = ([0-9A-Fa-f]{24})`) + pbxConfigs = regexp.MustCompile(`(?s)buildConfigurations = \((.*?)\);`) + pbxReference = regexp.MustCompile(`[0-9A-Fa-f]{24}`) + pbxBuildSettings = regexp.MustCompile(`(?s)buildSettings = \{(.*?)\n\t\t\t\};`) + pbxSetting = regexp.MustCompile(`(?m)^\s*([A-Za-z_][A-Za-z0-9_]*) = (.*?);\s*$`) + appleTeamID = regexp.MustCompile(`^[A-Z0-9]{10}$`) +) + +func pbxValue(value string) string { + value = strings.TrimSpace(value) + if len(value) >= 2 && strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`) { + value = value[1 : len(value)-1] + } + + return value +} + +func rfc1034(value string) string { + var builder strings.Builder + for _, character := range value { + switch { + case character >= 'a' && character <= 'z', character >= 'A' && character <= 'Z', + character >= '0' && character <= '9', character == '-', character == '.': + builder.WriteRune(character) + default: + builder.WriteRune('-') + } + } + + return builder.String() +} + +func resolveBundleID(bundleID string, settings map[string]string) string { + name := settings["PRODUCT_NAME"] + if name != "" && !strings.Contains(name, "$(") { + bundleID = strings.ReplaceAll(bundleID, "$(PRODUCT_NAME:rfc1034identifier)", rfc1034(name)) + bundleID = strings.ReplaceAll(bundleID, "${PRODUCT_NAME:rfc1034identifier}", rfc1034(name)) + bundleID = strings.ReplaceAll(bundleID, "$(PRODUCT_NAME)", name) + bundleID = strings.ReplaceAll(bundleID, "${PRODUCT_NAME}", name) + } + if strings.Contains(bundleID, "$") { + return "" + } + + return bundleID +} + +func projectPath(projectDir, value string) string { + value = strings.TrimPrefix(value, "$(SRCROOT)/") + value = strings.TrimPrefix(value, "${SRCROOT}/") + value = strings.TrimPrefix(value, "$(PROJECT_DIR)/") + if strings.Contains(value, "$") { + return "" + } + + return filepath.Join(projectDir, value) +} + +func pbxSettings(body string) map[string]string { + settings := map[string]string{} + block := pbxBuildSettings.FindStringSubmatch(body) + if block == nil { + return settings + } + for _, setting := range pbxSetting.FindAllStringSubmatch(block[1], -1) { + settings[setting[1]] = pbxValue(setting[2]) + } + + return settings +} + +func readXcodeProject(app *appleApp, xcodeproj string) { + contents, err := os.ReadFile(filepath.Join(xcodeproj, "project.pbxproj")) + if err != nil { + return + } + app.Projects = append(app.Projects, xcodeproj) + projectDir := filepath.Dir(xcodeproj) + + objects := map[string]string{} + var applications []string + for _, object := range pbxObject.FindAllStringSubmatch(string(contents), -1) { + objects[object[1]] = object[2] + isa := pbxIsa.FindStringSubmatch(object[2]) + productType := pbxProductType.FindStringSubmatch(object[2]) + if isa != nil && isa[1] == "PBXNativeTarget" && productType != nil && productType[1] == applicationProductType { + applications = append(applications, object[2]) + } + } + + for _, application := range applications { + list := pbxConfigList.FindStringSubmatch(application) + if list == nil { + continue + } + configs := pbxConfigs.FindStringSubmatch(objects[list[1]]) + if configs == nil { + continue + } + + byBundle := map[string]*appleTarget{} + var order []string + for _, reference := range pbxReference.FindAllString(configs[1], -1) { + settings := pbxSettings(objects[reference]) + bundleID := resolveBundleID(settings["PRODUCT_BUNDLE_IDENTIFIER"], settings) + if bundleID == "" { + continue + } + target, ok := byBundle[bundleID] + if !ok { + target = &appleTarget{Project: xcodeproj, BundleID: bundleID} + byBundle[bundleID] = target + order = append(order, bundleID) + } + if team := settings["DEVELOPMENT_TEAM"]; appleTeamID.MatchString(team) && target.TeamID == "" { + target.TeamID = team + } + if path := projectPath(projectDir, settings["CODE_SIGN_ENTITLEMENTS"]); settings["CODE_SIGN_ENTITLEMENTS"] != "" && path != "" { + target.Entitlements = appendUnique(target.Entitlements, path) + } + if path := projectPath(projectDir, settings["INFOPLIST_FILE"]); settings["INFOPLIST_FILE"] != "" && path != "" { + target.InfoPlists = appendUnique(target.InfoPlists, path) + } + } + for _, bundleID := range order { + app.Targets = append(app.Targets, *byBundle[bundleID]) + } + } +} + +type expoConfig struct { + Expo struct { + IOS struct { + BundleIdentifier string `json:"bundleIdentifier"` + AppleTeamID string `json:"appleTeamId"` + } `json:"ios"` + } `json:"expo"` +} + +func readExpoConfig(root string) (expoConfig, string) { + for _, name := range []string{"app.json", "app.config.json"} { + path := filepath.Join(root, name) + contents, err := os.ReadFile(path) + if err != nil { + continue + } + var config expoConfig + if json.Unmarshal(contents, &config) == nil { + return config, path + } + } + + return expoConfig{}, "" +} + +func detectApple(root string) (appleApp, error) { + app := appleApp{} + + err := walkProject(root, func(path string, entry fs.DirEntry) error { + if entry.IsDir() && strings.HasSuffix(entry.Name(), ".xcodeproj") { + readXcodeProject(&app, path) + + return filepath.SkipDir + } + + return nil + }) + if err != nil { + return app, err + } + + config, path := readExpoConfig(root) + if bundleID := config.Expo.IOS.BundleIdentifier; bundleID != "" { + app.ExpoConfig = path + team := "" + if appleTeamID.MatchString(config.Expo.IOS.AppleTeamID) { + team = config.Expo.IOS.AppleTeamID + } + matched := false + for index := range app.Targets { + if app.Targets[index].BundleID == bundleID { + matched = true + if app.Targets[index].TeamID == "" { + app.Targets[index].TeamID = team + } + } + } + if !matched { + app.Targets = append(app.Targets, appleTarget{BundleID: bundleID, TeamID: team}) + } + } + + for _, target := range app.Targets { + app.BundleIDs = appendUnique(app.BundleIDs, target.BundleID) + if target.TeamID != "" { + app.TeamIDs = appendUnique(app.TeamIDs, target.TeamID) + } + } + sort.Strings(app.BundleIDs) + + return app, nil +} + +var ( + gradleApplicationPlugin = regexp.MustCompile(`com\.android\.application`) + gradleApplicationID = regexp.MustCompile(`\bapplicationId\s*(?:=\s*)?["']([^"']+)["']`) + gradleNamespace = regexp.MustCompile(`\bnamespace\s*(?:=\s*)?["']([^"']+)["']`) + dartProjectID = regexp.MustCompile(`projectId:\s*'([^']+)'`) +) + +func appendUnique(values []string, value string) []string { + for _, existing := range values { + if existing == value { + return values + } + } + + return append(values, value) +} diff --git a/tests/e2e/CLIGo126Test.php b/tests/e2e/CLIGo126Test.php index 1849f9f3d3..3dec0b451f 100644 --- a/tests/e2e/CLIGo126Test.php +++ b/tests/e2e/CLIGo126Test.php @@ -27,6 +27,8 @@ final class CLIGo126Test extends Base #[Override] protected array $build = [ 'docker run --rm -v $(pwd):/app -w /app/tests/e2e/sdks/cli golang:1.26 go mod tidy', + // Only this build gets applehosts.go, which points the Apple sign-in at the mock API. + 'cp tests/e2e/languages/cli/applehosts.go tests/e2e/sdks/cli/internal/cmd/applehosts.go', // -buildvcs=false: the tree is generated inside this repository, and in // CI the checkout and the container disagree on ownership, so the git // stamp fails with "dubious ownership". Linux only. @@ -49,6 +51,8 @@ final class CLIGo126Test extends Base ...Base::UPLOAD_RESPONSE, ...Base::CLI_HEADERS_RESPONSES, 'CLI_TYPES_DEPENDENCIES:passed', + 'CLI_INIT_APNS:passed', + 'CLI_INIT_APNS_APPLE:passed', 'CLI_CONFORMANCE:passed', ]; diff --git a/tests/e2e/languages/cli/applehosts.go b/tests/e2e/languages/cli/applehosts.go new file mode 100644 index 0000000000..3d42165497 --- /dev/null +++ b/tests/e2e/languages/cli/applehosts.go @@ -0,0 +1,18 @@ +package cmd + +import ( + "os" + + "github.com/repoowner/reponame/internal/apns/appwrite" +) + +func init() { + e2eAppleHosts = func() appwrite.Hosts { + host := os.Getenv("APPWRITE_APPLE_HOST") + if host == "" { + return appwrite.Hosts{} + } + + return appwrite.Hosts{Idmsa: host, AppStoreConnect: host, Portal: host} + } +} diff --git a/tests/e2e/languages/cli/main.go b/tests/e2e/languages/cli/main.go index c0c85d5982..e98ca79f54 100644 --- a/tests/e2e/languages/cli/main.go +++ b/tests/e2e/languages/cli/main.go @@ -16,6 +16,11 @@ package main import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" "errors" "fmt" "os" @@ -94,6 +99,10 @@ func main() { harness.typesDependencies() fmt.Println("CLI_TYPES_DEPENDENCIES:passed") + harness.initApns() + fmt.Println("CLI_INIT_APNS:passed") + harness.initApnsApple() + fmt.Println("CLI_INIT_APNS_APPLE:passed") fmt.Println("CLI_CONFORMANCE:passed") } @@ -290,6 +299,141 @@ func (h *harness) value(args ...string) string { return "" } +// initApns sets up APNs for a project folder against the mock API: both providers and the Apple +// platform on the first run, nothing new on a rerun, and nothing written for bad input. The App ID +// check needs an Apple sign-in, and without a terminal it is skipped. +func (h *harness) initApns() { + project := h.apnsProject() + defer os.RemoveAll(project.dir) + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + fail(err) + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + fail(err) + } + project.write("AuthKey_ABCDE12345.p8", string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))) + + first := project.run(true, "--key-path", "AuthKey_ABCDE12345.p8", "--team-id", "ABCDE12345", "--bundle-id", "io.appwrite.e2e") + expectOutput(first, + "Created provider APNs (io.appwrite.e2e)", + "Created provider APNs sandbox (io.appwrite.e2e)", + "APNs is set up for io.appwrite.e2e", + "Added the Apple platform io.appwrite.e2e to the project", + "Skipped the App ID check", + "Enable the Appwrite push provider", + "AppwriteNotificationService", + ) + + again := project.run(true, "--bundle-id", "io.appwrite.e2e") + expectOutput(again, "APNs is already set up for io.appwrite.e2e") + if strings.Contains(again, "Added the Apple platform") || strings.Contains(again, "Created provider") { + fail(fmt.Errorf("a rerun changed the project:\n%s", again)) + } + + expectOutput(project.run(false, "--bundle-id", "not a bundle id!", "--key-path", "AuthKey_ABCDE12345.p8", "--team-id", "ABCDE12345"), "is not a bundle ID") + expectOutput(project.run(false, "--bundle-id", "io.appwrite.e2e", "--provider", "nope"), `unknown APNs key setup "nope"`) + expectOutput(project.run(false, "--bundle-id", "io.appwrite.e2e", "--environment", "staging"), "--environment must be production, sandbox or all") +} + +// initApnsApple signs in to the mock of Apple with an Apple ID and password, creates the key and +// registers the App ID, then reuses the saved sign-in: to check the App ID on a rerun, to turn on +// Push Notifications for an existing App ID, and to create keys until the team has no free slot. +func (h *harness) initApnsApple() { + project := h.apnsProject() + defer os.RemoveAll(project.dir) + + appleID := project.name + "@example.com" + project.env = []string{"APPWRITE_APPLE_HOST=http://mockapi/apple"} + first := project.runWith([]string{"APPWRITE_APPLE_ID=" + appleID, "APPWRITE_APPLE_PASSWORD=e2e-password"}, + true, "--create-key", "--register-app-id", "--bundle-id", "io.appwrite.e2e.apple") + expectOutput(first, + "Using the Apple ID from APPWRITE_APPLE_ID", + "Using Apple team E2E Team (E2ETEAM123)", + "Created provider APNs (io.appwrite.e2e.apple)", + "Created provider APNs sandbox (io.appwrite.e2e.apple)", + "Registered io.appwrite.e2e.apple as an App ID with Push Notifications on team E2ETEAM123", + ) + keys, err := filepath.Glob(filepath.Join(project.dir, "AuthKey_*.p8")) + if err != nil || len(keys) != 1 { + fail(fmt.Errorf("want the created key saved in the project folder, found %v:\n%s", keys, first)) + } + + expectOutput(project.run(true, "--bundle-id", "io.appwrite.e2e.apple"), + "APNs is already set up for io.appwrite.e2e.apple", + "Using the saved Apple sign-in for "+appleID, + "io.appwrite.e2e.apple is registered as an App ID with Push Notifications", + ) + + expectOutput(project.run(true, "--register-app-id", "--bundle-id", "io.appwrite.e2e.nopush", "--key-path", filepath.Base(keys[0]), "--team-id", "E2ETEAM123"), + "Using the saved Apple sign-in for "+appleID, + "Turned on Push Notifications for the App ID io.appwrite.e2e.nopush", + ) + + project.run(true, "--create-key", "--bundle-id", "io.appwrite.e2e.second") + expectOutput(project.run(false, "--create-key", "--bundle-id", "io.appwrite.e2e.third"), "this team has no free slot") +} + +type apnsProject struct { + h *harness + dir string + name string + env []string +} + +// apnsProject is a project folder with a project of its own: the mock keeps state across runs. +func (h *harness) apnsProject() *apnsProject { + dir, err := os.MkdirTemp("", "appwrite-cli-apns-") + if err != nil { + fail(err) + } + project := &apnsProject{h: h, dir: dir, name: "apns-e2e-" + filepath.Base(dir)[len("appwrite-cli-apns-"):]} + project.write("appwrite.config.json", `{"projectId":"`+project.name+`","endpoint":"http://mockapi/v1"}`) + + return project +} + +func (p *apnsProject) write(name, contents string) { + if err := os.WriteFile(filepath.Join(p.dir, name), []byte(contents), 0o600); err != nil { + fail(err) + } +} + +func (p *apnsProject) run(wantSuccess bool, args ...string) string { + return p.runWith(nil, wantSuccess, args...) +} + +// runWith runs `init apns` in the project folder with extra environment variables. +func (p *apnsProject) runWith(env []string, wantSuccess bool, args ...string) string { + absolute, err := filepath.Abs(binary) + if err != nil { + fail(err) + } + command := p.h.command(append([]string{"init", "apns"}, args...)...) + command.Dir = p.dir + command.Path = absolute + command.Env = append(append(command.Env, p.env...), env...) + var combined strings.Builder + command.Stdout = &combined + command.Stderr = &combined + err = command.Run() + if (err == nil) != wantSuccess { + fail(fmt.Errorf("`init apns %s` exit = %v, want success %v:\n%s", strings.Join(args, " "), err, wantSuccess, combined.String())) + } + + return combined.String() +} + +func expectOutput(output string, want ...string) { + for _, part := range want { + if !strings.Contains(output, part) { + fail(fmt.Errorf("init apns output is missing %q:\n%s", part, output)) + } + } +} + func fail(err error) { fmt.Fprintln(os.Stderr, "conformance harness:", err) os.Exit(1)