9 Commits
Author SHA1 Message Date
Mark Gregory 1340c53cd5 new scripts 2026-10-19 15:03:27 +01:00
Mark Gregory b81829012a new scripts 2026-10-19 15:02:33 +01:00
Mark Gregory ac0c5410c1 updates 2026-10-19 14:11:28 +01:00
Mark Gregory 26a2b448a5 new scripts 2026-10-19 14:11:01 +01:00
Mark Gregory 7cde037a4b cod consolidation and style updates 2026-09-28 16:24:16 +01:00
Mark Gregory f10ed942c0 new updates for paddel 2026-09-09 16:10:28 +01:00
Mark Gregory d541f492cf paddel setup new verifier 2026-09-09 14:40:18 +01:00
Mark Gregory e802dd8cf8 paddel setup new verifier 2026-09-09 12:36:34 +01:00
Mark Gregory a9bc0e5038 paddel setup new verifier 2026-09-09 12:33:45 +01:00
199 changed files with 6349 additions and 1339 deletions
Regular → Executable
+252
View File
@@ -1,5 +1,257 @@
.idea
.DS_Store
build/
DerivedData/
*.xcuserstate
xcuserdata/
/releases/
/.build/
.DS_Store
.AppleDouble
.LSOverride
Icon?
._*
.Spotlight-V100
.Trashes
.fseventsd
.TemporaryItems
*.swp
*.swo
*~
# ------------------------------------------------------------
# Xcode Build Products
# ------------------------------------------------------------
build/
Build/
DerivedData/
.build/
*.app
*.dSYM
*.dSYM.zip
*.ipa
*.xcarchive
*.ipa
*.appex
*.o
*.a
*.dylib
*.swiftmodule
*.swiftdoc
*.swiftsourceinfo
# ------------------------------------------------------------
# Xcode User Settings
# ------------------------------------------------------------
xcuserdata/
*.xcuserstate
*.xcuserdatad/
*.xccheckout
*.xcscmblueprint
*.xcuserstate
*.mode1v3
*.mode2v3
*.perspectivev3
# ------------------------------------------------------------
# Xcode Temporary Files
# ------------------------------------------------------------
*.hmap
*.ipa
*.ipa.zip
*.xccrashpoint
*.xcactivitylog
*.profdata
*.profraw
*.gcda
*.gcno
# ------------------------------------------------------------
# Swift Package Manager
# ------------------------------------------------------------
.swiftpm/xcode/package.xcworkspace/xcuserdata/
.swiftpm/configuration/
Packages/
.checkouts/
.repositories/
# Keep Package.resolved tracked for reproducible builds.
# ------------------------------------------------------------
# CocoaPods / Carthage
# ------------------------------------------------------------
Pods/
Carthage/Build/
# ------------------------------------------------------------
# Temporary Files
# ------------------------------------------------------------
*.tmp
*.temp
*.bak
*.backup
*.orig
*.rej
*.swift.bak
*.swift.backup-*
*.pbxproj.bak
*.pbxproj.backup-*
*.log
logs/
tmp/
temp/
# ------------------------------------------------------------
# Local Environment / Secrets
# ------------------------------------------------------------
.env
.env.*
!.env.example
*.pem
*.p12
*.p8
*.key
*.mobileprovision
Secrets/
secrets/
credentials.json
credentials.plist
config.local.json
config.local.plist
LocalConfig.swift
Secrets.swift
# ------------------------------------------------------------
# MySQL / Database Files
# ------------------------------------------------------------
*.sql
*.sql.gz
*.sql.zip
*.sqlite
*.sqlite3
*.db
*.db-shm
*.db-wal
*.sqlite-shm
*.sqlite-wal
*.myd
*.myi
*.ibd
*.dump
*.backup.sql
# ------------------------------------------------------------
# Test / Coverage Results
# ------------------------------------------------------------
*.xcresult
*.xccovreport
*.xccovarchive
coverage/
.coverage/
# ------------------------------------------------------------
# Crash Reports
# ------------------------------------------------------------
*.crash
*.ips
*.spin
*.hang
# ------------------------------------------------------------
# Editor Settings
# ------------------------------------------------------------
.vscode/
.idea/
*.sublime-workspace
# ------------------------------------------------------------
# Python Helper Scripts
# ------------------------------------------------------------
__pycache__/
*.pyc
.venv/
venv/
# ------------------------------------------------------------
# Distribution / Packaging
# ------------------------------------------------------------
dist/
release/
releases/
*.dmg
*.pkg
*.zip
# ------------------------------------------------------------
# IMPORTANT: Keep these tracked
# ------------------------------------------------------------
# WorkbenchReplacement.xcodeproj/
# WorkbenchReplacement/*.swift
# WorkbenchReplacement/Resources/
# WorkbenchReplacement/Assets.xcassets/
# *.icns
# *.png
# Package.resolved
# README.md
# LICENSE
/build/
/test-results/
+8
View File
@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.build" />
<excludeFolder url="file://$MODULE_DIR$/.build/Build" />
<excludeFolder url="file://$MODULE_DIR$/.build/Logs/Build" />
<excludeFolder url="file://$MODULE_DIR$/Rsync Studio.xcodeproj/project.xcworkspace/xcuserdata" />
<excludeFolder url="file://$MODULE_DIR$/Rsync Studio.xcodeproj/xcuserdata" />
<excludeFolder url="file://$MODULE_DIR$/build" />
<excludeFolder url="file://$MODULE_DIR$/releases" />
<excludeFolder url="file://$MODULE_DIR$/rsync-studio-licensing/.idea" />
<excludeFolder url="file://$MODULE_DIR$/rsync-studio-licensing/public/build" />
<excludeFolder url="file://$MODULE_DIR$/rsync-studio-licensing/vendor" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/RsyncStudio.iml" filepath="$PROJECT_DIR$/.idea/RsyncStudio.iml" />
</modules>
</component>
</project>
Generated
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/rsync-studio-licensing" vcs="Git" />
</component>
</project>
Regular → Executable
View File
View File

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 157 KiB

