New updates

This commit is contained in:
Mark Gregory
2026-09-09 08:23:43 +01:00
parent 868604b848
commit 5b9c19c52f
107 changed files with 11126 additions and 8 deletions
+97
View File
@@ -0,0 +1,97 @@
# 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
@@ -0,0 +1,125 @@
# 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
@@ -0,0 +1,47 @@
# 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.
+5
View File
@@ -112,3 +112,8 @@ The exact signed bytes are UTF-8: `version + "\n" + build + "\n" + downloadURL`.
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.
+26 -4
View File
@@ -28,6 +28,9 @@
AC1000000000000000000001 /* Views/HelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC2000000000000000000001 /* Views/HelpView.swift */; };
AC1000000000000000000002 /* Resources/Rsync Studio Help.md in Resources */ = {isa = PBXBuildFile; fileRef = AC2000000000000000000002 /* Resources/Rsync Studio Help.md */; };
AD1000000000000000000001 /* Resources/PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AD2000000000000000000001 /* Resources/PrivacyInfo.xcprivacy */; };
AE1000000000000000000001 /* Services/LicensingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2000000000000000000001 /* Services/LicensingManager.swift */; };
AE1000000000000000000002 /* Views/LicenseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2000000000000000000002 /* Views/LicenseView.swift */; };
AE1000000000000000000003 /* Views/LicenseRequiredView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2000000000000000000003 /* Views/LicenseRequiredView.swift */; };
C24299D89D285E985C089C0F /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 344231C687A1F4BDC84AE838 /* SettingsView.swift */; };
CC02106B9AF77855935487F6 /* Services/RsyncRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F173223A0A15A648115C2B6 /* Services/RsyncRunner.swift */; };
D2009B9542E292B66BB8A0D4 /* Views/Components/StatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5919F7BA545585E4369602 /* Views/Components/StatusBadge.swift */; };
@@ -56,6 +59,9 @@
AC2000000000000000000001 /* Views/HelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/HelpView.swift; sourceTree = "<group>"; };
AC2000000000000000000002 /* Resources/Rsync Studio Help.md */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Resources/Rsync Studio Help.md"; sourceTree = "<group>"; };
AD2000000000000000000001 /* Resources/PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = Resources/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
AE2000000000000000000001 /* Services/LicensingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/LicensingManager.swift; sourceTree = "<group>"; };
AE2000000000000000000002 /* Views/LicenseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/LicenseView.swift; sourceTree = "<group>"; };
AE2000000000000000000003 /* Views/LicenseRequiredView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/LicenseRequiredView.swift; sourceTree = "<group>"; };
BCA4F1E7C15E2FC1DCC85FEE /* Views/Jobs/JobEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Jobs/JobEditorView.swift; sourceTree = "<group>"; };
BE0FFB276373DDA4C5A8E419 /* Services/RsyncLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Services/RsyncLocator.swift; sourceTree = "<group>"; };
C7A5B87A4971CCE37EF1B979 /* Models/SyncRun.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/SyncRun.swift; sourceTree = "<group>"; };
@@ -100,6 +106,9 @@
BE0FFB276373DDA4C5A8E419 /* Services/RsyncLocator.swift */,
AB2000000000000000000002 /* Services/FileWatchService.swift */,
AB2000000000000000000003 /* Services/BackgroundSyncCoordinator.swift */,
AE2000000000000000000001 /* Services/LicensingManager.swift */,
AE2000000000000000000002 /* Views/LicenseView.swift */,
AE2000000000000000000003 /* Views/LicenseRequiredView.swift */,
8999F614BA12AF14ACCB5E3D /* Utilities/FilePicker.swift */,
CA5919F7BA545585E4369602 /* Views/Components/StatusBadge.swift */,
58B5F691C6DE4B3B7224BE4B /* Views/Components/PathField.swift */,
@@ -210,6 +219,9 @@
87F3C7BF9787C5EF1DFC58CE /* Services/RsyncLocator.swift in Sources */,
AB1000000000000000000002 /* Services/FileWatchService.swift in Sources */,
AB1000000000000000000003 /* Services/BackgroundSyncCoordinator.swift in Sources */,
AE1000000000000000000001 /* Services/LicensingManager.swift in Sources */,
AE1000000000000000000002 /* Views/LicenseView.swift in Sources */,
AE1000000000000000000003 /* Views/LicenseRequiredView.swift in Sources */,
0BBFC488F7DFF17E315AEEF2 /* Utilities/FilePicker.swift in Sources */,
D2009B9542E292B66BB8A0D4 /* Views/Components/StatusBadge.swift in Sources */,
3D5AB53FDA192FBBA66D2CEF /* Views/Components/PathField.swift in Sources */,
@@ -238,17 +250,22 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 39R6U3437D;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_KEY_CFBundleDisplayName = "Rsync Studio";
INFOPLIST_KEY_CFBundleIconName = AppIcon;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_RsyncStudioLicenseAPIBaseURL = "https://rsync.vm/api/";
INFOPLIST_KEY_RsyncStudioLicensePublicKey = "8/rlCrS5mVEi4rGNXkw0Z22XkRpSH5+jPnD4f0GyV2k=";
INFOPLIST_KEY_RsyncStudioPortalURL = "";
INFOPLIST_KEY_RsyncStudioPurchaseURL = "";
INFOPLIST_KEY_RsyncStudioSupportURL = "";
INFOPLIST_KEY_RsyncStudioUpdateManifestURL = "";
INFOPLIST_KEY_RsyncStudioUpdatePublicKey = "";
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.RsyncStudio;
INFOPLIST_FILE = "Rsync Studio/Info.plist";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
@@ -346,17 +363,22 @@
COPY_PHASE_STRIP = YES;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 39R6U3437D;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_KEY_CFBundleDisplayName = "Rsync Studio";
INFOPLIST_KEY_CFBundleIconName = AppIcon;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_RsyncStudioLicenseAPIBaseURL = "https://rsync.vm/api/";
INFOPLIST_KEY_RsyncStudioLicensePublicKey = "8/rlCrS5mVEi4rGNXkw0Z22XkRpSH5+jPnD4f0GyV2k=";
INFOPLIST_KEY_RsyncStudioPortalURL = "";
INFOPLIST_KEY_RsyncStudioPurchaseURL = "";
INFOPLIST_KEY_RsyncStudioSupportURL = "";
INFOPLIST_KEY_RsyncStudioUpdateManifestURL = "";
INFOPLIST_KEY_RsyncStudioUpdatePublicKey = "";
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = net.absdev.RsyncStudio;
INFOPLIST_FILE = "Rsync Studio/Info.plist";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
+13 -2
View File
@@ -4,6 +4,7 @@ import SwiftData
struct ContentView: View {
let startupStorageError: String?
@EnvironmentObject private var automation: BackgroundSyncCoordinator
@EnvironmentObject private var licensing: LicensingManager
@Environment(\.openWindow) private var openWindow
@State private var selectedJob: SyncJob?
@State private var section: AppSection = .jobs
@@ -17,8 +18,11 @@ struct ContentView: View {
var body: some View {
ZStack {
mainContent
.opacity(showingSplash ? 0 : 1)
Group {
if licensing.canUseApp { mainContent }
else { LicenseRequiredView() }
}
.opacity(showingSplash ? 0 : 1)
if showingSplash {
SplashView()
@@ -38,6 +42,13 @@ struct ContentView: View {
try? await Task.sleep(for: .seconds(1.0))
withAnimation(.easeOut(duration: 0.28)) { showingSplash = false }
}
.task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(900))
if Task.isCancelled { return }
licensing.refresh()
}
}
}
private var mainContent: some View {
+29
View File
@@ -0,0 +1,29 @@
<?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>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>RsyncStudioLicenseAPIBaseURL</key>
<string>https://rsync.vm/api/</string>
<key>RsyncStudioLicensePublicKey</key>
<string>8/rlCrS5mVEi4rGNXkw0Z22XkRpSH5+jPnD4f0GyV2k=</string>
</dict>
</plist>
+11 -1
View File
@@ -8,6 +8,7 @@ struct RsyncStudioApp: App {
let startupStorageError: String?
@StateObject private var runner: RsyncRunner
@StateObject private var automation: BackgroundSyncCoordinator
@StateObject private var licensing: LicensingManager
init() {
let schema = Schema([
@@ -16,6 +17,7 @@ struct RsyncStudioApp: App {
])
let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
KeychainStore.delete("defaultSSHPassword")
_licensing = StateObject(wrappedValue: LicensingManager())
do {
let container = try ModelContainer(for: schema, configurations: [configuration])
@@ -45,7 +47,14 @@ struct RsyncStudioApp: App {
.frame(minWidth: 1040, minHeight: 680)
.environmentObject(runner)
.environmentObject(automation)
.onAppear { automation.start() }
.environmentObject(licensing)
.onAppear {
licensing.refresh()
if licensing.canUseApp { automation.start() } else { automation.stop() }
}
.onChange(of: licensing.canUseApp) { _, allowed in
if allowed { automation.start() } else { automation.stop() }
}
}
.modelContainer(sharedModelContainer)
.commands {
@@ -60,6 +69,7 @@ struct RsyncStudioApp: App {
Settings {
SettingsView()
.environmentObject(automation)
.environmentObject(licensing)
}
}
}
@@ -0,0 +1,329 @@
import Combine
import Security
import Foundation
import CryptoKit
@MainActor
final class LicensingManager: ObservableObject {
enum State: Equatable {
case licensed(LicensePayload)
case trial(daysRemaining: Int, expiresAt: Date)
case expired
case invalid(String)
var canUseApp: Bool {
switch self {
case .licensed, .trial: true
case .expired, .invalid: false
}
}
}
struct LicensePayload: Codable, Equatable {
let licenseID: String
let customerName: String?
let customerEmail: String?
let edition: String
let installationID: String
let issuedAt: Date
let expiresAt: Date?
let maxMajorVersion: Int?
var displayOwner: String {
if let customerName, !customerName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return customerName }
if let customerEmail, !customerEmail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return customerEmail }
return "Licensed User"
}
}
struct ActivationResponse: Decodable {
let licenseToken: String
}
private struct ActivationRequest: Encodable {
let licenseKey: String
let installationID: String
let appVersion: String
let platform: String
}
private struct DeactivationRequest: Encodable {
let licenseToken: String
let installationID: String
}
enum LicensingError: LocalizedError {
case configuration(String)
case invalidLicense(String)
case server(String)
var errorDescription: String? {
switch self {
case .configuration(let message), .invalidLicense(let message), .server(let message): message
}
}
}
static let trialLengthDays = 30
@Published private(set) var state: State = .trial(daysRemaining: trialLengthDays, expiresAt: Date().addingTimeInterval(TimeInterval(trialLengthDays * 86_400)))
@Published private(set) var installationID = ""
@Published private(set) var isWorking = false
@Published var lastMessage: String?
private let calendar = Calendar(identifier: .gregorian)
private let firstLaunchAccount = "licensing.trial.firstLaunch"
private let lastSeenAccount = "licensing.trial.lastSeen"
private let installationAccount = "licensing.installationID"
private let tokenAccount = "licensing.token"
init() {
refresh()
}
var canUseApp: Bool { state.canUseApp }
var statusTitle: String {
switch state {
case .licensed: "Licensed"
case .trial: "Free Trial"
case .expired: "Trial Expired"
case .invalid: "Licence Problem"
}
}
var statusDetail: String {
switch state {
case .licensed(let payload):
let expiry = payload.expiresAt.map { " · expires \($0.formatted(date: .abbreviated, time: .omitted))" } ?? " · perpetual"
return "\(payload.edition) · \(payload.displayOwner)\(expiry)"
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."
case .invalid(let message):
return message
}
}
func refresh(now: Date = Date()) {
installationID = loadOrCreateInstallationID()
if let token = storedLicenseToken, !token.isEmpty {
do {
let payload = try verify(token: token, now: now)
state = .licensed(payload)
recordLastSeen(now)
return
} catch {
state = .invalid(error.localizedDescription)
recordLastSeen(now)
return
}
}
state = trialState(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.") }
let payload = try verify(token: clean, now: Date())
guard KeychainStore.set(clean, for: tokenAccount) == errSecSuccess else {
throw LicensingError.invalidLicense("Rsync Studio could not save the licence securely in Keychain.")
}
state = .licensed(payload)
lastMessage = "Licence installed successfully."
}
func activate(licenseKey: String) async throws {
let key = licenseKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { throw LicensingError.invalidLicense("Enter your licence key.") }
guard let baseURL = configuredAPIBaseURL else {
throw LicensingError.configuration("Online activation is not configured in this build. A signed licence token can still be installed manually.")
}
isWorking = true
defer { isWorking = false }
let requestBody = ActivationRequest(
licenseKey: key,
installationID: installationID,
appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown",
platform: "macOS"
)
let response: ActivationResponse = try await post(path: "v1/activate", body: requestBody, baseURL: baseURL)
try installSignedToken(response.licenseToken)
lastMessage = "Rsync Studio has been activated on this Mac."
}
func deactivate() async throws {
guard let token = storedLicenseToken, !token.isEmpty else {
KeychainStore.delete(tokenAccount)
refresh()
return
}
isWorking = true
defer { isWorking = false }
if let baseURL = configuredAPIBaseURL {
let body = DeactivationRequest(licenseToken: token, installationID: installationID)
let _: EmptyResponse = try await post(path: "v1/deactivate", body: body, baseURL: baseURL)
}
guard KeychainStore.delete(tokenAccount) == errSecSuccess else {
throw LicensingError.invalidLicense("Rsync Studio could not remove the local licence from Keychain.")
}
refresh()
lastMessage = "Licence removed from this Mac."
}
func removeInvalidLocalLicence() {
KeychainStore.delete(tokenAccount)
refresh()
lastMessage = "Invalid local licence removed."
}
private var storedLicenseToken: String? {
let token = KeychainStore.string(for: tokenAccount)
return token.isEmpty ? nil : token
}
private var configuredPublicKey: String? {
guard let value = Bundle.main.infoDictionary?["RsyncStudioLicensePublicKey"] as? String else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private var configuredAPIBaseURL: URL? {
guard let value = Bundle.main.infoDictionary?["RsyncStudioLicenseAPIBaseURL"] as? String else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: trimmed), url.scheme?.lowercased() == "https" else { return nil }
return url
}
private func loadOrCreateInstallationID() -> String {
let existing = KeychainStore.string(for: installationAccount)
if UUID(uuidString: existing) != nil { return existing }
let value = UUID().uuidString.lowercased()
_ = KeychainStore.set(value, for: installationAccount)
return value
}
private func trialState(now: Date) -> State {
let firstLaunch: Date
let storedFirst = KeychainStore.string(for: firstLaunchAccount)
if let parsed = Self.parseDate(storedFirst) {
firstLaunch = parsed
} else {
firstLaunch = now
_ = KeychainStore.set(Self.formatDate(now), for: firstLaunchAccount)
}
if let lastSeen = Self.parseDate(KeychainStore.string(for: lastSeenAccount)), now < lastSeen.addingTimeInterval(-300) {
recordLastSeen(lastSeen)
return .invalid("The system clock appears to have moved backwards. Correct the date and time, then reopen Rsync Studio.")
}
recordLastSeen(now)
guard let expiresAt = calendar.date(byAdding: .day, value: Self.trialLengthDays, to: firstLaunch) else { return .expired }
if now >= expiresAt { return .expired }
let startOfToday = calendar.startOfDay(for: now)
let expiryDay = calendar.startOfDay(for: expiresAt)
let days = max(1, calendar.dateComponents([.day], from: startOfToday, to: expiryDay).day ?? 1)
return .trial(daysRemaining: days, expiresAt: expiresAt)
}
private func recordLastSeen(_ date: Date) {
let previous = Self.parseDate(KeychainStore.string(for: lastSeenAccount))
if previous == nil || date > previous! {
_ = KeychainStore.set(Self.formatDate(date), for: lastSeenAccount)
}
}
private func verify(token: String, now: Date) throws -> LicensePayload {
guard let publicKeyText = configuredPublicKey else {
throw LicensingError.configuration("Licence verification is not configured in this build. Add RsyncStudioLicensePublicKey to the signed app configuration.")
}
let parts = token.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 3, parts[0] == "RS3" else {
throw LicensingError.invalidLicense("The licence token format is invalid.")
}
guard let payloadData = Self.base64URLDecode(String(parts[1])),
let signature = Self.base64URLDecode(String(parts[2])),
let keyData = Data(base64Encoded: publicKeyText) ?? Self.base64URLDecode(publicKeyText) else {
throw LicensingError.invalidLicense("The licence token or embedded public key is not valid Base64.")
}
let publicKey: Curve25519.Signing.PublicKey
do { publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: keyData) }
catch { throw LicensingError.configuration("The embedded Rsync Studio licence public key is invalid.") }
guard publicKey.isValidSignature(signature, for: payloadData) else {
throw LicensingError.invalidLicense("The licence signature is invalid.")
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let payload: LicensePayload
do { payload = try decoder.decode(LicensePayload.self, from: payloadData) }
catch { throw LicensingError.invalidLicense("The signed licence payload is invalid.") }
guard payload.installationID.caseInsensitiveCompare(installationID) == .orderedSame else {
throw LicensingError.invalidLicense("This licence was activated for a different Rsync Studio installation.")
}
if let expiresAt = payload.expiresAt, now >= expiresAt {
throw LicensingError.invalidLicense("This licence expired on \(expiresAt.formatted(date: .abbreviated, time: .omitted)).")
}
if let maxMajor = payload.maxMajorVersion,
let currentMajor = Self.currentMajorVersion,
currentMajor > maxMajor {
throw LicensingError.invalidLicense("This licence covers Rsync Studio through major version \(maxMajor).")
}
return payload
}
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.") }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = 20
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try JSONEncoder().encode(body)
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.") }
guard (200..<300).contains(http.statusCode) else {
let serverMessage = (try? JSONDecoder().decode(ServerError.self, from: data).message) ?? "Activation server returned HTTP \(http.statusCode)."
throw LicensingError.server(serverMessage)
}
if Response.self == EmptyResponse.self, data.isEmpty {
return EmptyResponse() as! Response
}
do { return try JSONDecoder().decode(Response.self, from: data) }
catch { throw LicensingError.server("The licence server returned malformed data.") }
}
private struct ServerError: Decodable { let message: String }
private struct EmptyResponse: Codable { init() {} }
private static var currentMajorVersion: Int? {
guard let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { return nil }
return Int(version.split(separator: ".").first ?? "")
}
private static func formatDate(_ date: Date) -> String { ISO8601DateFormatter().string(from: date) }
private static func parseDate(_ value: String) -> Date? { value.isEmpty ? nil : ISO8601DateFormatter().date(from: value) }
private static func base64URLDecode(_ value: String) -> Data? {
var text = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
let remainder = text.count % 4
if remainder != 0 { text += String(repeating: "=", count: 4 - remainder) }
return Data(base64Encoded: text)
}
}
+18 -1
View File
@@ -10,6 +10,7 @@ struct SettingsView: View {
@Query(sort: \SyncJob.name) private var jobs: [SyncJob]
@Query(sort: \SyncRun.startedAt, order: .reverse) private var runs: [SyncRun]
@EnvironmentObject private var automation: BackgroundSyncCoordinator
@EnvironmentObject private var licensing: LicensingManager
@AppStorage("defaultSSHUsername") private var defaultSSHUsername = NSUserName()
@AppStorage("defaultSSHKeyPath") private var defaultSSHKeyPath = ""
@AppStorage("defaultSSHPort") private var defaultSSHPort = 22
@@ -120,6 +121,9 @@ struct SettingsView: View {
}
Section("Support & Updates") {
Button("Buy Rsync Studio…") { openCommercialURL(infoKey: "RsyncStudioPurchaseURL") }
Button("Manage Licence Online…") { openCommercialURL(infoKey: "RsyncStudioPortalURL") }
Button("Support…") { openCommercialURL(infoKey: "RsyncStudioSupportURL") }
Button("Export Diagnostics…") { exportDiagnostics() }
Text("Diagnostics contain app/macOS/rsync versions, redacted job configuration and recent failures. Private keys and credentials are never exported.")
.font(.caption)
@@ -139,8 +143,12 @@ struct SettingsView: View {
}
.formStyle(.grouped)
.tabItem { Label("General", systemImage: "gearshape") }
LicenseView()
.environmentObject(licensing)
.tabItem { Label("Licence", systemImage: "key") }
}
.frame(width: 650, height: 455)
.frame(width: 680, height: 500)
.onAppear {
KeychainStore.delete("defaultSSHPassword")
launchAtLogin = SMAppService.mainApp.status == .enabled
@@ -173,6 +181,15 @@ struct SettingsView: View {
}
}
private func openCommercialURL(infoKey: String) {
guard let raw = Bundle.main.object(forInfoDictionaryKey: infoKey) as? String,
let url = URL(string: raw), url.scheme == "https" else {
updateStatus = "This commercial service URL has not been configured for this build."
return
}
NSWorkspace.shared.open(url)
}
private func checkForUpdates() {
isCheckingUpdates = true
Task {
@@ -0,0 +1,36 @@
import SwiftUI
struct LicenseRequiredView: View {
@EnvironmentObject private var licensing: LicensingManager
@Environment(\.openSettings) private var openSettings
var body: some View {
VStack(spacing: 20) {
Image(systemName: "lock.shield.fill")
.font(.system(size: 54, weight: .medium))
.foregroundStyle(Color.accentColor)
VStack(spacing: 7) {
Text(licensing.statusTitle)
.font(.title2.bold())
Text(licensing.statusDetail)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
}
Button("Open Licence Settings") { openSettings() }
.buttonStyle(.borderedProminent)
.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)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
}
.padding(48)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor))
}
}
+133
View File
@@ -0,0 +1,133 @@
import SwiftUI
struct LicenseView: View {
@EnvironmentObject private var licensing: LicensingManager
@State private var licenceKey = ""
@State private var signedToken = ""
@State private var errorMessage: String?
@State private var showingTokenEntry = false
var body: some View {
Form {
Section("Licence Status") {
HStack(spacing: 12) {
Image(systemName: statusIcon)
.font(.title2)
.foregroundStyle(licensing.canUseApp ? Color.green : Color.orange)
VStack(alignment: .leading, spacing: 3) {
Text(licensing.statusTitle).font(.headline)
Text(licensing.statusDetail).font(.caption).foregroundStyle(.secondary)
}
Spacer()
}
LabeledContent("Installation ID", value: licensing.installationID)
.textSelection(.enabled)
Text("The installation ID is a random UUID stored in macOS Keychain. Rsync Studio does not use your Mac serial number or other hardware identifiers.")
.font(.caption)
.foregroundStyle(.secondary)
}
if case .licensed(let payload) = licensing.state {
Section("Current Licence") {
LabeledContent("Edition", value: payload.edition)
LabeledContent("Licence ID", value: payload.licenseID)
LabeledContent("Licensed to", value: payload.displayOwner)
if let expiresAt = payload.expiresAt {
LabeledContent("Expires", value: expiresAt.formatted(date: .long, time: .omitted))
} else {
LabeledContent("Term", value: "Perpetual")
}
Button("Deactivate This Mac…", role: .destructive) {
Task { await deactivate() }
}
.disabled(licensing.isWorking)
}
} else {
Section("Activate Rsync Studio") {
TextField("Licence key", text: $licenceKey, prompt: Text("XXXX-XXXX-XXXX-XXXX"))
.textContentType(.password)
HStack {
Button {
Task { await activate() }
} label: {
if licensing.isWorking { ProgressView().controlSize(.small) }
else { Label("Activate", systemImage: "checkmark.seal") }
}
.buttonStyle(.borderedProminent)
.disabled(licenceKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || licensing.isWorking)
Button(showingTokenEntry ? "Hide Signed Token" : "Install Signed Token…") {
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.")
.font(.caption)
.foregroundStyle(.secondary)
}
if showingTokenEntry {
Section("Signed Offline Licence") {
TextEditor(text: $signedToken)
.font(.system(.caption, design: .monospaced))
.frame(minHeight: 86)
Button("Install Signed Licence") { installToken() }
.disabled(signedToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
if case .invalid = licensing.state {
Section("Recovery") {
Button("Remove Invalid Local Licence") { licensing.removeInvalidLocalLicence() }
Text("This removes only the stored local licence token. Trial dates and the installation identity are not reset.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
Section("Privacy") {
Text("Licence activation sends only the licence key, random installation ID, app version and platform to the licensing service. Sync paths, filenames, SSH hosts, credentials, job history and file contents are never included.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.alert("Rsync Studio Licence", isPresented: Binding(
get: { errorMessage != nil || licensing.lastMessage != nil },
set: { if !$0 { errorMessage = nil; licensing.lastMessage = nil } }
)) {
Button("OK", role: .cancel) { errorMessage = nil; licensing.lastMessage = nil }
} message: {
Text(errorMessage ?? licensing.lastMessage ?? "")
}
}
private var statusIcon: String {
switch licensing.state {
case .licensed: "checkmark.seal.fill"
case .trial: "clock.badge.checkmark"
case .expired: "clock.badge.exclamationmark"
case .invalid: "exclamationmark.shield.fill"
}
}
private func activate() async {
do {
try await licensing.activate(licenseKey: licenceKey)
licenceKey = ""
} catch { errorMessage = error.localizedDescription }
}
private func deactivate() async {
do { try await licensing.deactivate() }
catch { errorMessage = error.localizedDescription }
}
private func installToken() {
do {
try licensing.installSignedToken(signedToken)
signedToken = ""
showingTokenEntry = false
} catch { errorMessage = error.localizedDescription }
}
}
+28
View File
@@ -0,0 +1,28 @@
#!/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
@@ -0,0 +1,71 @@
#!/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)
+4
View File
@@ -16,6 +16,10 @@ 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"
+9
View File
@@ -23,6 +23,15 @@ grep -q 'hasBalancedShellSyntax' "$ROOT/Rsync Studio/Services/RsyncCommandBuilde
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:"
+19
View File
@@ -0,0 +1,19 @@
#!/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
@@ -0,0 +1,12 @@
#!/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.'
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 4
+77
View File
@@ -0,0 +1,77 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Rsync Studio commercial/licensing
RSYNC_STUDIO_LICENSE_PRIVATE_KEY_PATH=
RSYNC_STUDIO_LICENSE_PUBLIC_KEY=
RSYNC_STUDIO_DEFAULT_EDITION=Professional
RSYNC_STUDIO_DEFAULT_MAX_MAJOR_VERSION=1
RSYNC_STUDIO_PURCHASE_URL=
RSYNC_STUDIO_PORTAL_URL=
RSYNC_STUDIO_SUPPORT_URL=
PADDLE_WEBHOOK_SECRET=
PADDLE_WEBHOOK_TOLERANCE=300
LEMON_SQUEEZY_WEBHOOK_SECRET=
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
+27
View File
@@ -0,0 +1,27 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.codex
/.cursor/
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
+2
View File
@@ -0,0 +1,2 @@
ignore-scripts=true
audit=true
+47
View File
@@ -0,0 +1,47 @@
<laravel-boost-guidelines>
# Laravel Application
This repository contains a Laravel application. Complete the following setup before working on the user's request.
## Prerequisites
Verify that PHP and Composer are available:
```sh
php -v
composer -V
```
If either command is unavailable, detect the user's operating system and install the prerequisites with the appropriate command:
macOS:
```sh
/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.5)"
```
Windows PowerShell:
```powershell
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.5'))
```
Linux:
```sh
/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)"
```
After installation, ask the user to restart their terminal. If the agent needs the restarted shell to continue, ask the user to reopen their terminal and rerun their original prompt.
## Agent Setup
Install Laravel Boost from the application root before making application changes:
```sh
composer require laravel/boost --dev
php artisan boost:install
```
Boost replaces these bootstrap instructions with guidelines tailored to the application. After installation, read `AGENTS.md` again and continue with the user's original request using the generated guidelines.
</laravel-boost-guidelines>
+47
View File
@@ -0,0 +1,47 @@
<laravel-boost-guidelines>
# Laravel Application
This repository contains a Laravel application. Complete the following setup before working on the user's request.
## Prerequisites
Verify that PHP and Composer are available:
```sh
php -v
composer -V
```
If either command is unavailable, detect the user's operating system and install the prerequisites with the appropriate command:
macOS:
```sh
/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.5)"
```
Windows PowerShell:
```powershell
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.5'))
```
Linux:
```sh
/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)"
```
After installation, ask the user to restart their terminal. If the agent needs the restarted shell to continue, ask the user to reopen their terminal and rerun their original prompt.
## Agent Setup
Install Laravel Boost from the application root before making application changes:
```sh
composer require laravel/boost --dev
php artisan boost:install
```
Boost replaces these bootstrap instructions with guidelines tailored to the application. After installation, read `AGENTS.md` again and continue with the user's original request using the generated guidelines.
</laravel-boost-guidelines>
+5
View File
@@ -0,0 +1,5 @@
# Rsync Studio Licensing Server
Production licensing backend for Rsync Studio. This directory is an overlay for a fresh Laravel 13 application and is intentionally kept separate from the macOS target.
See `../Docs/COMMERCIAL_RELEASE_STAGE4.md` for deployment, key management and payment-provider setup.
@@ -0,0 +1,10 @@
<?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; }
}
@@ -0,0 +1,10 @@
<?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; }
}
@@ -0,0 +1,9 @@
<?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; }
}
@@ -0,0 +1,10 @@
<?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; }
}
@@ -0,0 +1,14 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\LicenseProduct;
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.');}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\LicenseActivation;
use App\Services\Licensing\LicenseKey;
use App\Services\Licensing\LicenseSigner;
use App\Services\Licensing\LicenseTokenVerifier;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
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'],
]);
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);
$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()]);
}
return response()->json(['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)[]);
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\LicenseWebhookEvent;
use App\Services\Payments\LemonSqueezyWebhookVerifier;
use App\Services\Payments\PaddleWebhookVerifier;
use App\Services\Payments\PurchaseProvisioner;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class PaymentWebhookController extends Controller
{
public function __invoke(string $provider, Request $request, PurchaseProvisioner $provisioner): 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);
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; }
});
} catch (\Throwable $e) { report($e); return response()->json(['message'=>'Webhook could not be processed.'],500); }
return response()->json(['ok'=>true]);
}
}
@@ -0,0 +1,14 @@
<?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'); }
}
@@ -0,0 +1,10 @@
<?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.'); }
}
@@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
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)]); }
}
@@ -0,0 +1,15 @@
<?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');
}
}
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
// Application controller base class.
}
@@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\LicenseDeliveryOutbox;
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.'); }
}
@@ -0,0 +1,11 @@
<?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 {
abort_unless($request->user()?->is_admin === true, 403);
return $next($request);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
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()); }
}
@@ -0,0 +1,9 @@
<?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'];
}
@@ -0,0 +1,9 @@
<?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'];
}
@@ -0,0 +1,10 @@
<?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'];
}
@@ -0,0 +1,8 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseWebhookEvent extends Model
{
protected $fillable = ['provider','external_event_id','event_type','payload_sha256','payload','processed_at','processing_error'];
protected $casts = ['payload'=>'array','processed_at'=>'datetime'];
}
@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable implements MustVerifyEmail
{
use HasFactory, Notifiable;
protected $fillable = ['name','email','password','is_admin'];
protected $hidden = ['password','remember_token'];
protected function casts(): array { return ['email_verified_at'=>'datetime','password'=>'hashed','is_admin'=>'boolean']; }
}
@@ -0,0 +1,26 @@
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
final class RsyncStudioLicensingServiceProvider extends ServiceProvider
{
public function boot(): void
{
RateLimiter::for('license-activation', function (Request $request): array {
return [
Limit::perMinute(12)->by($request->ip()),
Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))),
];
});
RateLimiter::for('login', fn (Request $request) => Limit::perMinute(5)
->by(strtolower((string) $request->input('email')).'|'.$request->ip()));
RateLimiter::for('registration', fn (Request $request) => Limit::perHour(5)->by($request->ip()));
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Services\Licensing;
use App\Models\License;
use App\Models\LicenseProduct;
use App\Models\LicenseDeliveryOutbox;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Crypt;
final class LicenseIssuer
{
public function issue(LicenseProduct $product, ?string $name, ?string $email, ?string $provider = null, ?string $orderID = null, ?string $customerID = null, bool $queueDelivery = false): array
{
return DB::transaction(function () use ($product,$name,$email,$provider,$orderID,$customerID,$queueDelivery) {
if ($provider && $orderID) {
$existing = License::where('payment_provider',$provider)->where('external_order_id',$orderID)->first();
if ($existing) return [$existing, null];
}
$plain = LicenseKey::generate();
$license = License::create([
'license_product_id'=>$product->id,
'license_key_hash'=>LicenseKey::hash($plain),
'license_key_last4'=>substr(LicenseKey::normalize($plain), -4),
'customer_name'=>$name,
'customer_email'=>$email ? strtolower(trim($email)) : null,
'edition'=>$product->edition,
'activation_limit'=>$product->activation_limit,
'max_major_version'=>$product->max_major_version,
'payment_provider'=>$provider,
'external_order_id'=>$orderID,
'external_customer_id'=>$customerID,
]);
if ($queueDelivery) {
LicenseDeliveryOutbox::updateOrCreate(
['license_id'=>$license->id],
['encrypted_license_key'=>Crypt::encryptString($plain),'recipient_email'=>$license->customer_email,'delivered_at'=>null]
);
}
return [$license, $plain];
});
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Services\Licensing;
final class LicenseKey
{
public static function generate(): string
{
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$raw = '';
for ($i=0; $i<20; $i++) $raw .= $alphabet[random_int(0, strlen($alphabet)-1)];
return implode('-', str_split($raw, 5));
}
public static function normalize(string $value): string
{
return strtoupper(preg_replace('/[^A-Z0-9]/i', '', trim($value)) ?? '');
}
public static function hash(string $value): string
{
return hash('sha256', self::normalize($value));
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Services\Licensing;
use App\Models\License;
use Carbon\CarbonInterface;
use RuntimeException;
final class LicenseSigner
{
public function sign(License $license, string $installationID, ?CarbonInterface $issuedAt = null): string
{
$seed = $this->privateSeed();
$pair = sodium_crypto_sign_seed_keypair($seed);
$secret = sodium_crypto_sign_secretkey($pair);
$payload = [
'customerEmail' => $license->customer_email,
'customerName' => $license->customer_name,
'edition' => $license->edition,
'expiresAt' => $license->expires_at?->utc()->format('Y-m-d\\TH:i:s\\Z'),
'installationID' => strtolower($installationID),
'issuedAt' => ($issuedAt ?? now())->utc()->format('Y-m-d\\TH:i:s\\Z'),
'licenseID' => strtolower((string) $license->id),
'maxMajorVersion' => $license->max_major_version,
];
ksort($payload, SORT_STRING);
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$sig = sodium_crypto_sign_detached($json, $secret);
return 'RS3.'.self::b64url($json).'.'.self::b64url($sig);
}
public function publicKeyBase64(): string
{
$pair = sodium_crypto_sign_seed_keypair($this->privateSeed());
return base64_encode(sodium_crypto_sign_publickey($pair));
}
private function privateSeed(): string
{
$path = config('rsync-studio.private_key_path');
if (!$path || !is_file($path)) throw new RuntimeException('RSYNC_STUDIO_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
$decoded = base64_decode(trim((string) file_get_contents($path)), true);
if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) throw new RuntimeException('The Rsync Studio private key must be a Base64-encoded 32-byte Ed25519 seed.');
return $decoded;
}
private static function b64url(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Services\Licensing;
use RuntimeException;
final class LicenseTokenVerifier
{
public function verify(string $token): array
{
$parts = explode('.', trim($token));
if (count($parts) !== 3 || $parts[0] !== 'RS3') throw new RuntimeException('Invalid licence token.');
$payload = $this->decode($parts[1]);
$signature = $this->decode($parts[2]);
$public = base64_decode((string) config('rsync-studio.public_key'), true);
if ($public === false || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) throw new RuntimeException('Server licence public key is invalid.');
if (!sodium_crypto_sign_verify_detached($signature, $payload, $public)) throw new RuntimeException('Licence token signature is invalid.');
$decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.');
}
private function decode(string $value): string
{
$value = strtr($value, '-_', '+/');
$value .= str_repeat('=', (4 - strlen($value) % 4) % 4);
$out = base64_decode($value, true);
if ($out === false) throw new RuntimeException('Invalid Base64URL value.');
return $out;
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Services\Payments;
use Illuminate\Http\Request;
use RuntimeException;
final class LemonSqueezyWebhookVerifier implements WebhookVerifier
{
public function verify(Request $request, string $rawBody): void
{
$secret = (string) config('rsync-studio.lemon_squeezy.webhook_secret');
$signature = (string) $request->header('X-Signature');
if ($secret === '' || $signature === '') throw new RuntimeException('Lemon Squeezy webhook verification is not configured.');
$expected = hash_hmac('sha256',$rawBody,$secret);
if (!hash_equals($expected,$signature)) throw new RuntimeException('Lemon Squeezy webhook signature is invalid.');
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Services\Payments;
use Illuminate\Http\Request;
use RuntimeException;
final class PaddleWebhookVerifier implements WebhookVerifier
{
public function verify(Request $request, string $rawBody): void
{
$secret = (string) config('rsync-studio.paddle.webhook_secret');
$header = (string) $request->header('Paddle-Signature');
if ($secret === '' || $header === '') throw new RuntimeException('Paddle webhook verification is not configured.');
$parts = [];
foreach (explode(';', $header) as $part) { [$k,$v] = array_pad(explode('=',trim($part),2),2,null); if ($k && $v) $parts[$k][]=$v; }
$ts = isset($parts['ts'][0]) ? (int)$parts['ts'][0] : 0;
$signatures = $parts['h1'] ?? [];
$tolerance = max(1,(int)config('rsync-studio.paddle.timestamp_tolerance',300));
if (!$ts || abs(time()-$ts)>$tolerance) throw new RuntimeException('Paddle webhook timestamp is outside the allowed tolerance.');
$expected = hash_hmac('sha256', $ts.':'.$rawBody, $secret);
foreach ($signatures as $signature) if (hash_equals($expected,$signature)) return;
throw new RuntimeException('Paddle webhook signature is invalid.');
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Services\Payments;
use App\Models\License;
use App\Models\LicenseProduct;
use App\Services\Licensing\LicenseIssuer;
use RuntimeException;
final class PurchaseProvisioner
{
public function __construct(private LicenseIssuer $issuer) {}
public function provision(string $provider, array $event): void
{
$normalized = match ($provider) {
'paddle' => $this->fromPaddle($event),
'lemon-squeezy' => $this->fromLemonSqueezy($event),
default => throw new RuntimeException('Unsupported payment provider.'),
};
if ($normalized['action'] === 'ignore') return;
if ($normalized['action'] === 'revoke') {
License::where('payment_provider',$provider)->where('external_order_id',$normalized['orderID'])->whereNull('revoked_at')->update(['revoked_at'=>now(),'revoke_reason'=>'Payment refunded or reversed']);
return;
}
$product = LicenseProduct::where('payment_provider',$provider)->where(function($q) use($normalized){ $q->where('external_price_id',$normalized['priceID'])->orWhere('external_product_id',$normalized['productID']); })->where('active',true)->first();
if (!$product) throw new RuntimeException('No active licence product maps to this payment item.');
$this->issuer->issue($product,$normalized['name'],$normalized['email'],$provider,$normalized['orderID'],$normalized['customerID'], true);
}
private function fromPaddle(array $e): array
{
$type=(string)($e['event_type']??''); $d=$e['data']??[];
if (in_array($type,['transaction.refunded','transaction.canceled'],true)) return ['action'=>'revoke','orderID'=>(string)($d['id']??''),'priceID'=>'','productID'=>'','name'=>null,'email'=>null,'customerID'=>null];
if ($type!=='transaction.completed') return ['action'=>'ignore'];
$item=$d['items'][0]??[]; $price=$item['price']??[]; $product=$price['product_id']??($item['product_id']??null);
return ['action'=>'issue','orderID'=>(string)($d['id']??''),'priceID'=>(string)($price['id']??($item['price_id']??'')),'productID'=>(string)($product??''),'name'=>$d['custom_data']['customer_name']??null,'email'=>$d['custom_data']['customer_email']??null,'customerID'=>isset($d['customer_id'])?(string)$d['customer_id']:null];
}
private function fromLemonSqueezy(array $e): array
{
$type=(string)($e['meta']['event_name']??''); $a=$e['data']['attributes']??[];
$order=(string)($e['data']['id']??($a['order_id']??''));
if (in_array($type,['order_refunded','subscription_expired'],true)) return ['action'=>'revoke','orderID'=>$order,'priceID'=>'','productID'=>'','name'=>null,'email'=>null,'customerID'=>null];
if (!in_array($type,['order_created','subscription_created'],true)) return ['action'=>'ignore'];
return ['action'=>'issue','orderID'=>$order,'priceID'=>(string)($a['variant_id']??''),'productID'=>(string)($a['product_id']??''),'name'=>$a['user_name']??null,'email'=>$a['user_email']??null,'customerID'=>isset($a['customer_id'])?(string)$a['customer_id']:null];
}
}
@@ -0,0 +1,4 @@
<?php
namespace App\Services\Payments;
use Illuminate\Http\Request;
interface WebhookVerifier { public function verify(Request $request, string $rawBody): void; }
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);
+22
View File
@@ -0,0 +1,22 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
})->create();
@@ -0,0 +1,2 @@
*
!.gitignore
@@ -0,0 +1,5 @@
<?php
return [
App\Providers\RsyncStudioLicensingServiceProvider::class,
];
+86
View File
@@ -0,0 +1,86 @@
{
"schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.3",
"laravel/framework": "^13.17",
"laravel/tinker": "^3.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.5",
"laravel/pao": "^1.0.6",
"laravel/pint": "^1.27",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^12.5.12"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install --ignore-scripts",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"@php artisan dev"
],
"test": [
"@php artisan config:clear --ansi @no_additional_args",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
{
"require": {
"php": "^8.3",
"ext-sodium": "*",
"laravel/framework": "^13.0"
}
}
@@ -0,0 +1,20 @@
<?php
return [
'product_code' => env('RSYNC_STUDIO_PRODUCT_CODE', 'rsync-studio'),
'private_key_path' => env('RSYNC_STUDIO_LICENSE_PRIVATE_KEY_PATH'),
'public_key' => env('RSYNC_STUDIO_LICENSE_PUBLIC_KEY'),
'default_edition' => env('RSYNC_STUDIO_DEFAULT_EDITION', 'Professional'),
'default_max_major_version' => (int) env('RSYNC_STUDIO_DEFAULT_MAX_MAJOR_VERSION', 1),
'activation_rate_limit' => env('RSYNC_STUDIO_ACTIVATION_RATE_LIMIT', '12,1'),
'purchase_url' => env('RSYNC_STUDIO_PURCHASE_URL'),
'portal_url' => env('RSYNC_STUDIO_PORTAL_URL'),
'support_url' => env('RSYNC_STUDIO_SUPPORT_URL'),
'paddle' => [
'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'),
'timestamp_tolerance' => (int) env('PADDLE_WEBHOOK_TOLERANCE', 300),
],
'lemon_squeezy' => [
'webhook_secret' => env('LEMON_SQUEEZY_WEBHOOK_SECRET'),
],
];
Binary file not shown.
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('sessions');
}
};
@@ -0,0 +1,91 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('license_products', function (Blueprint $table) {
$table->id();
$table->string('code')->unique();
$table->string('name');
$table->string('edition')->default('Professional');
$table->unsignedInteger('activation_limit')->default(1);
$table->unsignedInteger('max_major_version')->nullable();
$table->string('payment_provider')->nullable();
$table->string('external_product_id')->nullable();
$table->string('external_price_id')->nullable();
$table->boolean('active')->default(true);
$table->timestamps();
$table->unique(['payment_provider', 'external_price_id']);
});
Schema::create('licenses', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignId('license_product_id')->constrained('license_products');
$table->string('license_key_hash', 64)->unique();
$table->string('license_key_last4', 4);
$table->string('customer_name')->nullable();
$table->string('customer_email')->nullable()->index();
$table->string('edition');
$table->unsignedInteger('activation_limit');
$table->unsignedInteger('max_major_version')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamp('revoked_at')->nullable();
$table->string('revoke_reason')->nullable();
$table->string('payment_provider')->nullable();
$table->string('external_order_id')->nullable();
$table->string('external_customer_id')->nullable();
$table->timestamps();
$table->unique(['payment_provider', 'external_order_id']);
});
Schema::create('license_delivery_outbox', function (Blueprint $table) {
$table->id();
$table->uuid('license_id')->unique();
$table->text('encrypted_license_key');
$table->string('recipient_email')->nullable();
$table->timestamp('delivered_at')->nullable();
$table->timestamps();
$table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete();
});
Schema::create('license_activations', function (Blueprint $table) {
$table->id();
$table->uuid('license_id');
$table->uuid('installation_id');
$table->string('platform', 32)->nullable();
$table->string('app_version', 32)->nullable();
$table->timestamp('activated_at');
$table->timestamp('last_seen_at')->nullable();
$table->timestamp('deactivated_at')->nullable();
$table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete();
$table->unique(['license_id', 'installation_id']);
$table->index(['license_id', 'deactivated_at']);
});
Schema::create('license_webhook_events', function (Blueprint $table) {
$table->id();
$table->string('provider');
$table->string('external_event_id');
$table->string('event_type');
$table->string('payload_sha256', 64);
$table->json('payload');
$table->timestamp('processed_at')->nullable();
$table->text('processing_error')->nullable();
$table->timestamps();
$table->unique(['provider', 'external_event_id']);
});
}
public function down(): void
{
Schema::dropIfExists('license_webhook_events');
Schema::dropIfExists('license_activations');
Schema::dropIfExists('license_delivery_outbox');
Schema::dropIfExists('licenses');
Schema::dropIfExists('license_products');
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('users')) {
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
}
if (! Schema::hasTable('password_reset_tokens')) {
Schema::create('password_reset_tokens', function (Blueprint $table): void {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
}
}
public function down(): void
{
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('users');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('users') && ! Schema::hasColumn('users', 'is_admin')) {
Schema::table('users', fn (Blueprint $table) => $table->boolean('is_admin')->default(false)->index());
}
if (Schema::hasTable('license_activations') && ! Schema::hasColumn('license_activations', 'installation_name')) {
Schema::table('license_activations', fn (Blueprint $table) => $table->string('installation_name')->nullable()->after('installation_id'));
}
}
public function down(): void
{
if (Schema::hasTable('license_activations') && Schema::hasColumn('license_activations', 'installation_name')) {
Schema::table('license_activations', fn (Blueprint $table) => $table->dropColumn('installation_name'));
}
if (Schema::hasTable('users') && Schema::hasColumn('users', 'is_admin')) {
Schema::table('users', fn (Blueprint $table) => $table->dropColumn('is_admin'));
}
}
};
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then echo "Usage: ./Licensing\\ Server/install-overlay.sh /path/to/laravel-app" >&2; exit 2; fi
SRC="$(cd "$(dirname "$0")" && pwd)"; DST="$(cd "$1" && pwd)"
for dir in app config database routes tests resources; do [[ -d "$SRC/$dir" ]] && cp -R "$SRC/$dir/." "$DST/$dir/"; done
[[ -f "$DST/routes/web.php" ]] || printf '%s\n' '<?php' 'use Illuminate\Support\Facades\Route;' > "$DST/routes/web.php"
grep -q "web.stage5.php" "$DST/routes/web.php" || printf "\nrequire __DIR__.'/web.stage5.php';\n" >> "$DST/routes/web.php"
[[ -f "$DST/routes/api.php" ]] || printf '%s\n' '<?php' > "$DST/routes/api.php"
grep -q "api.stage4.php" "$DST/routes/api.php" || printf "\nrequire __DIR__.'/api.stage4.php';\n" >> "$DST/routes/api.php"
if ! grep -q 'RsyncStudioLicensingServiceProvider' "$DST/bootstrap/providers.php"; then python3 - "$DST/bootstrap/providers.php" <<'PY'
import sys
p=sys.argv[1]; s=open(p).read(); marker='return ['; s=s.replace(marker, marker+"\n App\\Providers\\RsyncStudioLicensingServiceProvider::class,",1); open(p,'w').write(s)
PY
fi
printf '\nStage 5 files copied into %s\n' "$DST"
printf 'Run: php artisan migrate && php artisan optimize:clear\n'
printf 'Then create an administrator with rsync-studio:admin.\n'
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://www.schemastore.org/package.json",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"concurrently": "^10.0.3",
"laravel-vite-plugin": "^3.1",
"tailwindcss": "^4.0.0",
"vite": "^8.0.0"
},
"optionalDependencies": {
"@laravel/multiplex": "^0.4.1"
}
}
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>
+25
View File
@@ -0,0 +1,25 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle X-XSRF-Token Header
RewriteCond %{HTTP:x-xsrf-token} .
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>
+20
View File
@@ -0,0 +1,20 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow:
@@ -0,0 +1,9 @@
@import 'tailwindcss';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}
@@ -0,0 +1 @@
//
@@ -0,0 +1 @@
@extends('layouts.app') @section('content')<h1>Licensing administration</h1><div class="grid"><div class="card"><strong>{{ $licenseCount }}</strong><p>Licences</p></div><div class="card"><strong>{{ $activeCount }}</strong><p>Not revoked</p></div><div class="card"><strong>{{ $activationCount }}</strong><p>Active installations</p></div><div class="card"><strong>{{ $userCount }}</strong><p>Portal users</p></div></div><a class="btn" href="{{ route('admin.licenses') }}">Manage licences</a>@endsection
@@ -0,0 +1 @@
@extends('layouts.app') @section('content')<div class="card"><h1>Licence •••• {{ $license->license_key_last4 }}</h1><p>{{ $license->customer_name }} · {{ $license->customer_email }}</p><p>{{ $license->edition }} · {{ $license->activation_limit }} seats</p>@if($license->revoked_at)<form method="post" action="{{ route('admin.restore',$license) }}">@csrf<button>Restore licence</button></form>@else<form method="post" action="{{ route('admin.revoke',$license) }}">@csrf<label>Reason</label><input name="reason"><button class="danger">Revoke licence</button></form>@endif</div><div class="card"><h2>Activations</h2><table><tr><th>ID</th><th>Platform</th><th>Version</th><th>Status</th></tr>@foreach($license->activations as $a)<tr><td>{{ $a->installation_name ?: $a->installation_id }}</td><td>{{ $a->platform }}</td><td>{{ $a->app_version }}</td><td>{{ $a->deactivated_at ? 'Released':'Active' }}</td></tr>@endforeach</table></div>@endsection
@@ -0,0 +1 @@
@extends('layouts.app') @section('content')<h1>Licences</h1><form><input name="q" value="{{ request('q') }}" placeholder="Search email, name or last 4"><button>Search</button></form><div class="card"><table><tr><th>Customer</th><th>Edition</th><th>Key</th><th>Status</th></tr>@foreach($licenses as $l)<tr><td><a href="{{ route('admin.license',$l) }}">{{ $l->customer_email ?: 'Manual' }}</a></td><td>{{ $l->edition }}</td><td>•••• {{ $l->license_key_last4 }}</td><td>{{ $l->revoked_at ? 'Revoked':'Active' }}</td></tr>@endforeach</table>{{ $licenses->links() }}</div>@endsection
@@ -0,0 +1 @@
@extends('layouts.app') @section('content')<div class="card"><h1>Reset password</h1><form method="post" action="{{ route('password.email') }}">@csrf<label>Email</label><input name="email" type="email" required><button>Send reset link</button></form></div>@endsection
@@ -0,0 +1 @@
@extends('layouts.app') @section('content')<div class="card"><h1>Sign in</h1><form method="post" action="{{ route('login') }}">@csrf<label>Email</label><input name="email" type="email" value="{{ old('email') }}" required autofocus><label>Password</label><input name="password" type="password" required><label><input style="width:auto" type="checkbox" name="remember"> Remember me</label><br><button>Sign in</button> <a href="{{ route('password.request') }}">Forgot password?</a></form></div>@endsection
@@ -0,0 +1 @@
@extends('layouts.app') @section('content')<div class="card"><h1>Create account</h1><p class="muted">Use the same email address used to purchase Rsync Studio.</p><form method="post" action="{{ route('register') }}">@csrf<label>Name</label><input name="name" value="{{ old('name') }}" required><label>Email</label><input name="email" type="email" value="{{ old('email') }}" required><label>Password</label><input name="password" type="password" required><label>Confirm password</label><input name="password_confirmation" type="password" required><button>Create account</button></form></div>@endsection
@@ -0,0 +1 @@
@extends('layouts.app') @section('content')<div class="card"><h1>Choose a new password</h1><form method="post" action="{{ route('password.update') }}">@csrf<input type="hidden" name="token" value="{{ $token }}"><label>Email</label><input name="email" type="email" value="{{ $email }}" required><label>New password</label><input name="password" type="password" required><label>Confirm password</label><input name="password_confirmation" type="password" required><button>Reset password</button></form></div>@endsection
@@ -0,0 +1 @@
@extends('layouts.app') @section('content')<div class="card"><h1>Verify your email</h1><p>For security, licence ownership is only shown after the account email has been verified.</p><form method="post" action="{{ route('verification.send') }}">@csrf<button>Send another verification email</button></form></div>@endsection
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="csrf-token" content="{{ csrf_token() }}"><title>{{ $title ?? 'Rsync Studio' }}</title><style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0;background:#f5f6f8;color:#18202a}nav{background:#111827;color:#fff;padding:14px 24px;display:flex;gap:18px;align-items:center}nav a{color:#fff;text-decoration:none}.wrap{max-width:980px;margin:32px auto;padding:0 20px}.card{background:#fff;border:1px solid #d9dee7;border-radius:12px;padding:22px;margin:16px 0;box-shadow:0 2px 8px #0000000d}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px}.muted{color:#667085}.ok{color:#067647}.bad{color:#b42318}button,.btn{display:inline-block;border:0;border-radius:8px;padding:10px 14px;background:#2563eb;color:#fff;text-decoration:none;cursor:pointer}button.danger{background:#b42318}input{width:100%;box-sizing:border-box;padding:10px;border:1px solid #cbd5e1;border-radius:8px;margin:6px 0 14px}label{font-weight:600}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:10px;border-bottom:1px solid #e5e7eb}.alert{padding:12px;border-radius:8px;background:#ecfdf3;margin-bottom:14px}.error{padding:12px;border-radius:8px;background:#fef3f2;margin-bottom:14px}</style></head><body><nav><strong>Rsync Studio</strong><a href="{{ route('home') }}">Home</a>@auth @if(auth()->user()->hasVerifiedEmail())<a href="{{ route('portal.index') }}">My licences</a>@endif @if(auth()->user()->is_admin)<a href="{{ route('admin.index') }}">Admin</a>@endif <span style="margin-left:auto">{{ auth()->user()->email }}</span><form method="post" action="{{ route('logout') }}">@csrf<button>Sign out</button></form>@else<span style="margin-left:auto"></span><a href="{{ route('login') }}">Sign in</a><a href="{{ route('register') }}">Create account</a>@endauth</nav><main class="wrap">@if(session('status'))<div class="alert">{{ session('status') }}</div>@endif @if($errors->any())<div class="error">{{ $errors->first() }}</div>@endif @yield('content')</main></body></html>
@@ -0,0 +1 @@
@extends('layouts.app') @section('content')<h1>My licences</h1>@forelse($licenses as $license)<div class="card"><h2>{{ $license->product->name }} · {{ $license->edition }}</h2><p>Licence ending <strong>{{ $license->license_key_last4 }}</strong> · {{ $license->activations->whereNull('deactivated_at')->count() }}/{{ $license->activation_limit }} activations</p><p class="{{ $license->is_usable ? 'ok':'bad' }}">{{ $license->is_usable ? 'Active':'Unavailable' }}</p><a class="btn" href="{{ route('portal.show',$license) }}">Manage</a></div>@empty<div class="card">No licences are associated with {{ auth()->user()->email }}.</div>@endforelse@endsection
@@ -0,0 +1 @@
@extends('layouts.app') @section('content')<div class="card"><h1>{{ $license->product->name }}</h1><p>{{ $license->edition }} · key ending {{ $license->license_key_last4 }}</p><form method="post" action="{{ route('portal.resend',$license) }}">@csrf<button>Email my licence key</button></form></div><div class="card"><h2>Installations</h2><table><tr><th>Installation</th><th>Platform</th><th>Version</th><th>Last seen</th><th></th></tr>@foreach($license->activations->whereNull('deactivated_at') as $a)<tr><td>{{ $a->installation_name ?: $a->installation_id }}</td><td>{{ $a->platform }}</td><td>{{ $a->app_version }}</td><td>{{ optional($a->last_seen_at)->toDayDateTimeString() }}</td><td><form method="post" action="{{ route('portal.deactivate',[$license,$a->id]) }}">@csrf @method('DELETE')<button class="danger">Deactivate</button></form></td></tr>@endforeach</table></div>@endsection
@@ -0,0 +1 @@
@extends('layouts.app') @section('content')<div class="card"><h1>Rsync Studio licensing</h1><p>Manage your Rsync Studio licence, installations and recovery options securely.</p>@guest<a class="btn" href="{{ route('login') }}">Sign in</a>@endguest</div>@endsection
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
<?php
require __DIR__.'/api.stage4.php';
@@ -0,0 +1,11 @@
<?php
use App\Http\Controllers\Api\LicenseActivationController;
use App\Http\Controllers\Api\PaymentWebhookController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(function () {
Route::post('/activate', [LicenseActivationController::class, 'activate'])->middleware('throttle:license-activation');
Route::post('/deactivate', [LicenseActivationController::class, 'deactivate'])->middleware('throttle:license-activation');
Route::post('/webhooks/{provider}', PaymentWebhookController::class)->whereIn('provider', ['paddle','lemon-squeezy']);
});
+3
View File
@@ -0,0 +1,3 @@
<?php
require __DIR__.'/web.stage5.php';
@@ -0,0 +1,14 @@
<?php
use App\Http\Controllers\Admin\AdminController;
use App\Http\Controllers\Auth\AuthenticatedSessionController;
use App\Http\Controllers\Auth\EmailVerificationController;
use App\Http\Controllers\Auth\PasswordController;
use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\Portal\PortalController;
use App\Http\Middleware\AdminOnly;
use Illuminate\Support\Facades\Route;
Route::get('/', fn()=>view('portal.welcome'))->name('home');
Route::middleware('guest')->group(function(){Route::get('/login',[AuthenticatedSessionController::class,'create'])->name('login');Route::post('/login',[AuthenticatedSessionController::class,'store'])->middleware('throttle:login');Route::get('/register',[RegisteredUserController::class,'create'])->name('register');Route::post('/register',[RegisteredUserController::class,'store'])->middleware('throttle:registration');Route::get('/forgot-password',[PasswordController::class,'requestForm'])->name('password.request');Route::post('/forgot-password',[PasswordController::class,'email'])->name('password.email')->middleware('throttle:5,1');Route::get('/reset-password/{token}',[PasswordController::class,'resetForm'])->name('password.reset');Route::post('/reset-password',[PasswordController::class,'reset'])->name('password.update');});
Route::middleware('auth')->group(function(){Route::post('/logout',[AuthenticatedSessionController::class,'destroy'])->name('logout');Route::get('/verify-email',[EmailVerificationController::class,'notice'])->name('verification.notice');Route::get('/verify-email/{id}/{hash}',[EmailVerificationController::class,'verify'])->middleware(['signed','throttle:6,1'])->name('verification.verify');Route::post('/email/verification-notification',[EmailVerificationController::class,'resend'])->middleware('throttle:6,1')->name('verification.send');});
Route::middleware(['auth','verified'])->prefix('portal')->name('portal.')->group(function(){Route::get('/',[PortalController::class,'index'])->name('index');Route::get('/licences/{license}',[PortalController::class,'show'])->name('show');Route::post('/licences/{license}/resend',[PortalController::class,'resend'])->middleware('throttle:3,10')->name('resend');Route::delete('/licences/{license}/activations/{activation}',[PortalController::class,'deactivate'])->name('deactivate');});
Route::middleware(['auth','verified',AdminOnly::class])->prefix('admin')->name('admin.')->group(function(){Route::get('/',[AdminController::class,'index'])->name('index');Route::get('/licences',[AdminController::class,'licenses'])->name('licenses');Route::get('/licences/{license}',[AdminController::class,'show'])->name('license');Route::post('/licences/{license}/revoke',[AdminController::class,'revoke'])->name('revoke');Route::post('/licences/{license}/restore',[AdminController::class,'restore'])->name('restore');});
@@ -0,0 +1,4 @@
*
!private/
!public/
!.gitignore
@@ -0,0 +1,2 @@
*
!.gitignore
@@ -0,0 +1,2 @@
*
!.gitignore
@@ -0,0 +1,10 @@
compiled.php
config.php
down
events.scanned.php
lsp-*.php
maintenance.php
routes.php
routes.scanned.php
schedule-*
services.json
@@ -0,0 +1,3 @@
*
!data/
!.gitignore

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