-36
View File
@@ -1,36 +0,0 @@
# Rsync Studio — Security / Release Audit
This project targets direct macOS distribution using Developer ID signing and Apple notarisation. App Sandbox is intentionally disabled because the product launches `rsync`/SSH and operates on arbitrary user-configured filesystem paths.
## Security controls present
- Hardened Runtime enabled for Debug and Release target configurations.
- No shell (`/bin/sh -c`) command construction; `Process` uses an absolute executable path and argument array.
- SSH host-key checking is not disabled.
- Automated SSH uses BatchMode, timeout and keepalive limits.
- SSH password authentication/storage is not supported; legacy stored password data is deleted.
- Remote host, username, port, key path and remote destination are validated.
- Remote paths reject shell metacharacters before rsync can invoke the remote shell.
- Advanced arguments reject transport replacement, remote-command, credential, arbitrary list/log/batch path and destructive overrides.
- Malformed Advanced argument quoting/trailing escapes are rejected.
- `--` terminates rsync option parsing before source/destination operands.
- Local source/destination overlap is rejected.
- File-watch and exclusion values reject control characters.
- Scheduled/watch delete requires explicit per-job authorisation.
- Run logs are ANSI-cleaned, likely credentials redacted and size-capped before persistence.
- Run history has bounded retention.
- Privacy manifest declares no tracking/no collected data and the app-specific UserDefaults required reason.
- Launch at login uses `SMAppService` and remains user-controlled.
- No analytics/telemetry or application HTTP client code is present.
## Required release verification on a Mac
Run:
```bash
./Scripts/release-preflight.sh
```
Then archive with Developer ID, submit with Apple `notarytool` (or Xcode Organizer), staple the ticket and run the signature/Gatekeeper checks in `Docs/RELEASE.md`.
A Linux source audit cannot substitute for Xcode's macOS SDK typecheck, code signing, notarisation or Gatekeeper assessment.
-88
View File
@@ -1,88 +0,0 @@
# Rsync Studio — Commercial Release Stage 1
This document freezes the commercial distribution identity and release-security baseline for Rsync Studio 1.0.0.
## Product identity
- Product: **Rsync Studio**
- Bundle identifier: **`net.absdev.RsyncStudio`**
- Marketing version: **`1.0.0`**
- Build number: **`1`**
- Minimum macOS: **14.0**
- Category: **Utilities**
- Distribution model: **Direct distribution outside the Mac App Store**
The bundle identifier is now treated as permanent product identity. Do not change it between commercial releases. Changing it creates a different application identity for macOS, Keychain access, preferences, update logic and future licensing.
## Apple signing baseline
Rsync Studio must ship with:
1. An active Apple Developer Program membership.
2. A **Developer ID Application** certificate belonging to the publisher's Apple Developer Team.
3. Hardened Runtime enabled.
4. No `get-task-allow` entitlement in a Release archive.
5. Successful Apple notarization.
6. A stapled notarization ticket before publication.
7. Successful Gatekeeper assessment of the exact artifact customers will download.
The Apple Team ID and certificate's full identity are deliberately not committed into the repository. Select the correct team in Xcode on the controlled release Mac. This avoids embedding developer-account-specific credentials into source control.
## App Sandbox
The App Sandbox remains intentionally disabled. Rsync Studio launches command-line synchronization/SSH tooling and must work with arbitrary user-selected filesystem locations. This is a direct-distribution architecture decision, not an accidental security relaxation.
Hardened Runtime remains mandatory.
## Versioning policy
Use semantic product versions:
- `1.0.0`, `1.0.1`, `1.1.0`, `2.0.0`
Increment `CURRENT_PROJECT_VERSION` for every build that may be distributed. Never publish two different binaries using the same version/build pair.
## Required Stage 1 checks
Run:
```bash
./Scripts/verify-release-config.sh
./Scripts/security-check.sh
./Scripts/release-preflight.sh
```
`release-preflight.sh` must be run on macOS with Xcode installed. It performs a clean Release build without signing so compiler/SDK failures are caught before creating the signed archive.
## First signed archive checklist
On the release Mac:
1. Open `Rsync Studio.xcodeproj` in Xcode.
2. Select the **Rsync Studio** target → **Signing & Capabilities**.
3. Select the publisher's Apple Developer Team.
4. Confirm Hardened Runtime is enabled.
5. Archive a **Release** build for macOS.
6. In Organizer choose direct/Developer ID distribution.
7. Confirm the archive is signed with **Developer ID Application**, not Apple Development.
8. Notarize the exported app/container with Apple.
9. Staple the ticket.
10. Verify the exact final artifact before uploading it anywhere.
## Signed artifact verification
For the exported application:
```bash
codesign --verify --deep --strict --verbose=2 "/path/to/Rsync Studio.app"
codesign -dv --verbose=4 "/path/to/Rsync Studio.app"
codesign -d --entitlements :- "/path/to/Rsync Studio.app"
spctl --assess --type execute --verbose=4 "/path/to/Rsync Studio.app"
xcrun stapler validate "/path/to/Rsync Studio.app"
```
The signing output must identify a Developer ID Application certificate belonging to the intended publisher. The entitlements must not contain `com.apple.security.get-task-allow`.
## Stage boundary
Stage 1 does **not** add customer licensing, payment handling, trial enforcement, DMG artwork, automatic installation of updates, or a licensing server. Those are intentionally separate stages so signing/distribution identity is stable before commercial logic is introduced.
-104
View File
@@ -1,104 +0,0 @@
# Rsync Studio — Commercial Release Stage 2
Stage 2 adds a reproducible, non-publishing direct-distribution release pipeline for macOS.
## What the pipeline does
`Scripts/release.sh` performs these steps in order:
1. Runs the existing release/security preflight.
2. Creates a clean Xcode Release archive.
3. Exports the archive using Apple's `developer-id` distribution method.
4. Verifies that the exported application is signed by **Developer ID Application** and has Hardened Runtime enabled.
5. Rejects `get-task-allow` and an unexpectedly sandboxed Release app.
6. Builds a compressed drag-to-Applications DMG using `hdiutil`.
7. Validates the configured `notarytool` Keychain profile.
8. Notarizes the exported app inside a temporary ZIP, staples its ticket directly to the app, validates it, then removes the temporary ZIP.
9. Builds the final DMG from that already-notarized/stapled app and validates the DMG container.
10. Submits the final DMG to Apple's notarization service and waits for the result.
11. Staples and validates the DMG notarization ticket.
12. Re-runs Gatekeeper/signature checks after notarization.
13. Produces a SHA-256 checksum and release metadata file.
14. Stops without uploading or publishing anything.
Existing release directories are immutable: the script refuses to overwrite `build/releases/<version>-<build>`. Increment the build number for a replacement build.
## One-time Mac setup
Install/sign into the current Xcode release and make sure a **Developer ID Application** certificate for your Apple Developer team is available in Keychain Access/Xcode.
Store notarization credentials once:
```bash
xcrun notarytool store-credentials "Rsync Studio Notary"
```
Apple will prompt for the required account/team credentials. The release script never stores those credentials in the source repository.
If Xcode cannot infer your team, run releases with your 10-character Apple Developer Team ID:
```bash
DEVELOPMENT_TEAM=YOURTEAMID ./Scripts/release.sh
```
Do not commit the Team ID solely because a local machine needs it; Xcode signing configuration can supply it.
## Produce a commercial build
From the repository root on the release Mac:
```bash
./Scripts/release.sh
```
To use another notarization Keychain profile:
```bash
./Scripts/release.sh --notary-profile MyNotaryProfile
```
For a signing/package smoke test without Apple notarization:
```bash
./Scripts/release.sh --skip-notarization
```
A skipped-notarization build is **not** suitable for customer distribution.
## Output
For version `1.0.0`, build `1`, the output is:
```text
build/releases/1.0.0-1/
├── Rsync Studio.xcarchive
├── export/
│ └── Rsync Studio.app
├── Rsync Studio-1.0.0.dmg
├── Rsync Studio-1.0.0.dmg.sha256
└── release-info.txt
```
The release directory deliberately includes the archive and exported application so a shipped artifact can be traced back to the exact signed archive.
## Mandatory clean-Mac test before publishing
Do not publish immediately after the script succeeds. Copy or download the final DMG onto a Mac/account that has not built Rsync Studio in Xcode, then:
1. Open the DMG.
2. Drag **Rsync Studio** into Applications.
3. Launch it from Applications.
4. Confirm Gatekeeper opens it normally with no unidentified-developer warning.
5. Create a harmless local dry-run job.
6. Verify SSH/local workflows required for the release.
7. Verify About reports the intended version/build.
8. Verify the app icon, Help menu, notifications and Launch at Login behaviour.
9. Eject/delete the DMG and repeat once from the actual hosted download before announcing the release.
## Release artifact policy
Never replace `Rsync Studio-1.0.0.dmg` with different bytes after it has been published. If the build changes, increment `CURRENT_PROJECT_VERSION` and create a new release artifact. Keep the published SHA-256 alongside the download.
## What Stage 2 intentionally does not do
It does not upload to a website/CDN, update the signed update manifest, publish release notes, create licences, take payments or activate customers. Those operations are deliberately separated so a successful local build cannot accidentally become a public release.
-97
View File
@@ -1,97 +0,0 @@
# Rsync Studio — Commercial Release Stage 3
Stage 3 adds the commercial licensing foundation and 30-day evaluation period. It deliberately does not add payment processing or a production licensing server yet.
## Runtime behaviour
- A new installation receives a 30-day trial on first launch.
- Trial start, last-seen time, installation UUID and installed licence token are stored in macOS Keychain.
- The installation UUID is random. Rsync Studio does not use the Mac serial number, MAC address or other hardware fingerprint.
- A backwards clock movement greater than five minutes causes the trial to pause with a clock-correction message rather than silently extending the trial.
- When the trial expires, background scheduling/file watching are stopped and the operational UI is replaced by the licence-required view.
- Jobs and history remain stored locally and are not deleted.
- A valid signed licence immediately re-enables the application.
Deleting the application's Keychain entries can reset local-only trial state. A production licensing service can close that gap by recording trial/activation state server-side. Stage 3 intentionally avoids invasive hardware fingerprinting just to make a local trial impossible to reset.
## Licence token design
Tokens use:
`RS3.<base64url JSON payload>.<base64url Ed25519 signature>`
The payload contains licence ID, customer display information, edition, installation UUID, issue time, optional expiry and optional maximum major version. Rsync Studio verifies the signature locally with an embedded public key using CryptoKit. The private signing key never belongs in the app, Git repository, release archive or DMG.
## Generate the signing keys
Run once on a trusted Mac:
`swift Scripts/license-keygen.swift`
The script stores the private/public keypair under `~/Library/Application Support/Rsync Studio Licensing/`. Protect and back up the private key. The private key should later be moved into production secret storage used by the licensing service.
Copy the printed public key into the Xcode build setting that generates:
`RsyncStudioLicensePublicKey`
The Stage 3 project contains an empty value by default so no secret/test key is accidentally shipped.
## Create a test offline licence
Copy the Installation ID shown in Settings → Licence, then run:
`swift Scripts/license-sign.swift --installation-id "INSTALLATION-UUID" --name "Test Customer" --email "customer@example.com" --edition "Professional" --max-major 1`
Paste the returned `RS3...` token into Settings → Licence → Install Signed Token.
## Online activation API contract
Set `RsyncStudioLicenseAPIBaseURL` to an HTTPS base URL, for example `https://licensing.example.com/`. Rsync Studio then uses:
### POST /v1/activate
Request:
```json
{
"licenseKey": "XXXX-XXXX-XXXX-XXXX",
"installationID": "random-uuid",
"appVersion": "1.0.0",
"platform": "macOS"
}
```
Success:
```json
{
"licenseToken": "RS3.payload.signature"
}
```
Errors should use a non-2xx response and preferably:
```json
{ "message": "Human-readable error" }
```
### POST /v1/deactivate
Request:
```json
{
"licenseToken": "RS3.payload.signature",
"installationID": "random-uuid"
}
```
A successful response may be `{}` or an empty 2xx body.
## Information sent during activation
Only the licence key, random installation UUID, application version and platform are sent for activation. Deactivation sends the signed licence token and installation UUID. Rsync Studio does not send sync paths, filenames, SSH hosts, SSH keys, credentials, job history, rsync logs or file contents.
## Production boundary
Stage 4 should implement the production licensing service and merchant-of-record/payment webhook integration, licence records, activation limits, signed-token issuance, revocation/deactivation and administrative tooling. The Ed25519 private key belongs there, not in Rsync Studio.
-125
View File
@@ -1,125 +0,0 @@
# Commercial Release Stage 4 — Licensing service and purchase provisioning
Stage 4 adds a production Laravel 13 licensing backend under `Licensing Server/`. The server owns customer licence records, activation limits, revocation and purchase webhook processing. Rsync Studio itself never receives payment card data.
## Security architecture
- Plaintext licence keys are shown once and only a SHA-256 hash plus last four characters are stored.
- The Ed25519 private signing seed stays outside source control and outside the web root.
- The server signs an installation-specific `RS3` token; the Mac verifies it locally with the embedded public key.
- Activation is idempotent for the same installation and locked inside a database transaction to enforce machine limits safely under concurrency.
- Deactivation verifies the signed token before freeing a seat.
- Purchase webhook events are signature-verified against their raw request bodies before JSON parsing and are persisted idempotently.
- Refund/reversal events revoke the corresponding licence.
- No synced paths, filenames, SSH hosts, credentials, logs or file contents are handled by the licensing service.
## Install into Laravel 13
Create a fresh private Laravel application on the server, then copy this overlay into it. Example:
`composer create-project laravel/laravel:^13.0 rsync-studio-licensing`
From the Rsync Studio source tree:
`./"Licensing Server"/install-overlay.sh /var/www/rsync-studio-licensing`
Add `App\\Providers\\RsyncStudioLicensingServiceProvider::class` to `bootstrap/providers.php`. Add `require __DIR__.'/api.stage4.php';` to `routes/api.php`, or rename `api.stage4.php` to your normal API routes file if it is a dedicated service.
Ensure PHP Sodium is enabled, then migrate:
`php artisan migrate`
## Signing key
Use the exact 32-byte Base64 private key created during Stage 3. Keep it outside the repository, for example `/etc/rsync-studio/license-private-key.txt`, readable only by the PHP-FPM account or a tightly controlled deployment group.
Environment:
`RSYNC_STUDIO_LICENSE_PRIVATE_KEY_PATH=/etc/rsync-studio/license-private-key.txt`
`RSYNC_STUDIO_LICENSE_PUBLIC_KEY=THE_STAGE3_PUBLIC_KEY`
Never put the private key into `.env` as a literal if your deployment tooling or diagnostics may expose environment values; the file path approach supports restrictive filesystem permissions and rotation procedures.
## Create products
Personal, one Mac:
`php artisan rsync-studio:product personal --name="Rsync Studio Personal" --edition=Personal --activations=1 --max-major=1`
Professional, three Macs:
`php artisan rsync-studio:product professional --name="Rsync Studio Professional" --edition=Professional --activations=3 --max-major=1`
Business, five Macs:
`php artisan rsync-studio:product business-5 --name="Rsync Studio Business 5" --edition=Business --activations=5 --max-major=1`
Manual support licence:
`php artisan rsync-studio:license:issue professional --name="Customer Name" --email="customer@example.com"`
The plaintext licence key is intentionally printed only at issue time.
## Mac API configuration
Set the signed application's generated Info.plist key `RsyncStudioLicenseAPIBaseURL` to your HTTPS endpoint, for example `https://licensing.example.com/api/`.
The Mac uses:
- `POST /api/v1/activate`
- `POST /api/v1/deactivate`
Stage 3 already implements these client calls.
## Paddle webhook
Set:
`PADDLE_WEBHOOK_SECRET=YOUR_NOTIFICATION_DESTINATION_SECRET`
Configure Paddle to send verified webhooks to:
`https://licensing.example.com/api/v1/webhooks/paddle`
Create the local product mapping with the Paddle price or product ID, for example:
`php artisan rsync-studio:product professional --name="Rsync Studio Professional" --edition=Professional --activations=3 --max-major=1 --provider=paddle --price-id=pri_xxxxx`
The verifier uses the raw body, `Paddle-Signature`, timestamp plus `:` plus raw body, HMAC-SHA256 and a constant-time comparison. The default replay tolerance in this implementation is 300 seconds and can be changed with `PADDLE_WEBHOOK_TOLERANCE`.
## Lemon Squeezy webhook
Set:
`LEMON_SQUEEZY_WEBHOOK_SECRET=YOUR_SIGNING_SECRET`
Webhook URL:
`https://licensing.example.com/api/v1/webhooks/lemon-squeezy`
Map the Lemon Squeezy variant ID as the external price ID:
`php artisan rsync-studio:product professional --name="Rsync Studio Professional" --edition=Professional --activations=3 --max-major=1 --provider=lemon-squeezy --price-id=YOUR_VARIANT_ID`
The raw request body is HMAC-SHA256 signed with the configured secret and compared with the `X-Signature` header before processing.
## Webhook delivery of the licence key
Purchase-created plaintext keys are placed into `license_delivery_outbox` encrypted with Laravel's application encryption key. They are not logged and are not stored in plaintext. Stage 5 will consume that outbox to send the transactional licence email/customer portal response and then mark or purge the delivery record. Manual `rsync-studio:license:issue` keys are printed once and are not queued. This prevents Stage 4 purchases from creating an unrecoverable key before the delivery layer exists.
## Deployment minimum
- HTTPS only.
- Production Laravel settings and `APP_DEBUG=false`.
- MySQL/PostgreSQL with backups.
- Redis-backed rate limiting/cache recommended.
- Webhook routes excluded from CSRF only when placed in the API route stack.
- Restrict server/private-key permissions.
- Do not expose database/admin ports to the public Internet.
- Alert on failed webhooks and repeated activation abuse.
- Back up licence/customer data and the signing seed separately.
## Stage boundary
Stage 4 creates and protects the commercial licence source of truth. Stage 5 should add customer delivery and self-service: checkout website, transactional licence email, customer portal/resend/deactivate-all flow, support/admin UI, and a clean purchase button inside Rsync Studio.
-47
View File
@@ -1,47 +0,0 @@
# Rsync Studio Commercial Release — Stage 5
Stage 5 adds the authenticated customer and administrator experience to the Stage 4 licensing API.
## Security model
- Mac activation remains licence-key based and rate limited.
- Customer portal uses Laravel sessions, CSRF protection, password hashing, session regeneration and email verification.
- Portal licence ownership is matched only to the authenticated **verified** email address.
- Admin access requires an explicit `is_admin` database flag.
- Login and registration endpoints are throttled by the licensing service provider.
- Password reset uses Laravel's signed/reset-token flow.
- Full purchase-created licence keys remain encrypted at rest in `license_delivery_outbox`; they are never shown in the portal and may only be mailed to the verified owner.
- Manual licence keys remain non-recoverable by design.
## Install / update
Run the overlay from the Rsync Studio repository root:
`./"Licensing Server"/install-overlay.sh /path/to/rsync-studio-licensing`
Then run:
`php artisan migrate && php artisan optimize:clear`
Create the first administrator:
`php artisan rsync-studio:admin admin@example.com --name="Rsync Studio Admin"`
The temporary password is printed once for a newly created admin. Sign in and change/reset it before production use.
## Required production environment
Set HTTPS `APP_URL`, production mail settings, database/session/cache/queue settings, the Stage 4 signing keys and payment webhook secrets. Add:
- `RSYNC_STUDIO_PURCHASE_URL`
- `RSYNC_STUDIO_PORTAL_URL`
- `RSYNC_STUDIO_SUPPORT_URL`
The macOS app can embed those HTTPS addresses in its generated Info.plist.
## Customer flow
1. Checkout provider posts a verified webhook.
2. Stage 4 provisions the licence and encrypts its one-time key in the delivery outbox.
3. Delivery mail sends the key to the purchase email.
4. Customer creates a portal account with that same email and verifies it.
5. Portal exposes licence status and active installations, allows seat deactivation and secure key resend when the encrypted delivery record exists.
## Admin flow
The admin dashboard is behind `auth`, `verified`, and `AdminOnly` middleware. It supports search, licence inspection, activations, revoke and restore.
+51
View File
@@ -0,0 +1,51 @@
# Paddle sandbox checkout
Rsync Studio's licensing site now hosts a Paddle sandbox checkout at:
- `https://rsync.stage.absdev.net/buy`
The macOS app opens this page from the Licence and General settings screens.
## Stage environment
The stage `.env` must contain:
```dotenv
PADDLE_CLIENT_SIDE_TOKEN=test_...
PADDLE_ENVIRONMENT=sandbox
RSYNC_STUDIO_PURCHASE_URL=https://rsync.stage.absdev.net/buy
RSYNC_STUDIO_PORTAL_URL=https://rsync.stage.absdev.net/portal
RSYNC_STUDIO_SUPPORT_URL=https://rsync.stage.absdev.net/support
```
Keep `PADDLE_API_KEY` server-side. It must never be added to the macOS app or browser JavaScript.
The four Paddle catalog IDs remain configured through `PADDLE_PERSONAL_*` and `PADDLE_PROFESSIONAL_*` variables.
## Checkout behavior
- Paddle.js is initialized with the sandbox client-side token.
- Prices are localized with `Paddle.PricePreview()` when available, with the agreed GBP prices as a fallback.
- Each plan opens Paddle's overlay checkout using the configured price ID.
- All plans advertise the configured 7-day Paddle trial.
- A successful Paddle subscription webhook provisions the licence and creates the encrypted delivery outbox record.
- After webhook processing, pending licence keys are sent by email on a best-effort basis without rolling back the verified webhook if mail delivery is temporarily unavailable.
## macOS stage configuration
The app is configured for:
- API: `https://rsync.stage.absdev.net/api/`
- Buy: `https://rsync.stage.absdev.net/buy`
- Portal: `https://rsync.stage.absdev.net/portal`
- Support: `https://rsync.stage.absdev.net/support`
- The current stage Ed25519 licence public key is embedded in `Info.plist`.
The signing private seed remains server-only.
## Remote portal deactivation
Rsync Studio revalidates an installed licence against `POST /api/v1/validate` on launch/resume and every 24 hours while running. The validation endpoint reads only the local licensing database; it does **not** call Paddle and therefore does not add Paddle API usage or transaction charges.
A successful validation refreshes the signed licence token so subscription entitlement changes are picked up. If an installation was deactivated in the customer portal, the server returns HTTP 403 and the app removes its local token immediately. Temporary network/server failures retain the locally signed licence for up to three days; after that grace period the app requires an online validation.
-119
View File
@@ -1,119 +0,0 @@
# Rsync Studio — Direct Distribution Release
Rsync Studio is designed for direct macOS distribution rather than the Mac App Store because it intentionally launches `rsync`/`ssh` and accesses user-selected arbitrary paths.
## Release security baseline
- Developer ID Application signing.
- Hardened Runtime enabled.
- App Sandbox intentionally disabled for this product architecture.
- No Hardened Runtime exception entitlements.
- SSH host-key verification is never disabled.
- SSH uses BatchMode, connection timeout and keepalive limits.
- Password storage/authentication is not supported; use a private key or the macOS SSH agent.
- Advanced rsync arguments that can replace the SSH transport, run remote commands, load passwords, read/write arbitrary option/list/log/batch files or bypass controlled delete handling are rejected.
- Scheduled/file-watch `--delete` requires explicit per-job authorisation.
- Run logs are sanitised and capped before storage and expire according to Settings.
- `PrivacyInfo.xcprivacy` declares no tracking/no collected data and the UserDefaults required-reason API.
## Mandatory preflight
Before every release archive, run on the release Mac:
```bash
./Scripts/release-preflight.sh
```
This performs the static security policy checks, validates the privacy manifest, and performs a clean unsigned **Release** build with the installed macOS SDK so compiler/typecheck warnings or SDK incompatibilities are caught before signing.
## Archive
In Xcode select **Any Mac (Apple Silicon, Intel)**, choose **Product > Archive**, then distribute using **Developer ID**.
Alternatively:
```bash
xcodebuild -project "Rsync Studio.xcodeproj" -scheme "Rsync Studio" -configuration Release -archivePath "build/Rsync Studio.xcarchive" archive
```
Choose your Developer ID Application certificate/team in Xcode signing settings before archiving.
## Verify signature
```bash
codesign --verify --deep --strict --verbose=2 "/path/to/Rsync Studio.app"
codesign -d --entitlements :- "/path/to/Rsync Studio.app"
```
The Release build should show Hardened Runtime and should not contain `com.apple.security.get-task-allow`.
## Notarise
Store notarisation credentials in a Keychain profile once:
```bash
xcrun notarytool store-credentials "Rsync Studio Notary"
```
Submit a signed ZIP/DMG/PKG:
```bash
ditto -c -k --keepParent "/path/to/Rsync Studio.app" "Rsync Studio.zip"
xcrun notarytool submit "Rsync Studio.zip" --keychain-profile "Rsync Studio Notary" --wait
```
Staple and validate:
```bash
xcrun stapler staple "/path/to/Rsync Studio.app"
xcrun stapler validate "/path/to/Rsync Studio.app"
spctl --assess --type execute --verbose=4 "/path/to/Rsync Studio.app"
```
## Pre-release functional security tests
1. Local source nested inside destination is rejected.
2. Local destination nested inside source is rejected.
3. SSH host beginning with `-` is rejected.
4. SSH username containing spaces/control characters is rejected.
5. A `.pub` key cannot be selected as the effective private key.
6. Advanced arguments `-e`, `--rsh`, `--rsync-path`, `--password-file`, `--files-from`, `--log-file`, `--write-batch`, `--remove-source-files`, `--delete*` and `-M` are rejected, and malformed quoting is rejected.
7. Remote destination shell metacharacters are rejected before rsync is launched.
8. Scheduled/watch job using `--delete` cannot run until **Allow unattended delete** is explicitly enabled.
9. SSH to a previously unknown host does not silently bypass host-key checking.
10. Scheduled SSH job with unavailable authentication fails rather than prompting indefinitely.
11. History expiry removes old completed runs according to Settings.
12. A failed persistent SwiftData store opens Recovery Mode instead of immediately crashing.
13. `Scripts/security-check.sh` passes before every signed build.
## Signed update feed
The application contains a dependency-free signed update checker. It remains disabled until these generated Info.plist values are populated in the Release configuration:
- `RsyncStudioUpdateManifestURL`: HTTPS URL to the JSON manifest.
- `RsyncStudioUpdatePublicKey`: Base64 raw Ed25519/Curve25519.Signing public key.
Manifest schema:
```json
{
"version": "1.2.0",
"build": 120,
"downloadURL": "https://example.com/releases/Rsync Studio-1.2.0.dmg",
"signature": "BASE64_ED25519_SIGNATURE"
}
```
The exact signed bytes are UTF-8: `version + "\n" + build + "\n" + downloadURL`. The checker refuses HTTP feeds, unsigned manifests, invalid signatures, and non-HTTPS download URLs. It opens the verified signed download location; it does not silently install software. Keep the private signing key outside the source repository and build machine where practical.
## Automated commercial release pipeline
Stage 2 adds `Scripts/release.sh`, which automates archive/export, signed-app verification, drag-to-Applications DMG creation, Apple notarization, stapling, Gatekeeper verification, SHA-256 generation and release metadata. It intentionally does **not** publish artifacts.
See `Docs/COMMERCIAL_RELEASE_STAGE2.md` for first-time setup and usage.
## Stage 4 licensing backend
See `Docs/COMMERCIAL_RELEASE_STAGE4.md` and the `Licensing Server/` Laravel overlay for production activation, purchase provisioning and webhook handling.
Regular → Executable
+2 -2
View File
@@ -45,7 +45,7 @@ Background automation remains active while the application is running, including
This project targets Developer ID signed/notarised direct distribution, not the Mac App Store. Current hardening includes Hardened Runtime, validated rsync executables, protected advanced rsync arguments, non-interactive SSH with timeouts/keepalives, normal SSH host-key verification, private-key/agent authentication only, unattended `--delete` authorisation, local source/destination overlap protection, sanitised/capped run logs, configurable history retention, a privacy manifest, and persistent-store recovery mode.
See `Docs/RELEASE.md` and run `Scripts/security-check.sh` before producing a release archive.
See `Docs/RELEASE.md` and run `Scripts/old/security-check.sh` before producing a release archive.
## Professional reliability features
@@ -54,4 +54,4 @@ The production build includes job preflight health checks, destination-volume id
## Commercial direct-distribution build
On the release Mac, `Scripts/release.sh` creates the Developer ID signed, notarized and stapled DMG plus its SHA-256 checksum. The script never publishes automatically. See `Docs/COMMERCIAL_RELEASE_STAGE2.md`.
On the release Mac, `Scripts/old/release.sh` creates the Developer ID signed, notarized and stapled DMG plus its SHA-256 checksum. The script never publishes automatically. See `Docs/COMMERCIAL_RELEASE_STAGE2.md`.
View File
View File
View File
View File
View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

Before

Width:  |  Height:  |  Size: 596 B

After

Width:  |  Height:  |  Size: 596 B

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

View File

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 157 KiB

View File
Regular → Executable
+8 -2
View File
@@ -6,6 +6,7 @@ struct ContentView: View {
@EnvironmentObject private var automation: BackgroundSyncCoordinator
@EnvironmentObject private var licensing: LicensingManager
@Environment(\.openWindow) private var openWindow
@Environment(\.scenePhase) private var scenePhase
@State private var selectedJob: SyncJob?
@State private var section: AppSection = .jobs
@State private var showingNewJob = false
@@ -43,12 +44,17 @@ struct ContentView: View {
withAnimation(.easeOut(duration: 0.28)) { showingSplash = false }
}
.task {
await licensing.revalidateIfNeeded()
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(900))
try? await Task.sleep(for: .seconds(LicensingManager.remoteValidationInterval))
if Task.isCancelled { return }
licensing.refresh()
await licensing.revalidateIfNeeded()
}
}
.onChange(of: scenePhase) { _, phase in
guard phase == .active else { return }
Task { await licensing.revalidateIfNeeded(force: true) }
}
}
private var mainContent: some View {
Regular → Executable
+8 -2
View File
@@ -22,8 +22,14 @@
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>RsyncStudioLicenseAPIBaseURL</key>
<string>https://rsync.vm/api/</string>
<string>https://rsync.stage.absdev.net/api/</string>
<key>RsyncStudioLicensePublicKey</key>
<string>8/rlCrS5mVEi4rGNXkw0Z22XkRpSH5+jPnD4f0GyV2k=</string>
<string>/QWCMp3he0bUM0VIYYmH9y1/BCncpUo4h/J8sM8gPKg=</string>
<key>RsyncStudioPurchaseURL</key>
<string>https://rsync.stage.absdev.net/buy</string>
<key>RsyncStudioPortalURL</key>
<string>https://rsync.stage.absdev.net/portal</string>
<key>RsyncStudioSupportURL</key>
<string>https://rsync.stage.absdev.net/support</string>
</dict>
</plist>
View File
View File
View File
View File
+1 -1
View File
@@ -548,7 +548,7 @@ The bundled privacy manifest declares no tracking and no collected data.
## Release verification
The project contains `Docs/RELEASE.md` and `Scripts/security-check.sh` with Developer ID, notarisation, signature and Gatekeeper verification steps for production builds.
The project contains `Docs/RELEASE.md` and `../../Scripts/old/security-check.sh` with Developer ID, notarisation, signature and Gatekeeper verification steps for production builds.
# Production Reliability
View File
View File
View File
+159 -3
View File
@@ -52,19 +52,34 @@ final class LicensingManager: ObservableObject {
let installationID: String
}
private struct ValidationRequest: Encodable {
let licenseToken: String
let installationID: String
let appVersion: String
let platform: String
}
private struct ValidationResponse: Decodable {
let active: Bool
let licenseToken: String
}
enum LicensingError: LocalizedError {
case configuration(String)
case invalidLicense(String)
case server(String)
case remoteInactive(String)
var errorDescription: String? {
switch self {
case .configuration(let message), .invalidLicense(let message), .server(let message): message
case .configuration(let message), .invalidLicense(let message), .server(let message), .remoteInactive(let message): message
}
}
}
static let trialLengthDays = 30
static let trialLengthDays = 7
static let remoteValidationInterval: TimeInterval = 24 * 60 * 60
static let offlineGracePeriod: TimeInterval = 3 * 24 * 60 * 60
@Published private(set) var state: State = .trial(daysRemaining: trialLengthDays, expiresAt: Date().addingTimeInterval(TimeInterval(trialLengthDays * 86_400)))
@Published private(set) var installationID = ""
@@ -76,6 +91,9 @@ final class LicensingManager: ObservableObject {
private let lastSeenAccount = "licensing.trial.lastSeen"
private let installationAccount = "licensing.installationID"
private let tokenAccount = "licensing.token"
private let lastRemoteValidationAccount = "licensing.remoteValidation.lastSuccess"
private let remoteBlockMessageAccount = "licensing.remoteValidation.blockMessage"
private var revalidationInFlight = false
init() {
refresh()
@@ -100,7 +118,7 @@ final class LicensingManager: ObservableObject {
case .trial(let days, let expiresAt):
return "\(days) day\(days == 1 ? "" : "s") remaining · ends \(expiresAt.formatted(date: .abbreviated, time: .omitted))"
case .expired:
return "The 30-day evaluation period has ended. Activate a licence to continue using sync jobs."
return "The 7-day evaluation period has ended. Activate a licence to continue using sync jobs."
case .invalid(let message):
return message
}
@@ -122,9 +140,81 @@ final class LicensingManager: ObservableObject {
}
}
let blockedMessage = KeychainStore.string(for: remoteBlockMessageAccount)
if !blockedMessage.isEmpty {
state = .invalid(blockedMessage)
return
}
state = trialState(now: now)
}
/// Revalidates only against the Rsync Studio licensing server. This endpoint reads
/// our own database and never calls Paddle, so routine validation has no Paddle API cost.
func revalidateIfNeeded(force: Bool = false, now: Date = Date()) async {
guard !revalidationInFlight else { return }
installationID = loadOrCreateInstallationID()
guard let token = storedLicenseToken, !token.isEmpty else {
refresh(now: now)
return
}
let localPayload: LicensePayload
do {
localPayload = try verify(token: token, now: now)
} catch {
state = .invalid(error.localizedDescription)
return
}
if !force,
let lastSuccess = Self.parseDate(KeychainStore.string(for: lastRemoteValidationAccount)),
now.timeIntervalSince(lastSuccess) < Self.remoteValidationInterval {
state = .licensed(localPayload)
return
}
guard let baseURL = configuredAPIBaseURL else {
applyOfflineGrace(localPayload: localPayload, now: now)
return
}
// Existing installations created before remote validation was added get a
// three-day migration grace period if the first network check cannot complete.
if Self.parseDate(KeychainStore.string(for: lastRemoteValidationAccount)) == nil {
_ = KeychainStore.set(Self.formatDate(now), for: lastRemoteValidationAccount)
}
revalidationInFlight = true
defer { revalidationInFlight = false }
do {
let response = try await validateRemotely(token: token, baseURL: baseURL)
guard response.active else {
throw LicensingError.remoteInactive("This Mac has been deactivated from the licence portal.")
}
let refreshedPayload = try verify(token: response.licenseToken, now: now)
guard KeychainStore.set(response.licenseToken, for: tokenAccount) == errSecSuccess else {
throw LicensingError.invalidLicense("Rsync Studio could not update the licence securely in Keychain.")
}
_ = KeychainStore.set(Self.formatDate(now), for: lastRemoteValidationAccount)
KeychainStore.delete(remoteBlockMessageAccount)
state = .licensed(refreshedPayload)
recordLastSeen(now)
} catch LicensingError.remoteInactive(let message) {
KeychainStore.delete(tokenAccount)
KeychainStore.delete(lastRemoteValidationAccount)
_ = KeychainStore.set(message, for: remoteBlockMessageAccount)
state = .invalid(message)
lastMessage = message
} catch {
applyOfflineGrace(localPayload: localPayload, now: now)
}
}
func installSignedToken(_ token: String) throws {
let clean = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !clean.isEmpty else { throw LicensingError.invalidLicense("Paste a signed Rsync Studio licence token.") }
@@ -132,6 +222,7 @@ final class LicensingManager: ObservableObject {
guard KeychainStore.set(clean, for: tokenAccount) == errSecSuccess else {
throw LicensingError.invalidLicense("Rsync Studio could not save the licence securely in Keychain.")
}
KeychainStore.delete(remoteBlockMessageAccount)
state = .licensed(payload)
lastMessage = "Licence installed successfully."
}
@@ -154,6 +245,8 @@ final class LicensingManager: ObservableObject {
)
let response: ActivationResponse = try await post(path: "v1/activate", body: requestBody, baseURL: baseURL)
try installSignedToken(response.licenseToken)
_ = KeychainStore.set(Self.formatDate(Date()), for: lastRemoteValidationAccount)
KeychainStore.delete(remoteBlockMessageAccount)
lastMessage = "Rsync Studio has been activated on this Mac."
}
@@ -175,12 +268,16 @@ final class LicensingManager: ObservableObject {
guard KeychainStore.delete(tokenAccount) == errSecSuccess else {
throw LicensingError.invalidLicense("Rsync Studio could not remove the local licence from Keychain.")
}
KeychainStore.delete(lastRemoteValidationAccount)
KeychainStore.delete(remoteBlockMessageAccount)
refresh()
lastMessage = "Licence removed from this Mac."
}
func removeInvalidLocalLicence() {
KeychainStore.delete(tokenAccount)
KeychainStore.delete(lastRemoteValidationAccount)
KeychainStore.delete(remoteBlockMessageAccount)
refresh()
lastMessage = "Invalid local licence removed."
}
@@ -286,6 +383,65 @@ final class LicensingManager: ObservableObject {
return payload
}
private func validateRemotely(token: String, baseURL: URL) async throws -> ValidationResponse {
let url = baseURL.appending(path: "v1/validate")
guard url.scheme?.lowercased() == "https" else {
throw LicensingError.configuration("Licence API must use HTTPS.")
}
let body = ValidationRequest(
licenseToken: token,
installationID: installationID,
appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown",
platform: "macOS"
)
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try JSONEncoder().encode(body)
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw LicensingError.server("The licence server returned an invalid response.")
}
if http.statusCode == 403 {
let message = (try? JSONDecoder().decode(ServerError.self, from: data).message)
?? "This licence is no longer active on this Mac."
throw LicensingError.remoteInactive(message)
}
guard (200..<300).contains(http.statusCode) else {
let message = (try? JSONDecoder().decode(ServerError.self, from: data).message)
?? "Licence server returned HTTP \(http.statusCode)."
throw LicensingError.server(message)
}
do {
return try JSONDecoder().decode(ValidationResponse.self, from: data)
} catch {
throw LicensingError.server("The licence server returned malformed validation data.")
}
} catch let error as LicensingError {
throw error
} catch {
throw LicensingError.server(error.localizedDescription)
}
}
private func applyOfflineGrace(localPayload: LicensePayload, now: Date) {
let lastSuccess = Self.parseDate(KeychainStore.string(for: lastRemoteValidationAccount))
if let lastSuccess, now.timeIntervalSince(lastSuccess) <= Self.offlineGracePeriod {
state = .licensed(localPayload)
return
}
state = .invalid("Rsync Studio has been unable to verify this licence for more than 3 days. Connect to the internet and reopen the app.")
}
private func post<Request: Encodable, Response: Decodable>(path: String, body: Request, baseURL: URL) async throws -> Response {
let url = baseURL.appending(path: path)
guard url.scheme?.lowercased() == "https" else { throw LicensingError.configuration("Licence API must use HTTPS.") }
View File
View File
View File
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
+15 -3
View File
@@ -1,4 +1,5 @@
import SwiftUI
import AppKit
struct LicenseRequiredView: View {
@EnvironmentObject private var licensing: LicensingManager
@@ -19,9 +20,13 @@ struct LicenseRequiredView: View {
.frame(maxWidth: 520)
}
Button("Open Licence Settings") { openSettings() }
.buttonStyle(.borderedProminent)
.controlSize(.large)
HStack(spacing: 12) {
Button("Buy Rsync Studio…") { openPurchasePage() }
.buttonStyle(.borderedProminent)
.controlSize(.large)
Button("Open Licence Settings") { openSettings() }
.controlSize(.large)
}
Text("Your jobs and history remain on this Mac. Rsync Studio does not delete local data when a trial expires or a licence is removed.")
.font(.caption)
@@ -33,4 +38,11 @@ struct LicenseRequiredView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor))
}
private func openPurchasePage() {
guard let raw = Bundle.main.object(forInfoDictionaryKey: "RsyncStudioPurchaseURL") as? String,
let url = URL(string: raw),
url.scheme?.lowercased() == "https" else { return }
NSWorkspace.shared.open(url)
}
}
+13 -1
View File
@@ -1,4 +1,5 @@
import SwiftUI
import AppKit
struct LicenseView: View {
@EnvironmentObject private var licensing: LicensingManager
@@ -60,7 +61,8 @@ struct LicenseView: View {
showingTokenEntry.toggle()
}
}
Text("Online activation will be enabled when the licensing service URL is configured. Signed offline licence tokens can be installed without contacting a server.")
Button("Buy Rsync Studio…") { openPurchasePage() }
Text("Buy Personal or Professional online, then enter the licence key delivered to your email. Signed offline licence tokens can also be installed without contacting the activation server.")
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -130,4 +132,14 @@ struct LicenseView: View {
showingTokenEntry = false
} catch { errorMessage = error.localizedDescription }
}
private func openPurchasePage() {
guard let raw = Bundle.main.object(forInfoDictionaryKey: "RsyncStudioPurchaseURL") as? String,
let url = URL(string: raw),
url.scheme?.lowercased() == "https" else {
errorMessage = "The Rsync Studio purchase page is not configured in this build."
return
}
NSWorkspace.shared.open(url)
}
}
View File
View File
View File
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
set -euo pipefail
# Resolve script location under Bash or Zsh.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_ROOT"
# Find the Xcode project.
PROJECT="$(find "$PROJECT_ROOT" -maxdepth 2 -name '*.xcodeproj' -type d -print -quit)"
if [[ -z "$PROJECT" ]]; then
echo "ERROR: No Xcode project found in $PROJECT_ROOT"
exit 1
fi
echo "Project: $PROJECT"
# Detect the application scheme.
SCHEME="$(xcodebuild -list -json -project "$PROJECT" | python3 -c '
import json
import sys
data = json.load(sys.stdin)
schemes = data.get("project", {}).get("schemes", [])
if not schemes:
sys.exit("ERROR: No Xcode schemes found")
print(schemes[0])
')"
if [[ -z "$SCHEME" ]]; then
echo "ERROR: Could not detect Xcode scheme"
exit 1
fi
echo "Scheme: $SCHEME"
DERIVED_DATA="$PROJECT_ROOT/.build"
echo "Building Rsync Studio..."
xcodebuild \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Debug \
-derivedDataPath "$DERIVED_DATA" \
CODE_SIGNING_ALLOWED=NO \
build
APP="$(find "$DERIVED_DATA/Build/Products/Debug" -maxdepth 1 -name '*.app' -type d -print -quit)"
if [[ -z "$APP" ]]; then
echo "ERROR: Built application not found"
exit 1
fi
echo "Build successful: $APP"
if [[ "${LAUNCH_AFTER_BUILD:-1}" == "1" ]]; then
echo "Launching application..."
open "$APP"
fi
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env swift
import Foundation
import CryptoKit
let fm = FileManager.default
let home = fm.homeDirectoryForCurrentUser
let directory = home.appendingPathComponent("Library/Application Support/Rsync Studio Licensing", isDirectory: true)
let privateURL = directory.appendingPathComponent("license-private-key.txt")
let publicURL = directory.appendingPathComponent("license-public-key.txt")
try fm.createDirectory(at: directory, withIntermediateDirectories: true)
if fm.fileExists(atPath: privateURL.path) || fm.fileExists(atPath: publicURL.path) {
fputs("Refusing to overwrite an existing licensing keypair at \(directory.path)\n", stderr)
exit(1)
}
let privateKey = Curve25519.Signing.PrivateKey()
let privateText = privateKey.rawRepresentation.base64EncodedString()
let publicText = privateKey.publicKey.rawRepresentation.base64EncodedString()
try privateText.write(to: privateURL, atomically: true, encoding: .utf8)
try publicText.write(to: publicURL, atomically: true, encoding: .utf8)
try fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: privateURL.path)
try fm.setAttributes([.posixPermissions: 0o644], ofItemAtPath: publicURL.path)
print("Created Rsync Studio Ed25519 licensing keypair.")
print("PRIVATE (KEEP SECRET): \(privateURL.path)")
print("PUBLIC: \(publicURL.path)")
print("\nPublic key for RsyncStudioLicensePublicKey:\n\(publicText)")
-71
View File
@@ -1,71 +0,0 @@
#!/usr/bin/env swift
import Foundation
import CryptoKit
struct Payload: Codable {
let licenseID: String
let customerName: String?
let customerEmail: String?
let edition: String
let installationID: String
let issuedAt: Date
let expiresAt: Date?
let maxMajorVersion: Int?
}
func base64URL(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
let args = CommandLine.arguments
func value(_ flag: String) -> String? {
guard let index = args.firstIndex(of: flag), index + 1 < args.count else { return nil }
return args[index + 1]
}
guard let installationID = value("--installation-id"), UUID(uuidString: installationID) != nil else {
fputs("Usage: swift Scripts/license-sign.swift --installation-id UUID [--name NAME] [--email EMAIL] [--edition Professional] [--license-id ID] [--max-major 1] [--expires YYYY-MM-DD]\n", stderr)
exit(2)
}
let fm = FileManager.default
let defaultPrivate = fm.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support/Rsync Studio Licensing/license-private-key.txt")
let privateURL = value("--private-key").map { URL(fileURLWithPath: $0) } ?? defaultPrivate
let privateText = try String(contentsOf: privateURL, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines)
guard let privateData = Data(base64Encoded: privateText) else { throw NSError(domain: "RsyncStudioLicensing", code: 1, userInfo: [NSLocalizedDescriptionKey: "Private key is not Base64."]) }
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateData)
var expiry: Date?
if let text = value("--expires") {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
formatter.timeZone = TimeZone(secondsFromGMT: 0)
guard let date = formatter.date(from: text) else {
fputs("Invalid --expires date; use YYYY-MM-DD.\n", stderr)
exit(2)
}
expiry = date
}
let payload = Payload(
licenseID: value("--license-id") ?? UUID().uuidString.lowercased(),
customerName: value("--name"),
customerEmail: value("--email"),
edition: value("--edition") ?? "Professional",
installationID: installationID.lowercased(),
issuedAt: Date(),
expiresAt: expiry,
maxMajorVersion: value("--max-major").flatMap(Int.init) ?? 1
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(payload)
let signature = try privateKey.signature(for: data)
let token = "RS3.\(base64URL(data)).\(base64URL(signature))"
print(token)
-41
View File
@@ -1,41 +0,0 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
PROJECT="$ROOT/Rsync Studio.xcodeproj"
SCHEME="Rsync Studio"
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "release-preflight.sh must run on macOS with Xcode installed." >&2
exit 2
fi
for tool in xcodebuild plutil codesign spctl; do
command -v "$tool" >/dev/null || { echo "Missing required tool: $tool" >&2; exit 2; }
done
"$SCRIPT_DIR/verify-release-config.sh"
"$SCRIPT_DIR/security-check.sh"
if ! grep -Eq 'INFOPLIST_KEY_RsyncStudioLicensePublicKey = "[A-Za-z0-9+/_=-]{40,}";' "$PROJECT/project.pbxproj"; then
echo "RsyncStudioLicensePublicKey is not configured. Generate the licensing keypair and embed the PUBLIC key before a commercial release." >&2
exit 1
fi
plutil -lint "$ROOT/Rsync Studio/Resources/PrivacyInfo.xcprivacy"
BUILD_DIR="$ROOT/build/preflight"
rm -rf "$BUILD_DIR"
xcodebuild \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Release \
-destination 'platform=macOS' \
-derivedDataPath "$BUILD_DIR" \
CODE_SIGNING_ALLOWED=NO \
clean build
APP="$BUILD_DIR/Build/Products/Release/Rsync Studio.app"
test -d "$APP" || { echo "Release app was not produced at $APP" >&2; exit 1; }
echo "Unsigned Release build passed: $APP"
echo "Next: Archive with Developer ID, notarise, staple, then run the signed-app checks in Docs/RELEASE.md."
+140 -186
View File
@@ -1,206 +1,160 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
PROJECT="$ROOT/Rsync Studio.xcodeproj"
# Rsync Studio direct distribution: Developer ID signed, notarized app and DMG.
# macOS/Xcode required. Install as Scripts/release.sh.
APP_NAME="Rsync Studio"
SCHEME="Rsync Studio"
CONFIGURATION="Release"
PRODUCT_NAME="Rsync Studio"
DISPLAY_NAME="Rsync Studio"
BUNDLE_ID="net.absdev.RsyncStudio"
NOTARY_PROFILE="${NOTARY_PROFILE:-Rsync Studio Notary}"
RELEASE_ROOT="${RELEASE_ROOT:-$ROOT/build/releases}"
SKIP_PREFLIGHT=0
SKIP_NOTARIZATION=0
PROJECT="Rsync Studio.xcodeproj"
TEAM_ID="${TEAM_ID:-39R6U3437D}"
SIGNING_IDENTITY="${SIGNING_IDENTITY:-B99D353C88996866C49E4389B375645F76870A3F}"
NOTARY_PROFILE="${NOTARY_PROFILE:-orion-notary}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -d "$SCRIPT_DIR/$PROJECT" ]]; then ROOT_DIR="$SCRIPT_DIR"
elif [[ -d "$SCRIPT_DIR/../$PROJECT" ]]; then ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
else echo "ERROR: Cannot locate $PROJECT" >&2; exit 1; fi
BUILD_DIR="$ROOT_DIR/build/direct-distribution"
ARCHIVE_PATH="$BUILD_DIR/$APP_NAME.xcarchive"
EXPORT_DIR="$BUILD_DIR/export"
EXPORT_OPTIONS="$BUILD_DIR/ExportOptions.plist"
APP_PATH="$EXPORT_DIR/$APP_NAME.app"
APP_ZIP="$BUILD_DIR/$APP_NAME-notarization.zip"
DMG_STAGE="$BUILD_DIR/dmg-stage"
RW_DMG="$BUILD_DIR/$APP_NAME-rw.dmg"
TMP_DMG="$BUILD_DIR/$APP_NAME.dmg"
RELEASE_DIR="$ROOT_DIR/releases"
DMG_PATH="$RELEASE_DIR/$APP_NAME.dmg"
MOUNT_POINT="$BUILD_DIR/dmg-mount"
usage() {
cat <<USAGE
Usage: $(basename "$0") [options]
Builds a Developer ID archive, exports the signed app, creates a drag-to-Applications
DMG, notarizes/staples it, verifies Gatekeeper, and writes SHA-256 checksums.
Nothing is uploaded to your website or update feed.
Options:
--notary-profile NAME Keychain profile created by notarytool (default: $NOTARY_PROFILE)
--output DIR Release output root (default: build/releases)
--skip-preflight Skip Scripts/release-preflight.sh
--skip-notarization Build/sign/package only; do not submit to Apple
-h, --help Show this help
Environment:
DEVELOPMENT_TEAM Optional Apple Developer Team ID. Recommended for CI.
NOTARY_PROFILE Alternative to --notary-profile.
RELEASE_ROOT Alternative to --output.
First-time notarization setup:
xcrun notarytool store-credentials "$NOTARY_PROFILE"
USAGE
for tool in xcodebuild xcrun codesign spctl ditto hdiutil shasum security swift sips /usr/libexec/PlistBuddy; do
command -v "$tool" >/dev/null || { echo "ERROR: Missing $tool" >&2; exit 1; }
done
if ! security find-identity -v -p codesigning | grep -F "$SIGNING_IDENTITY" >/dev/null; then
echo "ERROR: Developer ID identity not found: $SIGNING_IDENTITY" >&2; exit 1
fi
xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null || {
echo "ERROR: Notarytool profile '$NOTARY_PROFILE' unavailable" >&2; exit 1;
}
while [[ $# -gt 0 ]]; do
case "$1" in
--notary-profile) [[ $# -ge 2 ]] || { echo "Missing value for $1" >&2; exit 2; }; NOTARY_PROFILE="$2"; shift 2 ;;
--output) [[ $# -ge 2 ]] || { echo "Missing value for $1" >&2; exit 2; }; RELEASE_ROOT="$2"; shift 2 ;;
--skip-preflight) SKIP_PREFLIGHT=1; shift ;;
--skip-notarization) SKIP_NOTARIZATION=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done
[[ "$(uname -s)" == "Darwin" ]] || { echo "ERROR: release.sh must run on macOS." >&2; exit 2; }
for tool in xcodebuild xcrun hdiutil codesign spctl plutil shasum ditto; do
command -v "$tool" >/dev/null || { echo "ERROR: Missing required tool: $tool" >&2; exit 2; }
done
PBX="$PROJECT/project.pbxproj"
VERSION="$(awk -F' = ' '/MARKETING_VERSION = / {gsub(/;/,"",$2); print $2; exit}' "$PBX")"
BUILD="$(awk -F' = ' '/CURRENT_PROJECT_VERSION = / {gsub(/;/,"",$2); print $2; exit}' "$PBX")"
[[ -n "$VERSION" && -n "$BUILD" ]] || { echo "ERROR: Could not determine version/build from Xcode project." >&2; exit 1; }
RELEASE_DIR="$RELEASE_ROOT/$VERSION-$BUILD"
ARCHIVE="$RELEASE_DIR/$PRODUCT_NAME.xcarchive"
EXPORT_DIR="$RELEASE_DIR/export"
DMG_STAGING="$RELEASE_DIR/dmg-root"
DMG="$RELEASE_DIR/$PRODUCT_NAME-$VERSION.dmg"
CHECKSUM="$DMG.sha256"
METADATA="$RELEASE_DIR/release-info.txt"
EXPORT_OPTIONS="$RELEASE_DIR/ExportOptions.plist"
if [[ -e "$RELEASE_DIR" ]]; then
echo "ERROR: Release directory already exists: $RELEASE_DIR" >&2
echo "Refusing to overwrite an existing version/build artifact." >&2
exit 1
fi
mkdir -p "$RELEASE_DIR"
cleanup() {
rm -rf "$DMG_STAGING"
}
trap cleanup EXIT
if [[ "$SKIP_PREFLIGHT" -eq 0 ]]; then
"$SCRIPT_DIR/release-preflight.sh"
fi
cd "$ROOT_DIR"
rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR" "$EXPORT_DIR" "$DMG_STAGE" "$RELEASE_DIR" "$MOUNT_POINT"
cat > "$EXPORT_OPTIONS" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>developer-id</string>
<key>signingStyle</key>
<string>automatic</string>
<key>stripSwiftSymbols</key>
<true/>
</dict>
</plist>
<plist version="1.0"><dict>
<key>method</key><string>developer-id</string>
<key>signingStyle</key><string>automatic</string>
<key>teamID</key><string>$TEAM_ID</string>
</dict></plist>
PLIST
plutil -lint "$EXPORT_OPTIONS" >/dev/null
XCODE_SIGN_ARGS=()
if [[ -n "${DEVELOPMENT_TEAM:-}" ]]; then
XCODE_SIGN_ARGS+=("DEVELOPMENT_TEAM=$DEVELOPMENT_TEAM")
echo '==> Archiving Rsync Studio with hardened runtime...'
xcodebuild -project "$PROJECT" -scheme "$SCHEME" -configuration Release \
-destination 'generic/platform=macOS' -archivePath "$ARCHIVE_PATH" \
DEVELOPMENT_TEAM="$TEAM_ID" CODE_SIGN_STYLE=Automatic \
ENABLE_HARDENED_RUNTIME=YES \
clean archive
echo '==> Exporting Developer ID app...'
xcodebuild -exportArchive -archivePath "$ARCHIVE_PATH" \
-exportPath "$EXPORT_DIR" -exportOptionsPlist "$EXPORT_OPTIONS"
[[ -d "$APP_PATH" ]] || { echo "ERROR: Missing exported app" >&2; exit 1; }
# Xcode's generated Info.plist can omit CFBundleIconFile even when the
# build setting specifies it. Set it on the exported bundle BEFORE the
# final Developer ID signature, so the signed app includes the icon key.
ICON_PATH="$APP_PATH/Contents/Resources/AppIcon.icns"
if [[ ! -s "$ICON_PATH" ]]; then
ICON_PATH="$(find "$APP_PATH/Contents/Resources" -maxdepth 1 -name "*.icns" -type f -print -quit)"
fi
echo "== Archive $DISPLAY_NAME $VERSION ($BUILD) =="
xcodebuild \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration "$CONFIGURATION" \
-destination 'generic/platform=macOS' \
-archivePath "$ARCHIVE" \
clean archive \
"${XCODE_SIGN_ARGS[@]}"
echo "== Export Developer ID app =="
xcodebuild \
-exportArchive \
-archivePath "$ARCHIVE" \
-exportPath "$EXPORT_DIR" \
-exportOptionsPlist "$EXPORT_OPTIONS" \
-allowProvisioningUpdates
APP="$EXPORT_DIR/$PRODUCT_NAME.app"
[[ -d "$APP" ]] || { echo "ERROR: Exported app not found: $APP" >&2; exit 1; }
ACTUAL_BUNDLE_ID="$(plutil -extract CFBundleIdentifier raw -o - "$APP/Contents/Info.plist" 2>/dev/null || true)"
[[ "$ACTUAL_BUNDLE_ID" == "$BUNDLE_ID" ]] || { echo "ERROR: Exported bundle id is '$ACTUAL_BUNDLE_ID', expected '$BUNDLE_ID'." >&2; exit 1; }
"$SCRIPT_DIR/verify-signed-app.sh" "$APP"
if [[ "$SKIP_NOTARIZATION" -eq 0 ]]; then
echo "== Validate notarization credentials =="
xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null
echo "== Notarize exported app =="
APP_ZIP="$RELEASE_DIR/$PRODUCT_NAME-$VERSION-notary.zip"
ditto -c -k --keepParent "$APP" "$APP_ZIP"
xcrun notarytool submit "$APP_ZIP" --keychain-profile "$NOTARY_PROFILE" --wait
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
rm -f "$APP_ZIP"
"$SCRIPT_DIR/verify-signed-app.sh" "$APP"
[[ -s "$ICON_PATH" ]] || { echo "ERROR: Missing bundled icon $ICON_PATH" >&2; exit 1; }
PLIST_PATH="$APP_PATH/Contents/Info.plist"
ICON_KEY="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' "$PLIST_PATH" 2>/dev/null || true)"
if [[ "$ICON_KEY" != "$(basename "$ICON_PATH" .icns)" && "$ICON_KEY" != "$(basename "$ICON_PATH")" ]]; then
echo "==> Setting CFBundleIconFile in exported app (before signing)..."
if /usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' "$PLIST_PATH" >/dev/null 2>&1; then
/usr/libexec/PlistBuddy -c 'Set :CFBundleIconFile $(basename "$ICON_PATH")' "$PLIST_PATH"
else
/usr/libexec/PlistBuddy -c 'Add :CFBundleIconFile string $(basename "$ICON_PATH")' "$PLIST_PATH"
fi
fi
ICON_KEY="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' "$PLIST_PATH")"
[[ "$ICON_KEY" == "$(basename "$ICON_PATH" .icns)" || "$ICON_KEY" == "$(basename "$ICON_PATH")" ]] || {
echo "ERROR: CFBundleIconFile still incorrect: '$ICON_KEY'" >&2; exit 1;
}
echo "==> Application icon verified: $ICON_KEY"
sips -g pixelWidth -g pixelHeight "$ICON_PATH"
echo "== Create drag-to-Applications DMG =="
mkdir -p "$DMG_STAGING"
ditto "$APP" "$DMG_STAGING/$PRODUCT_NAME.app"
ln -s /Applications "$DMG_STAGING/Applications"
cat > "$DMG_STAGING/README.txt" <<TXT
$DISPLAY_NAME $VERSION
# Export may omit hardened runtime; sign exported app explicitly.
# Sign embedded code first if your project adds frameworks/helpers in future.
echo '==> Developer ID signing with hardened runtime...'
codesign --force --deep --options runtime --timestamp --sign "$SIGNING_IDENTITY" "$APP_PATH"
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
SIGNATURE_INFO="$(codesign -dv --verbose=4 "$APP_PATH" 2>&1 || true)"
printf '%s\n' "$SIGNATURE_INFO" | grep -E 'Identifier=|Authority=|TeamIdentifier=|flags=' || true
[[ "$SIGNATURE_INFO" == *"TeamIdentifier=$TEAM_ID"* ]] || { echo 'ERROR: Wrong signing team' >&2; exit 1; }
[[ "$SIGNATURE_INFO" == *'(runtime)'* ]] || { echo 'ERROR: Hardened runtime missing' >&2; exit 1; }
Install: drag $DISPLAY_NAME to Applications.
Then eject this disk image and launch $DISPLAY_NAME from Applications.
TXT
hdiutil create \
-volname "$DISPLAY_NAME $VERSION" \
-srcfolder "$DMG_STAGING" \
-ov \
-format UDZO \
-imagekey zlib-level=9 \
"$DMG" >/dev/null
echo '==> Notarizing and stapling app...'
ditto -c -k --keepParent "$APP_PATH" "$APP_ZIP"
xcrun notarytool submit "$APP_ZIP" --keychain-profile "$NOTARY_PROFILE" --wait
xcrun stapler staple "$APP_PATH"
xcrun stapler validate "$APP_PATH"
spctl --assess --type execute --verbose=4 "$APP_PATH"
hdiutil verify "$DMG" >/dev/null
echo '==> Preparing writable DMG...'
ditto "$APP_PATH" "$DMG_STAGE/$APP_NAME.app"
ln -s /Applications "$DMG_STAGE/Applications"
# Create writable image first: the custom volume icon must be set ON the
# mounted filesystem, not on the staging directory.
hdiutil create -size 100m -fs HFS+ -volname "$APP_NAME" -ov "$RW_DMG"
MOUNTED=0
cleanup() {
if [[ "$MOUNTED" == 1 ]]; then hdiutil detach "$MOUNT_POINT" -quiet || true; fi
}
trap cleanup EXIT
hdiutil attach "$RW_DMG" -nobrowse -noverify -mountpoint "$MOUNT_POINT" -quiet
MOUNTED=1
ditto "$DMG_STAGE/$APP_NAME.app" "$MOUNT_POINT/$APP_NAME.app"
ln -s /Applications "$MOUNT_POINT/Applications"
cp "$ICON_PATH" "$MOUNT_POINT/.VolumeIcon.icns"
if xcrun --find SetFile >/dev/null 2>&1; then xcrun SetFile -a C "$MOUNT_POINT"; else echo "WARNING: SetFile unavailable; skipping custom volume icon flag"; fi
hdiutil detach "$MOUNT_POINT" -quiet
MOUNTED=0
hdiutil convert "$RW_DMG" -format UDZO -o "$TMP_DMG" -ov
if [[ "$SKIP_NOTARIZATION" -eq 0 ]]; then
echo "== Notarize final DMG =="
xcrun notarytool submit "$DMG" --keychain-profile "$NOTARY_PROFILE" --wait
xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG"
# A DMG *file* icon is different from its mounted volume icon. Use AppKit to
# assign a Finder custom icon to the DMG file before signing/notarization.
# This creates Finder metadata (custom icon resource/finder flag).
ICON_SWIFT="$BUILD_DIR/set-dmg-file-icon.swift"
cat > "$ICON_SWIFT" <<'SWIFT'
import AppKit
import Foundation
let arguments = CommandLine.arguments
precondition(arguments.count == 3, "Usage: icon.swift <icon.icns> <file.dmg>")
guard let icon = NSImage(contentsOfFile: arguments[1]) else {
fputs("ERROR: Cannot load ICNS image\n", stderr); exit(1)
}
guard NSWorkspace.shared.setIcon(icon, forFile: arguments[2], options: []) else {
fputs("ERROR: NSWorkspace could not set DMG Finder icon\n", stderr); exit(1)
}
print("Custom Finder icon assigned to DMG file")
SWIFT
echo '==> Assigning Finder icon to DMG file...'
swift "$ICON_SWIFT" "$ICON_PATH" "$TMP_DMG"
echo "== Final Gatekeeper verification =="
"$SCRIPT_DIR/verify-signed-app.sh" "$APP"
spctl --assess --type open --context context:primary-signature --verbose=4 "$DMG"
else
echo "WARNING: Notarization was skipped. This output is NOT ready for public distribution." >&2
fi
echo '==> Signing and notarizing DMG...'
codesign --force --timestamp --sign "$SIGNING_IDENTITY" "$TMP_DMG"
codesign --verify --verbose=2 "$TMP_DMG"
xcrun notarytool submit "$TMP_DMG" --keychain-profile "$NOTARY_PROFILE" --wait
xcrun stapler staple "$TMP_DMG"
xcrun stapler validate "$TMP_DMG"
spctl --assess --type open --context context:primary-signature --verbose=4 "$TMP_DMG"
printf '%s %s\n' "$(shasum -a 256 "$DMG" | awk '{print $1}')" "$(basename "$DMG")" > "$CHECKSUM"
DMG_SIZE="$(stat -f '%z' "$DMG")"
DMG_SHA256="$(awk '{print $1}' "$CHECKSUM")"
cat > "$METADATA" <<INFO
Product: $DISPLAY_NAME
Bundle ID: $BUNDLE_ID
Version: $VERSION
Build: $BUILD
Artifact: $(basename "$DMG")
Bytes: $DMG_SIZE
SHA-256: $DMG_SHA256
Notarized: $([[ "$SKIP_NOTARIZATION" -eq 0 ]] && echo yes || echo no)
Notary profile: $NOTARY_PROFILE
Generated (UTC): $(date -u '+%Y-%m-%dT%H:%M:%SZ')
INFO
rm -f "$EXPORT_OPTIONS"
echo
echo "Release artifact created successfully:"
echo " $DMG"
echo " $CHECKSUM"
echo " $METADATA"
echo
echo "Publishing is intentionally NOT automated. Test the DMG on a clean Mac before upload."
# Publish using ditto so Finder metadata/resource forks survive the copy.
ditto "$TMP_DMG" "$DMG_PATH"
echo '==> Release ready:'
ls -lh "$DMG_PATH"
shasum -a 256 "$DMG_PATH"
printf '\n========== RSYNC STUDIO RELEASE COMPLETE ==========\n%s\n' "$DMG_PATH"
-40
View File
@@ -1,40 +0,0 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
PROJECT="$ROOT/Rsync Studio.xcodeproj"
echo "== Rsync Studio project security checks =="
grep -q 'ENABLE_HARDENED_RUNTIME = YES' "$PROJECT/project.pbxproj"
grep -q 'ENABLE_APP_SANDBOX = NO' "$PROJECT/project.pbxproj"
grep -q 'INFOPLIST_KEY_CFBundleIconName = AppIcon' "$PROJECT/project.pbxproj"
test -f "$ROOT/Rsync Studio/Resources/PrivacyInfo.xcprivacy"
if grep -R --line-number -E 'StrictHostKeyChecking[ =]+no|UserKnownHostsFile[ =]+/dev/null|sshpass' "$ROOT/Rsync Studio" --include='*.swift'; then
echo "Unsafe SSH configuration detected." >&2
exit 1
fi
grep -q 'BatchMode=yes' "$ROOT/Rsync Studio/Services/RsyncCommandBuilder.swift"
grep -q 'allowUnattendedDelete' "$ROOT/Rsync Studio/Models/SyncJob.swift"
grep -q 'RsyncSecurityValidator' "$ROOT/Rsync Studio/Services/RsyncCommandBuilder.swift"
grep -q 'isSafeRemotePath' "$ROOT/Rsync Studio/Services/RsyncCommandBuilder.swift"
grep -q 'hasBalancedShellSyntax' "$ROOT/Rsync Studio/Services/RsyncCommandBuilder.swift"
grep -q -- '--files-from' "$ROOT/Rsync Studio/Services/RsyncCommandBuilder.swift"
grep -q -- '--log-file' "$ROOT/Rsync Studio/Services/RsyncCommandBuilder.swift"
grep -q 'final class LicensingManager' "$ROOT/Rsync Studio/Services/LicensingManager.swift"
grep -q 'trialLengthDays = 30' "$ROOT/Rsync Studio/Services/LicensingManager.swift"
grep -q 'Curve25519.Signing.PublicKey' "$ROOT/Rsync Studio/Services/LicensingManager.swift"
grep -q 'RsyncStudioLicensePublicKey' "$PROJECT/project.pbxproj"
if find "$ROOT" -type f \( -iname '*private*key*' -o -iname '*.p8' \) ! -path '*/build/*' | grep -q .; then
echo "A private signing key appears to be present in the project tree." >&2
exit 1
fi
echo "Static policy checks passed."
echo ""
echo "After creating a signed Release app, also run:"
echo " codesign --verify --deep --strict --verbose=2 /path/to/Rsync Studio.app"
echo " spctl --assess --type execute --verbose=4 /path/to/Rsync Studio.app"
echo " xcrun stapler validate /path/to/Rsync Studio.app"
-51
View File
@@ -1,51 +0,0 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
PBX="$ROOT/Rsync Studio.xcodeproj/project.pbxproj"
EXPECTED_BUNDLE_ID="net.absdev.RsyncStudio"
EXPECTED_VERSION="1.0.0"
EXPECTED_BUILD="1"
EXPECTED_MIN_MACOS="14.0"
fail() { echo "ERROR: $*" >&2; exit 1; }
pass() { echo "OK: $*"; }
[[ -f "$PBX" ]] || fail "Missing Xcode project file"
[[ $(grep -c "PRODUCT_BUNDLE_IDENTIFIER = $EXPECTED_BUNDLE_ID;" "$PBX") -ge 2 ]] || fail "Bundle identifier must remain $EXPECTED_BUNDLE_ID in all app configurations"
pass "Bundle identifier is $EXPECTED_BUNDLE_ID"
[[ $(grep -c "MARKETING_VERSION = $EXPECTED_VERSION;" "$PBX") -ge 2 ]] || fail "Marketing version must be $EXPECTED_VERSION"
pass "Marketing version is $EXPECTED_VERSION"
[[ $(grep -c "CURRENT_PROJECT_VERSION = $EXPECTED_BUILD;" "$PBX") -ge 2 ]] || fail "Build number must be $EXPECTED_BUILD"
pass "Build number is $EXPECTED_BUILD"
[[ $(grep -c 'ENABLE_HARDENED_RUNTIME = YES;' "$PBX") -ge 2 ]] || fail "Hardened Runtime must be enabled"
pass "Hardened Runtime is enabled"
[[ $(grep -c 'ENABLE_APP_SANDBOX = NO;' "$PBX") -ge 2 ]] || fail "App Sandbox baseline changed unexpectedly"
pass "App Sandbox remains intentionally disabled for direct distribution"
[[ $(grep -c "MACOSX_DEPLOYMENT_TARGET = $EXPECTED_MIN_MACOS;" "$PBX") -ge 2 ]] || fail "macOS deployment target must remain $EXPECTED_MIN_MACOS"
pass "Minimum macOS is $EXPECTED_MIN_MACOS"
[[ $(grep -c 'INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";' "$PBX") -ge 2 ]] || fail "Application category is missing"
pass "Application category is Utilities"
if grep -q 'CODE_SIGN_IDENTITY = "Apple Development"' "$PBX"; then
fail "A project-level Apple Development identity is pinned; Release distribution must use Developer ID Application"
fi
pass "No development certificate is pinned into the project"
if grep -q 'com.apple.security.get-task-allow' "$ROOT/Rsync Studio"/*.entitlements 2>/dev/null; then
fail "get-task-allow must never ship in a commercial Release build"
fi
pass "No get-task-allow entitlement is present in source entitlements"
cat <<'MSG'
Release identity baseline passed.
Before the first commercial archive, select your Apple Developer Team in Xcode and ensure the archived app is signed with a "Developer ID Application" certificate. The exact certificate identity is intentionally not committed to source control.
MSG
-44
View File
@@ -1,44 +0,0 @@
#!/bin/bash
set -euo pipefail
APP="${1:-}"
[[ -n "$APP" ]] || { echo "Usage: $0 /path/to/Rsync Studio.app" >&2; exit 2; }
[[ -d "$APP" ]] || { echo "ERROR: App not found: $APP" >&2; exit 2; }
for tool in codesign spctl; do
command -v "$tool" >/dev/null || { echo "ERROR: Missing required tool: $tool" >&2; exit 2; }
done
echo "== Verifying code signature =="
codesign --verify --deep --strict --verbose=2 "$APP"
echo "== Inspecting signing authority =="
SIGNING_INFO="$(codesign -dvvv "$APP" 2>&1)"
printf '%s\n' "$SIGNING_INFO"
grep -q 'Authority=Developer ID Application:' <<<"$SIGNING_INFO" || {
echo "ERROR: App is not signed with Developer ID Application." >&2
exit 1
}
echo "== Checking Hardened Runtime =="
grep -Eq '^CodeDirectory .* flags=.*runtime' <<<"$SIGNING_INFO" || {
echo "ERROR: Hardened Runtime flag was not found in the signed app." >&2
exit 1
}
echo "== Checking release entitlements =="
ENTITLEMENTS="$(codesign -d --entitlements :- "$APP" 2>/dev/null || true)"
if grep -q 'com.apple.security.get-task-allow' <<<"$ENTITLEMENTS"; then
echo "ERROR: Release app contains com.apple.security.get-task-allow." >&2
exit 1
fi
if grep -q '<true/>' <<<"$(printf '%s\n' "$ENTITLEMENTS" | sed -n '/com.apple.security.app-sandbox/,+1p')"; then
echo "ERROR: App Sandbox is unexpectedly enabled in the direct-distribution build." >&2
exit 1
fi
echo "== Gatekeeper assessment =="
spctl --assess --type execute --verbose=4 "$APP"
echo "Signed Developer ID app verification passed: $APP"
-19
View File
@@ -1,19 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SERVER="$ROOT/rsync-studio-licensing"
[[ -d "$SERVER" ]] || { echo 'Licensing Server directory is missing.' >&2; exit 1; }
if command -v php >/dev/null 2>&1; then
while IFS= read -r -d '' file; do php -l "$file" >/dev/null; done < <(find "$SERVER" -name '*.php' -type f -print0)
echo 'OK: all Stage 4 PHP files pass php -l'
else
echo 'WARN: PHP is unavailable; skipped php -l checks.'
fi
bash -n "$SERVER/install-overlay.sh"
! grep -RniE --exclude='*.md' --exclude='*.example' '(PRIVATE[_ -]?KEY|PADDLE_WEBHOOK_SECRET|LEMON_SQUEEZY_WEBHOOK_SECRET)[[:space:]]*=[[:space:]]*[A-Za-z0-9+/=_-]{20,}' "$SERVER" >/dev/null || { echo 'Possible committed secret detected.' >&2; exit 1; }
grep -q "sodium_crypto_sign_detached" "$SERVER/app/Services/Licensing/LicenseSigner.php"
grep -q "hash_equals" "$SERVER/app/Services/Payments/PaddleWebhookVerifier.php"
grep -q "hash_equals" "$SERVER/app/Services/Payments/LemonSqueezyWebhookVerifier.php"
grep -q "lockForUpdate" "$SERVER/app/Http/Controllers/Api/LicenseActivationController.php"
grep -q "license_delivery_outbox" "$SERVER/database/migrations/2026_09_08_200000_create_rsync_studio_licensing_tables.php"
echo 'Stage 4 licensing-server static checks passed.'
-12
View File
@@ -1,12 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
LS="$ROOT/rsync-studio-licensing"
find "$LS/app" "$LS/config" "$LS/database" "$LS/routes" -name '*.php' -print0 | while IFS= read -r -d '' f; do php -l "$f" >/dev/null; done
grep -q "implements MustVerifyEmail" "$LS/app/Models/User.php"
grep -q "AdminOnly::class" "$LS/routes/web.stage5.php"
grep -q "middleware(\['auth','verified'\])" "$LS/routes/web.stage5.php"
grep -q "api: __DIR__.'/../routes/api.php'" "$LS/bootstrap/app.php"
grep -q "RsyncStudioLicensingServiceProvider::class" "$LS/bootstrap/providers.php"
grep -q "create_rsync_studio_auth_tables" <(find "$LS/database/migrations" -maxdepth 1 -type f -printf '%f\n')
echo 'Stage 5 integrated licensing portal static verification passed.'
View File
+10
View File
@@ -72,6 +72,16 @@ RSYNC_STUDIO_DEFAULT_MAX_MAJOR_VERSION=1
RSYNC_STUDIO_PURCHASE_URL=
RSYNC_STUDIO_PORTAL_URL=
RSYNC_STUDIO_SUPPORT_URL=
PADDLE_API_KEY=
PADDLE_API_URL=https://sandbox-api.paddle.com
PADDLE_CLIENT_SIDE_TOKEN=
PADDLE_ENVIRONMENT=sandbox
PADDLE_WEBHOOK_SECRET=
PADDLE_WEBHOOK_TOLERANCE=300
PADDLE_PERSONAL_PRODUCT_ID=
PADDLE_PERSONAL_MONTHLY_PRICE_ID=
PADDLE_PERSONAL_ANNUAL_PRICE_ID=
PADDLE_PROFESSIONAL_PRODUCT_ID=
PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID=
PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID=
LEMON_SQUEEZY_WEBHOOK_SECRET=
View File
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
+29 -3
View File
@@ -1,10 +1,36 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
final class RsyncStudioAdminCreate extends Command {
protected $signature='rsync-studio:admin {email} {--name=Administrator}'; protected $description='Create or promote an Rsync Studio licensing administrator';
public function handle(): int { $email=strtolower(trim($this->argument('email'))); $password=Str::password(24,true,true,false,false); $u=User::firstOrNew(['email'=>$email]); $u->name=$this->option('name'); $u->is_admin=true; $u->email_verified_at=now(); if(!$u->exists){$u->password=Hash::make($password);} $u->save(); $this->info("Administrator ready: {$email}"); if(!$u->wasRecentlyCreated){$this->line('Existing password retained.');}else{$this->warn("Temporary password (shown once): {$password}");} return self::SUCCESS; }
final class RsyncStudioAdminCreate extends Command
{
protected $signature = 'rsync-studio:admin {email} {--name=Administrator}';
protected $description = 'Create or promote an Rsync Studio licensing administrator';
public function handle(): int
{
$email = strtolower(trim($this->argument('email')));
$password = Str::password(24, true, true, false, false);
$u = User::firstOrNew(['email' => $email]);
$u->name = $this->option('name');
$u->is_admin = true;
$u->email_verified_at = now();
if (! $u->exists) {
$u->password = Hash::make($password);
} $u->save();
$this->info("Administrator ready: {$email}");
if (! $u->wasRecentlyCreated) {
$this->line('Existing password retained.');
} else {
$this->warn("Temporary password (shown once): {$password}");
}
return self::SUCCESS;
}
}
+21 -2
View File
@@ -1,10 +1,29 @@
<?php
namespace App\Console\Commands;
use App\Models\LicenseProduct;
use App\Services\Licensing\LicenseIssuer;
use Illuminate\Console\Command;
class RsyncStudioLicenseIssue extends Command
{
protected $signature='rsync-studio:license:issue {product} {--name=} {--email=}'; protected $description='Issue a manual Rsync Studio licence key';
public function handle(LicenseIssuer $issuer): int { $p=LicenseProduct::where('code',$this->argument('product'))->firstOrFail(); [$l,$key]=$issuer->issue($p,$this->option('name')?:null,$this->option('email')?:null); if(!$key){$this->warn('Licence already existed; plaintext key is intentionally not recoverable.');return self::FAILURE;} $this->line('Licence ID: '.$l->id);$this->line('Licence key: '.$key);$this->warn('This is the only time the plaintext key is shown.');return self::SUCCESS; }
protected $signature = 'rsync-studio:license:issue {product} {--name=} {--email=}';
protected $description = 'Issue a manual Rsync Studio licence key';
public function handle(LicenseIssuer $issuer): int
{
$p = LicenseProduct::where('code', $this->argument('product'))->firstOrFail();
[$l,$key] = $issuer->issue($p, $this->option('name') ?: null, $this->option('email') ?: null);
if (! $key) {
$this->warn('Licence already existed; plaintext key is intentionally not recoverable.');
return self::FAILURE;
} $this->line('Licence ID: '.$l->id);
$this->line('Licence key: '.$key);
$this->warn('This is the only time the plaintext key is shown.');
return self::SUCCESS;
}
}
+15 -2
View File
@@ -1,9 +1,22 @@
<?php
namespace App\Console\Commands;
use App\Models\License;
use Illuminate\Console\Command;
class RsyncStudioLicenseRevoke extends Command
{
protected $signature='rsync-studio:license:revoke {id} {--reason=Administrative revocation}'; protected $description='Revoke a Rsync Studio licence';
public function handle(): int { $l=License::findOrFail($this->argument('id'));$l->update(['revoked_at'=>now(),'revoke_reason'=>$this->option('reason')]);$this->info('Licence revoked.');return self::SUCCESS; }
protected $signature = 'rsync-studio:license:revoke {id} {--reason=Administrative revocation}';
protected $description = 'Revoke a Rsync Studio licence';
public function handle(): int
{
$l = License::findOrFail($this->argument('id'));
$l->update(['revoked_at' => now(), 'revoke_reason' => $this->option('reason')]);
$this->info('Licence revoked.');
return self::SUCCESS;
}
}
+14 -3
View File
@@ -1,10 +1,21 @@
<?php
namespace App\Console\Commands;
use App\Models\LicenseProduct;
use Illuminate\Console\Command;
class RsyncStudioProductCreate extends Command
{
protected $signature='rsync-studio:product {code} {--name=Rsync Studio} {--edition=Professional} {--activations=1} {--max-major=1} {--provider=} {--product-id=} {--price-id=}';
protected $description='Create or update a Rsync Studio licensing product';
public function handle(): int { $p=LicenseProduct::updateOrCreate(['code'=>$this->argument('code')],['name'=>$this->option('name'),'edition'=>$this->option('edition'),'activation_limit'=>(int)$this->option('activations'),'max_major_version'=>$this->option('max-major')!==''?(int)$this->option('max-major'):null,'payment_provider'=>$this->option('provider')?:null,'external_product_id'=>$this->option('product-id')?:null,'external_price_id'=>$this->option('price-id')?:null,'active'=>true]); $this->info("Product {$p->code} ready."); return self::SUCCESS; }
protected $signature = 'rsync-studio:product {code} {--name=Rsync Studio} {--edition=Professional} {--activations=1} {--max-major=1} {--provider=} {--product-id=} {--price-id=}';
protected $description = 'Create or update a Rsync Studio licensing product';
public function handle(): int
{
$p = LicenseProduct::updateOrCreate(['code' => $this->argument('code')], ['name' => $this->option('name'), 'edition' => $this->option('edition'), 'activation_limit' => (int) $this->option('activations'), 'max_major_version' => $this->option('max-major') !== '' ? (int) $this->option('max-major') : null, 'payment_provider' => $this->option('provider') ?: null, 'external_product_id' => $this->option('product-id') ?: null, 'external_price_id' => $this->option('price-id') ?: null, 'active' => true]);
$this->info("Product {$p->code} ready.");
return self::SUCCESS;
}
}
+42 -7
View File
@@ -1,14 +1,49 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\LicenseProduct;
use App\Models\LicenseActivation;
use App\Models\User;
use Illuminate\Http\Request;
final class AdminController extends Controller {
public function index(){ return view('admin.index',['licenseCount'=>License::count(),'activeCount'=>License::whereNull('revoked_at')->count(),'activationCount'=>\App\Models\LicenseActivation::whereNull('deactivated_at')->count(),'userCount'=>User::count()]); }
public function licenses(Request $r){ $q=License::with('product')->latest(); if($s=trim((string)$r->query('q'))) $q->where(fn($x)=>$x->where('customer_email','like',"%{$s}%")->orWhere('customer_name','like',"%{$s}%")->orWhere('license_key_last4','like',"%{$s}%")); return view('admin.licenses',['licenses'=>$q->paginate(50)->withQueryString()]); }
public function show(License $license){$license->load(['product','activations']); return view('admin.license',compact('license'));}
public function revoke(Request $request,License $license){$data=$request->validate(['reason'=>['nullable','string','max:255']]);$license->update(['revoked_at'=>now(),'revoke_reason'=>$data['reason']?:'Revoked by administrator']);return back()->with('status','Licence revoked.');}
public function restore(License $license){$license->update(['revoked_at'=>null,'revoke_reason'=>null]);return back()->with('status','Licence restored.');}
final class AdminController extends Controller
{
public function index()
{
return view('admin.index', ['licenseCount' => License::count(), 'activeCount' => License::whereNull('revoked_at')->count(), 'activationCount' => LicenseActivation::whereNull('deactivated_at')->count(), 'userCount' => User::count()]);
}
public function licenses(Request $r)
{
$q = License::with('product')->latest();
if ($s = trim((string) $r->query('q'))) {
$q->where(fn ($x) => $x->where('customer_email', 'like', "%{$s}%")->orWhere('customer_name', 'like', "%{$s}%")->orWhere('license_key_last4', 'like', "%{$s}%"));
}
return view('admin.licenses', ['licenses' => $q->paginate(50)->withQueryString()]);
}
public function show(License $license)
{
$license->load(['product', 'activations']);
return view('admin.license', compact('license'));
}
public function revoke(Request $request, License $license)
{
$data = $request->validate(['reason' => ['nullable', 'string', 'max:255']]);
$license->update(['revoked_at' => now(), 'revoke_reason' => $data['reason'] ?: 'Revoked by administrator']);
return back()->with('status', 'Licence revoked.');
}
public function restore(License $license)
{
$license->update(['revoked_at' => null, 'revoke_reason' => null]);
return back()->with('status','Licence restored.');
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\SupportTicket;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
final class SupportTicketController extends Controller
{
public function index(Request $request)
{
$tickets = SupportTicket::query()
->with('assignee')
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->latest()
->paginate(50)
->withQueryString();
return view('admin.support.index', compact('tickets'));
}
public function show(SupportTicket $ticket)
{
$ticket->load(['user', 'assignee']);
if (auth()->user()) {
auth()->user()->unreadNotifications()->get()
->filter(fn ($notification) => (int) ($notification->data['support_ticket_id'] ?? 0) === $ticket->id)
->each->markAsRead();
}
return view('admin.support.show', [
'ticket' => $ticket,
'admins' => User::query()->where('is_admin', true)->orderBy('name')->get(['id', 'name', 'email']),
]);
}
public function update(Request $request, SupportTicket $ticket): RedirectResponse
{
$data = $request->validate([
'status' => ['required', 'in:new,open,waiting,resolved,closed'],
'priority' => ['required', 'in:low,normal,high,urgent'],
'assigned_to' => ['nullable', 'integer', 'exists:users,id'],
]);
if ($data['assigned_to'] ?? null) {
abort_unless(User::whereKey($data['assigned_to'])->where('is_admin', true)->exists(), 422);
}
$data['resolved_at'] = in_array($data['status'], ['resolved', 'closed'], true)
? ($ticket->resolved_at ?? now())
: null;
$ticket->update($data);
return back()->with('status', 'Support ticket updated.');
}
}
+134 -23
View File
@@ -1,4 +1,5 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
@@ -17,37 +18,147 @@ final class LicenseActivationController extends Controller
public function activate(Request $request, LicenseSigner $signer): JsonResponse
{
$data = $request->validate([
'licenseKey'=>['required','string','max:128'],
'installationID'=>['required','uuid'],
'appVersion'=>['required','string','max:32'],
'platform'=>['required','string','max:32'],
'licenseKey' => ['required', 'string', 'max:128'],
'installationID' => ['required', 'uuid'],
'appVersion' => ['required', 'string', 'max:32'],
'platform' => ['required', 'string', 'max:32'],
]);
return DB::transaction(function () use ($data,$signer) {
$license = License::where('license_key_hash', LicenseKey::hash($data['licenseKey']))->lockForUpdate()->first();
if (!$license) throw ValidationException::withMessages(['licenseKey'=>'The licence key is not valid.']);
if (!$license->is_usable) return response()->json(['message'=>'This licence is expired or has been revoked.'], 403);
return DB::transaction(function () use ($data, $signer) {
$license = License::where('license_key_hash', LicenseKey::hash($data['licenseKey']))
->lockForUpdate()
->first();
$activation = LicenseActivation::where('license_id',$license->id)->where('installation_id',strtolower($data['installationID']))->first();
if ($activation && $activation->deactivated_at === null) {
$activation->update(['last_seen_at'=>now(),'platform'=>$data['platform'],'app_version'=>$data['appVersion']]);
} else {
$activeCount = LicenseActivation::where('license_id',$license->id)->whereNull('deactivated_at')->count();
if ($activeCount >= $license->activation_limit) return response()->json(['message'=>'This licence has reached its activation limit. Deactivate another Mac first.'], 409);
if ($activation) $activation->update(['deactivated_at'=>null,'activated_at'=>now(),'last_seen_at'=>now(),'platform'=>$data['platform'],'app_version'=>$data['appVersion']]);
else LicenseActivation::create(['license_id'=>$license->id,'installation_id'=>strtolower($data['installationID']),'platform'=>$data['platform'],'app_version'=>$data['appVersion'],'activated_at'=>now(),'last_seen_at'=>now()]);
if (! $license) {
throw ValidationException::withMessages(['licenseKey' => 'The licence key is not valid.']);
}
return response()->json(['licenseToken'=>$signer->sign($license, $data['installationID'])]);
if (! $license->is_usable) {
return response()->json(['message' => 'This licence is expired or has been revoked.'], 403);
}
$installationID = strtolower($data['installationID']);
$activation = LicenseActivation::where('license_id', $license->id)
->where('installation_id', $installationID)
->first();
if ($activation && $activation->deactivated_at === null) {
$activation->update([
'last_seen_at' => now(),
'platform' => $data['platform'],
'app_version' => $data['appVersion'],
]);
} else {
$activeCount = LicenseActivation::where('license_id', $license->id)
->whereNull('deactivated_at')
->count();
if ($activeCount >= $license->activation_limit) {
return response()->json(['message' => 'This licence has reached its activation limit. Deactivate another Mac first.'], 409);
}
if ($activation) {
$activation->update([
'deactivated_at' => null,
'activated_at' => now(),
'last_seen_at' => now(),
'platform' => $data['platform'],
'app_version' => $data['appVersion'],
]);
} else {
LicenseActivation::create([
'license_id' => $license->id,
'installation_id' => $installationID,
'platform' => $data['platform'],
'app_version' => $data['appVersion'],
'activated_at' => now(),
'last_seen_at' => now(),
]);
}
}
return response()->json([
'licenseToken' => $signer->sign($license, $data['installationID']),
]);
});
}
public function validateInstallation(
Request $request,
LicenseTokenVerifier $verifier,
LicenseSigner $signer,
): JsonResponse {
$data = $request->validate([
'licenseToken' => ['required', 'string', 'max:8192'],
'installationID' => ['required', 'uuid'],
'appVersion' => ['required', 'string', 'max:32'],
'platform' => ['required', 'string', 'max:32'],
]);
try {
$payload = $verifier->verify($data['licenseToken']);
} catch (\Throwable) {
return response()->json(['message' => 'The licence token is invalid.'], 400);
}
if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) {
return response()->json(['message' => 'The installation does not match this licence token.'], 400);
}
return DB::transaction(function () use ($data, $payload, $signer) {
$license = License::whereKey((string) ($payload['licenseID'] ?? ''))
->lockForUpdate()
->first();
if (! $license || ! $license->is_usable) {
return response()->json(['message' => 'This licence is expired or has been revoked.'], 403);
}
$activation = LicenseActivation::where('license_id', $license->id)
->where('installation_id', strtolower($data['installationID']))
->first();
if (! $activation || $activation->deactivated_at !== null) {
return response()->json(['message' => 'This Mac has been deactivated from the licence portal.'], 403);
}
$activation->update([
'last_seen_at' => now(),
'platform' => $data['platform'],
'app_version' => $data['appVersion'],
]);
return response()->json([
'active' => true,
// Refreshing the signed token also applies subscription edition/expiry changes
// without requiring the user to reactivate manually.
'licenseToken' => $signer->sign($license, $data['installationID']),
]);
});
}
public function deactivate(Request $request, LicenseTokenVerifier $verifier): JsonResponse
{
$data = $request->validate(['licenseToken'=>['required','string','max:8192'],'installationID'=>['required','uuid']]);
try { $payload = $verifier->verify($data['licenseToken']); }
catch (\Throwable) { return response()->json(['message'=>'The licence token is invalid.'], 400); }
if (strcasecmp((string)($payload['installationID'] ?? ''), $data['installationID']) !== 0) return response()->json(['message'=>'The installation does not match this licence token.'], 400);
LicenseActivation::where('license_id',(string)($payload['licenseID'] ?? ''))->where('installation_id',strtolower($data['installationID']))->whereNull('deactivated_at')->update(['deactivated_at'=>now(),'last_seen_at'=>now()]);
return response()->json((object)[]);
$data = $request->validate([
'licenseToken' => ['required', 'string', 'max:8192'],
'installationID' => ['required', 'uuid'],
]);
try {
$payload = $verifier->verify($data['licenseToken']);
} catch (\Throwable) {
return response()->json(['message' => 'The licence token is invalid.'], 400);
}
if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) {
return response()->json(['message' => 'The installation does not match this licence token.'], 400);
}
LicenseActivation::where('license_id', (string) ($payload['licenseID'] ?? ''))
->where('installation_id', strtolower($data['installationID']))
->whereNull('deactivated_at')
->update(['deactivated_at' => now(), 'last_seen_at' => now()]);
return response()->json((object) []);
}
}
+58 -17
View File
@@ -1,8 +1,10 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\LicenseWebhookEvent;
use App\Services\Licensing\LicenseDeliveryService;
use App\Services\Payments\LemonSqueezyWebhookVerifier;
use App\Services\Payments\PaddleWebhookVerifier;
use App\Services\Payments\PurchaseProvisioner;
@@ -12,26 +14,65 @@ use Illuminate\Support\Facades\DB;
final class PaymentWebhookController extends Controller
{
public function __invoke(string $provider, Request $request, PurchaseProvisioner $provisioner): JsonResponse
public function __invoke(string $provider, Request $request, PurchaseProvisioner $provisioner, LicenseDeliveryService $delivery): JsonResponse
{
$raw=(string)$request->getContent();
$verifier=match($provider){'paddle'=>app(PaddleWebhookVerifier::class),'lemon-squeezy'=>app(LemonSqueezyWebhookVerifier::class),default=>null};
if (!$verifier) return response()->json(['message'=>'Unknown webhook provider.'],404);
try { $verifier->verify($request,$raw); } catch (\Throwable $e) { report($e); return response()->json(['message'=>'Invalid webhook signature.'],400); }
try { $payload=json_decode($raw,true,flags:JSON_THROW_ON_ERROR); } catch (\Throwable) { return response()->json(['message'=>'Invalid JSON.'],400); }
$externalID=(string)($provider==='paddle'?($payload['event_id']??''):($payload['meta']['event_name']??'').':'.($payload['data']['id']??''));
$eventType=(string)($provider==='paddle'?($payload['event_type']??'unknown'):($payload['meta']['event_name']??'unknown'));
if ($externalID==='') return response()->json(['message'=>'Webhook event has no stable identifier.'],400);
$raw = (string) $request->getContent();
$verifier = match ($provider) {
'paddle' => app(PaddleWebhookVerifier::class),'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),default => null
};
if (! $verifier) {
return response()->json(['message' => 'Unknown webhook provider.'], 404);
}
try {
$verifier->verify($request, $raw);
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Invalid webhook signature.'], 400);
}
try {
$payload = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
} catch (\Throwable) {
return response()->json(['message' => 'Invalid JSON.'], 400);
}
$externalID = (string) ($provider === 'paddle' ? ($payload['event_id'] ?? '') : ($payload['meta']['event_name'] ?? '').':'.($payload['data']['id'] ?? ''));
$eventType = (string) ($provider === 'paddle' ? ($payload['event_type'] ?? 'unknown') : ($payload['meta']['event_name'] ?? 'unknown'));
if ($externalID === '') {
return response()->json(['message' => 'Webhook event has no stable identifier.'], 400);
}
try {
DB::transaction(function() use($provider,$externalID,$eventType,$payload,$raw,$provisioner){
$event=LicenseWebhookEvent::firstOrCreate(['provider'=>$provider,'external_event_id'=>$externalID],['event_type'=>$eventType,'payload_sha256'=>hash('sha256',$raw),'payload'=>$payload]);
if ($event->processed_at) return;
if (!hash_equals($event->payload_sha256,hash('sha256',$raw))) throw new \RuntimeException('Webhook ID was reused with a different payload.');
try { $provisioner->provision($provider,$payload); $event->update(['processed_at'=>now(),'processing_error'=>null]); }
catch (\Throwable $e) { $event->update(['processing_error'=>mb_substr($e->getMessage(),0,4000)]); throw $e; }
DB::transaction(function () use ($provider, $externalID, $eventType, $payload, $raw, $provisioner) {
$event = LicenseWebhookEvent::firstOrCreate(['provider' => $provider, 'external_event_id' => $externalID], ['event_type' => $eventType, 'payload_sha256' => hash('sha256', $raw), 'payload' => $payload]);
if ($event->processed_at) {
return;
}
if (! hash_equals($event->payload_sha256, hash('sha256', $raw))) {
throw new \RuntimeException('Webhook ID was reused with a different payload.');
}
try {
$provisioner->provision($provider, $payload);
$event->update(['processed_at' => now(), 'processing_error' => null]);
} catch (\Throwable $e) {
$event->update(['processing_error' => mb_substr($e->getMessage(), 0, 4000)]);
throw $e;
}
});
} catch (\Throwable $e) { report($e); return response()->json(['message'=>'Webhook could not be processed.'],500); }
return response()->json(['ok'=>true]);
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Webhook could not be processed.'], 500);
}
// Delivery is intentionally outside the webhook transaction. A temporary mail
// outage must not roll back an otherwise valid Paddle event. Pending outbox
// rows remain available for portal resend or a later webhook delivery attempt.
try {
$delivery->deliverPending(10);
} catch (\Throwable $e) {
report($e);
}
return response()->json(['ok' => true]);
}
}
@@ -1,14 +1,35 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
final class AuthenticatedSessionController extends Controller {
public function create(){ return view('auth.login'); }
public function store(Request $request){
$credentials=$request->validate(['email'=>['required','email'],'password'=>['required','string']]);
if(!Auth::attempt(['email'=>strtolower($credentials['email']),'password'=>$credentials['password']], $request->boolean('remember'))){ return back()->withErrors(['email'=>'The supplied credentials are invalid.'])->onlyInput('email'); }
$request->session()->regenerate(); return redirect()->intended(route('portal.index'));
}
public function destroy(Request $request){ Auth::logout(); $request->session()->invalidate(); $request->session()->regenerateToken(); return redirect()->route('login'); }
final class AuthenticatedSessionController extends Controller
{
public function create()
{
return view('auth.login');
}
public function store(Request $request)
{
$credentials = $request->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]);
if (! Auth::attempt(['email' => strtolower($credentials['email']), 'password' => $credentials['password']], $request->boolean('remember'))) {
return back()->withErrors(['email' => 'The supplied credentials are invalid.'])->onlyInput('email');
}
$request->session()->regenerate();
return redirect()->intended(route('portal.index'));
}
public function destroy(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
}
+25 -4
View File
@@ -1,10 +1,31 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\Request;
final class EmailVerificationController extends Controller {
public function notice(){ return view('auth.verify-email'); }
public function verify(EmailVerificationRequest $request){ $request->fulfill(); return redirect()->route('portal.index')->with('status','Email verified.'); }
public function resend(Request $request){ if(!$request->user()->hasVerifiedEmail()) $request->user()->sendEmailVerificationNotification(); return back()->with('status','Verification link sent.'); }
final class EmailVerificationController extends Controller
{
public function notice()
{
return view('auth.verify-email');
}
public function verify(EmailVerificationRequest $request)
{
$request->fulfill();
return redirect()->route('portal.index')->with('status', 'Email verified.');
}
public function resend(Request $request)
{
if (! $request->user()->hasVerifiedEmail()) {
$request->user()->sendEmailVerificationNotification();
}
return back()->with('status', 'Verification link sent.');
}
}
+33 -5
View File
@@ -1,5 +1,7 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
@@ -7,9 +9,35 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password as PasswordRule;
final class PasswordController extends Controller {
public function requestForm(){ return view('auth.forgot-password'); }
public function email(Request $request){ $request->validate(['email'=>['required','email']]); $status=Password::sendResetLink(['email'=>strtolower($request->email)]); return $status===Password::RESET_LINK_SENT?back()->with('status',__($status)):back()->withErrors(['email'=>__($status)]); }
public function resetForm(Request $request,string $token){ return view('auth.reset-password',['token'=>$token,'email'=>$request->query('email')]); }
public function reset(Request $request){ $data=$request->validate(['token'=>['required'],'email'=>['required','email'],'password'=>['required','confirmed',PasswordRule::min(12)->letters()->mixedCase()->numbers()]]); $status=Password::reset($data,function(User $user,string $password){$user->forceFill(['password'=>$password,'remember_token'=>Str::random(60)])->save();event(new PasswordReset($user));}); return $status===Password::PASSWORD_RESET?redirect()->route('login')->with('status',__($status)):back()->withErrors(['email'=>__($status)]); }
final class PasswordController extends Controller
{
public function requestForm()
{
return view('auth.forgot-password');
}
public function email(Request $request)
{
$request->validate(['email' => ['required', 'email']]);
$status = Password::sendResetLink(['email' => strtolower($request->email)]);
return $status === Password::RESET_LINK_SENT ? back()->with('status', __($status)) : back()->withErrors(['email' => __($status)]);
}
public function resetForm(Request $request, string $token)
{
return view('auth.reset-password', ['token' => $token, 'email' => $request->query('email')]);
}
public function reset(Request $request)
{
$data = $request->validate(['token' => ['required'], 'email' => ['required', 'email'], 'password' => ['required', 'confirmed', PasswordRule::min(12)->letters()->mixedCase()->numbers()]]);
$status = Password::reset($data, function (User $user, string $password) {
$user->forceFill(['password' => $password, 'remember_token' => Str::random(60)])->save();
event(new PasswordReset($user));
});
return $status === Password::PASSWORD_RESET ? redirect()->route('login')->with('status', __($status)) : back()->withErrors(['email' => __($status)]);
}
}
+20 -6
View File
@@ -1,15 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\Password;
final class RegisteredUserController extends Controller {
public function create(){ return view('auth.register'); }
public function store(Request $request){
$data=$request->validate(['name'=>['required','string','max:120'],'email'=>['required','email','max:255','unique:users,email'],'password'=>['required','confirmed',Password::min(12)->letters()->mixedCase()->numbers()]]);
$user=User::create(['name'=>$data['name'],'email'=>strtolower($data['email']),'password'=>$data['password']]); event(new Registered($user)); Auth::login($user); $request->session()->regenerate(); return redirect()->route('verification.notice');
}
final class RegisteredUserController extends Controller
{
public function create()
{
return view('auth.register');
}
public function store(Request $request)
{
$data = $request->validate(['name' => ['required', 'string', 'max:120'], 'email' => ['required', 'email', 'max:255', 'unique:users,email'], 'password' => ['required', 'confirmed', Password::min(12)->letters()->mixedCase()->numbers()]]);
$user = User::create(['name' => $data['name'], 'email' => strtolower($data['email']), 'password' => $data['password']]);
event(new Registered($user));
Auth::login($user);
$request->session()->regenerate();
return redirect()->route('verification.notice');
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Contracts\View\View;
use RuntimeException;
final class CheckoutController extends Controller
{
public function __invoke(): View
{
$paddle = config('rsync-studio.paddle');
$token = trim((string) ($paddle['client_side_token'] ?? ''));
if ($token === '') {
throw new RuntimeException('PADDLE_CLIENT_SIDE_TOKEN is not configured.');
}
$plans = [
'personal-monthly' => ['name' => 'Personal', 'billing' => 'Monthly', 'display_price' => '£1.99 / month', 'price_id' => $paddle['personal_monthly_price_id'], 'activation_limit' => 1],
'personal-annual' => ['name' => 'Personal', 'billing' => 'Annual', 'display_price' => '£14.99 / year', 'price_id' => $paddle['personal_annual_price_id'], 'activation_limit' => 1],
'professional-monthly' => ['name' => 'Professional', 'billing' => 'Monthly', 'display_price' => '£3.99 / month', 'price_id' => $paddle['professional_monthly_price_id'], 'activation_limit' => 3],
'professional-annual' => ['name' => 'Professional', 'billing' => 'Annual', 'display_price' => '£29.99 / year', 'price_id' => $paddle['professional_annual_price_id'], 'activation_limit' => 3],
];
foreach ($plans as $plan) {
if (trim((string) $plan['price_id']) === '') {
throw new RuntimeException('One or more Paddle price IDs are not configured.');
}
}
return view('checkout.buy', [
'plans' => $plans,
'paddleToken' => $token,
'paddleEnvironment' => (string) ($paddle['environment'] ?? 'sandbox'),
]);
}
}
View File
+133 -6
View File
@@ -1,15 +1,142 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\LicenseDeliveryOutbox;
use App\Models\LicenseProduct;
use App\Services\Payments\PaddleClient;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Mail;
final class PortalController extends Controller {
private function owned(Request $request){ return License::with(['product','activations'])->whereRaw('lower(customer_email) = ?', [strtolower($request->user()->email)]); }
public function index(Request $request){ return view('portal.index',['licenses'=>$this->owned($request)->latest()->get()]); }
public function show(Request $request,License $license){ abort_unless(strtolower((string)$license->customer_email)===strtolower($request->user()->email),404); $license->load(['product','activations']); return view('portal.show',compact('license')); }
public function deactivate(Request $request,License $license,int $activation){ abort_unless(strtolower((string)$license->customer_email)===strtolower($request->user()->email),404); $item=$license->activations()->whereKey($activation)->firstOrFail(); if(!$item->deactivated_at){$item->update(['deactivated_at'=>now()]);} return back()->with('status','Activation released.'); }
public function resend(Request $request,License $license){ abort_unless(strtolower((string)$license->customer_email)===strtolower($request->user()->email),404); $out=LicenseDeliveryOutbox::where('license_id',$license->id)->first(); if(!$out) return back()->withErrors(['licence'=>'This licence was issued manually and its full key is not recoverable. Contact support.']); $plain=Crypt::decryptString($out->encrypted_license_key); Mail::raw("Your Rsync Studio licence key is:\n\n{$plain}\n\nKeep this key private.",fn($m)=>$m->to($request->user()->email)->subject('Your Rsync Studio licence key')); $out->update(['delivered_at'=>now()]); return back()->with('status','Licence key sent to your verified email address.'); }
use RuntimeException;
final class PortalController extends Controller
{
public function __construct(private PaddleClient $paddle) {}
private function owned(Request $request)
{
return License::with(['product', 'activations'])
->whereRaw('lower(customer_email) = ?', [strtolower($request->user()->email)]);
}
public function index(Request $request)
{
$licenses = $this->owned($request)->latest()->get();
$hasProfessional = $licenses->contains(fn (License $license) => $license->is_usable && $license->edition === 'Professional');
return view('portal.index', compact('licenses', 'hasProfessional'));
}
public function show(Request $request, License $license)
{
$this->authorise($request, $license);
$license->load(['product', 'activations']);
return view('portal.show', compact('license'));
}
public function upgrade(Request $request, License $license): RedirectResponse
{
$this->authorise($request, $license);
abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422);
abort_unless($license->edition === 'Personal' && $license->is_usable, 422);
$targetPriceID = $this->professionalPriceFor($license);
$targetProduct = LicenseProduct::query()
->where('payment_provider', 'paddle')
->where('external_price_id', $targetPriceID)
->where('active', true)
->firstOrFail();
try {
$subscription = $this->paddle->updateSubscriptionPrice($license->external_subscription_id, $targetPriceID);
} catch (RuntimeException $exception) {
return back()->withErrors(['licence' => $exception->getMessage()]);
}
// Apply the entitlement immediately. Paddle webhooks remain authoritative and
// will reconcile subscription status/current-period details afterwards.
$license->update([
'license_product_id' => $targetProduct->id,
'edition' => $targetProduct->edition,
'activation_limit' => $targetProduct->activation_limit,
'max_major_version' => $targetProduct->max_major_version,
'external_price_id' => $targetPriceID,
'subscription_status' => (string) ($subscription['status'] ?? $license->subscription_status),
'revoked_at' => null,
'revoke_reason' => null,
]);
return redirect()->route('portal.show', $license)->with('status', 'Upgraded to Professional. Your existing Paddle subscription was changed; no second subscription was created.');
}
public function cancel(Request $request, License $license): RedirectResponse
{
$this->authorise($request, $license);
abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422);
abort_unless(! in_array($license->subscription_status, ['canceled'], true), 422);
try {
$this->paddle->cancelSubscriptionImmediately($license->external_subscription_id);
} catch (RuntimeException $exception) {
return back()->withErrors(['licence' => $exception->getMessage()]);
}
$license->update([
'subscription_status' => 'canceled',
'expires_at' => now(),
'revoked_at' => now(),
'revoke_reason' => 'Subscription canceled from licence portal',
'cancel_scheduled_at' => null,
]);
return redirect()->route('portal.index')->with('status', 'Subscription canceled in Paddle and the licence is no longer active.');
}
public function deactivate(Request $request, License $license, int $activation)
{
$this->authorise($request, $license);
$item = $license->activations()->whereKey($activation)->firstOrFail();
if (! $item->deactivated_at) {
$item->update(['deactivated_at' => now()]);
}
return back()->with('status', 'Mac deactivated. Rsync Studio will detect this through the licensing API.');
}
public function resend(Request $request, License $license)
{
$this->authorise($request, $license);
$out = LicenseDeliveryOutbox::where('license_id', $license->id)->first();
if (! $out) {
return back()->withErrors(['licence' => 'This licence was issued manually and its full key is not recoverable. Contact support.']);
}
$plain = Crypt::decryptString($out->encrypted_license_key);
Mail::raw("Your Rsync Studio licence key is:\n\n{$plain}\n\nKeep this key private.", fn ($m) => $m->to($request->user()->email)->subject('Your Rsync Studio licence key'));
$out->update(['delivered_at' => now()]);
return back()->with('status', 'Licence key sent to your verified email address.');
}
private function authorise(Request $request, License $license): void
{
abort_unless(strtolower((string) $license->customer_email) === strtolower($request->user()->email), 404);
}
private function professionalPriceFor(License $license): string
{
$paddle = config('rsync-studio.paddle');
$current = (string) $license->external_price_id;
if ($current === (string) ($paddle['personal_annual_price_id'] ?? '')) {
return (string) ($paddle['professional_annual_price_id'] ?? '');
}
return (string) ($paddle['professional_monthly_price_id'] ?? '');
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Support;
use App\Http\Controllers\Controller;
use App\Models\SupportTicket;
use App\Models\User;
use App\Notifications\NewSupportTicket;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
final class SupportController extends Controller
{
public function create(Request $request)
{
return view('support', [
'categories' => [
'technical' => 'Technical support',
'licensing' => 'Licence & activation',
'billing' => 'Billing & subscription',
'feature' => 'Feature request',
'privacy' => 'Privacy / data request',
'general' => 'General enquiry',
],
]);
}
public function store(Request $request): RedirectResponse
{
// Honeypot: bots tend to fill hidden fields. Return success without retaining spam.
if (filled($request->input('company_website'))) {
return back()->with('status', 'Your support request has been received.');
}
$data = $request->validate([
'name' => ['required', 'string', 'max:120'],
'email' => ['required', 'email:rfc', 'max:254'],
'category' => ['required', 'in:technical,licensing,billing,feature,privacy,general'],
'subject' => ['required', 'string', 'max:180'],
'message' => ['required', 'string', 'min:10', 'max:10000'],
'privacy_notice' => ['accepted'],
]);
$ticket = SupportTicket::create([
'user_id' => $request->user()?->id,
'reference' => $this->reference(),
'name' => $data['name'],
'email' => strtolower($data['email']),
'category' => $data['category'],
'subject' => $data['subject'],
'message' => $data['message'],
'privacy_notice_accepted_at' => now(),
]);
$this->notifyTeam($ticket);
Mail::raw(
"We received your Rsync Studio support request.\n\nReference: {$ticket->reference}\nSubject: {$ticket->subject}\n\nWe will reply to this email address. Please keep the reference in any follow-up.",
fn ($mail) => $mail->to($ticket->email)->subject("Rsync Studio support request {$ticket->reference}")
);
return redirect()->route('support')->with('status', "Support request {$ticket->reference} received. A confirmation has been emailed to you.");
}
private function notifyTeam(SupportTicket $ticket): void
{
if (config('rsync-studio.support.notify_admin_users', true)) {
User::query()->where('is_admin', true)->get()->each->notify(new NewSupportTicket($ticket));
}
$emails = collect(config('rsync-studio.support.alert_emails', []))
->filter(fn ($email) => filter_var($email, FILTER_VALIDATE_EMAIL))
->unique()
->values();
if ($emails->isNotEmpty()) {
Notification::route('mail', $emails->all())->notify(new NewSupportTicket($ticket));
}
}
private function reference(): string
{
do {
$reference = 'RS-'.now()->format('ymd').'-'.Str::upper(Str::random(6));
} while (SupportTicket::where('reference', $reference)->exists());
return $reference;
}
}
+8 -2
View File
@@ -1,11 +1,17 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class AdminOnly {
public function handle(Request $request, Closure $next): Response {
final class AdminOnly
{
public function handle(Request $request, Closure $next): Response
{
abort_unless($request->user()?->is_admin === true, 403);
return $next($request);
}
}
+22 -5
View File
@@ -1,4 +1,5 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
@@ -9,11 +10,27 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class License extends Model
{
use HasUuids;
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['license_product_id','license_key_hash','license_key_last4','customer_name','customer_email','edition','activation_limit','max_major_version','expires_at','revoked_at','revoke_reason','payment_provider','external_order_id','external_customer_id'];
protected $casts = ['expires_at'=>'datetime','revoked_at'=>'datetime','activation_limit'=>'integer','max_major_version'=>'integer'];
public function product(): BelongsTo { return $this->belongsTo(LicenseProduct::class, 'license_product_id'); }
public function activations(): HasMany { return $this->hasMany(LicenseActivation::class); }
public function getIsUsableAttribute(): bool { return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture()); }
protected $fillable = ['license_product_id', 'license_key_hash', 'license_key_last4', 'customer_name', 'customer_email', 'edition', 'activation_limit', 'max_major_version', 'expires_at', 'revoked_at', 'revoke_reason', 'payment_provider', 'external_order_id', 'external_customer_id', 'external_subscription_id', 'external_price_id', 'subscription_status', 'current_period_ends_at', 'cancel_scheduled_at'];
protected $casts = ['expires_at' => 'datetime', 'revoked_at' => 'datetime', 'activation_limit' => 'integer', 'max_major_version' => 'integer', 'current_period_ends_at' => 'datetime', 'cancel_scheduled_at' => 'datetime'];
public function product(): BelongsTo
{
return $this->belongsTo(LicenseProduct::class, 'license_product_id');
}
public function activations(): HasMany
{
return $this->hasMany(LicenseActivation::class);
}
public function getIsUsableAttribute(): bool
{
return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture());
}
}
+7 -2
View File
@@ -1,9 +1,14 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseActivation extends Model
{
public $timestamps = false;
protected $fillable = ['license_id','installation_id','installation_name','platform','app_version','activated_at','last_seen_at','deactivated_at'];
protected $casts = ['activated_at'=>'datetime','last_seen_at'=>'datetime','deactivated_at'=>'datetime'];
protected $fillable = ['license_id', 'installation_id', 'installation_name', 'platform', 'app_version', 'activated_at', 'last_seen_at', 'deactivated_at'];
protected $casts = ['activated_at' => 'datetime', 'last_seen_at' => 'datetime', 'deactivated_at' => 'datetime'];
}
+7 -2
View File
@@ -1,9 +1,14 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseDeliveryOutbox extends Model
{
protected $table = 'license_delivery_outbox';
protected $fillable = ['license_id','encrypted_license_key','recipient_email','delivered_at'];
protected $casts = ['delivered_at'=>'datetime'];
protected $fillable = ['license_id', 'encrypted_license_key', 'recipient_email', 'delivered_at'];
protected $casts = ['delivered_at' => 'datetime'];
}
+4 -2
View File
@@ -1,10 +1,12 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseProduct extends Model
{
protected $fillable = ['code','name','edition','activation_limit','max_major_version','payment_provider','external_product_id','external_price_id','active'];
protected $casts = ['active'=>'boolean','activation_limit'=>'integer','max_major_version'=>'integer'];
protected $fillable = ['code', 'name', 'edition', 'activation_limit', 'max_major_version', 'payment_provider', 'external_product_id', 'external_price_id', 'active'];
protected $casts = ['active' => 'boolean', 'activation_limit' => 'integer', 'max_major_version' => 'integer'];
}

Some files were not shown because too many files have changed in this diff Show More