connection updates

This commit is contained in:
Mark Gregory
2026-10-19 12:11:39 +01:00
parent 1df9549a0e
commit 7cf013a783
+189
View File
@@ -4,6 +4,7 @@ import Charts
import AppKit
import CryptoKit
import UniformTypeIdentifiers
import Security
// MARK: - Classic Workbench Colours
@@ -7476,6 +7477,7 @@ private struct DBSynInlineRowEditor: View {
@State private var values: [String]
@State private var executing = false
@State private var errorText: String?
@State private var passwordColumnIndex: Int?
init(model: DatabaseModel, database: String, table: String,
columns: [String], original: [String]) {
@@ -7554,6 +7556,15 @@ private struct DBSynInlineRowEditor: View {
TextField(columns[i], text: $values[i])
.textFieldStyle(.roundedBorder)
.disabled(primaryIndices.contains(i) || executing)
if isLaravelPasswordColumn(i) && !primaryIndices.contains(i) {
Button {
passwordColumnIndex = i
} label: {
Label("Laravel Password…", systemImage: "key.fill")
}
.disabled(executing)
.help("Generate a Laravel bcrypt password hash for this field")
}
if primaryIndices.contains(i) {
Text("PRIMARY KEY").font(.caption).foregroundStyle(.secondary)
}
@@ -7590,6 +7601,22 @@ private struct DBSynInlineRowEditor: View {
}
.padding(20)
.frame(width: 780, height: 620)
.sheet(item: Binding(
get: { passwordColumnIndex.map { PasswordFieldSelection(index: $0) } },
set: { passwordColumnIndex = $0?.index }
)) { selection in
DBSynLaravelPasswordGenerator { hash in
guard values.indices.contains(selection.index) else { return }
values[selection.index] = hash
passwordColumnIndex = nil
}
}
}
private func isLaravelPasswordColumn(_ i: Int) -> Bool {
guard columns.indices.contains(i) else { return false }
let name = columns[i].lowercased()
return name == "password" || name == "password_hash"
}
private func apply() {
@@ -7616,6 +7643,168 @@ private struct DBSynInlineRowEditor: View {
}
// MARK: - Laravel bcrypt password generation
private struct PasswordFieldSelection: Identifiable {
let index: Int
var id: Int { index }
}
private enum LaravelBcryptService {
enum HashError: LocalizedError {
case noPHP
case failed(String)
var errorDescription: String? {
switch self {
case .noPHP:
return "PHP CLI was not found. Install PHP or specify its full executable path. Laravel bcrypt requires PHP's password_hash()."
case .failed(let message): return message
}
}
}
static func randomPassword(length: Int = 24) -> String {
let alphabet = Array("ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#%+=")
var bytes = [UInt8](repeating: 0, count: length)
guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else {
return "" // Do not silently fall back to a weaker generator
}
// Rejection sampling prevents modulo bias.
var result = ""
for byte in bytes where Int(byte) < (256 / alphabet.count) * alphabet.count {
result.append(alphabet[Int(byte) % alphabet.count])
}
while result.count < length {
var byte: UInt8 = 0
guard SecRandomCopyBytes(kSecRandomDefault, 1, &byte) == errSecSuccess else { break }
if Int(byte) < (256 / alphabet.count) * alphabet.count {
result.append(alphabet[Int(byte) % alphabet.count])
}
}
return String(result.prefix(length))
}
static func hash(_ plaintext: String, phpPath: String) throws -> String {
let candidates = [phpPath, "/opt/homebrew/bin/php", "/usr/local/bin/php", "/usr/bin/php"]
guard let executable = candidates.first(where: {
!$0.isEmpty && $0.hasPrefix("/") && FileManager.default.isExecutableFile(atPath: $0)
}) else { throw HashError.noPHP }
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
// Read the secret from stdin, never expose it in command-line arguments.
process.arguments = ["-r", "$p = stream_get_contents(STDIN); $h = password_hash($p, PASSWORD_BCRYPT, ['cost' => 12]); if (!$h) { fwrite(STDERR, 'bcrypt failed'); exit(1); } echo $h;"]
let input = Pipe()
let output = Pipe()
let errors = Pipe()
process.standardInput = input
process.standardOutput = output
process.standardError = errors
try process.run()
input.fileHandleForWriting.write(Data(plaintext.utf8))
try? input.fileHandleForWriting.close()
let result = output.fileHandleForReading.readDataToEndOfFile()
let failure = errors.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
let hash = String(decoding: result, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
guard process.terminationStatus == 0,
hash.range(of: #"^\$2y\$12\$[./A-Za-z0-9]{53}$"#, options: .regularExpression) != nil else {
let message = String(decoding: failure, as: UTF8.self)
throw HashError.failed("PHP bcrypt hashing failed: " + (message.isEmpty ? "Invalid hash output" : message))
}
return hash
}
}
private struct DBSynLaravelPasswordGenerator: View {
@Environment(\.dismiss) private var dismiss
let onUseHash: (String) -> Void
@State private var plaintext = ""
@State private var hashed = ""
@State private var phpPath = ""
@State private var showPassword = false
@State private var busy = false
@State private var errorText: String?
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Label("Laravel Password Generator", systemImage: "key.fill")
.font(.title2.bold())
Text("Creates a Laravel-compatible bcrypt hash (cost 12) for a users.password field. Requires PHP CLI.")
.foregroundStyle(.secondary)
HStack {
if showPassword {
TextField("Plaintext password", text: $plaintext)
} else {
SecureField("Plaintext password", text: $plaintext)
}
Button("Generate") {
plaintext = LaravelBcryptService.randomPassword()
hashed = ""
errorText = nil
}
Button(showPassword ? "Hide" : "Show") { showPassword.toggle() }
Button("Copy Password") { copy(plaintext) }.disabled(plaintext.isEmpty)
}
HStack {
TextField("PHP executable path (optional)", text: $phpPath)
.textFieldStyle(.roundedBorder)
Text("Uses Homebrew PHP automatically when available")
.font(.caption).foregroundStyle(.secondary)
}
HStack {
Button(busy ? "Hashing…" : "Generate bcrypt Hash") { generateHash() }
.disabled(plaintext.isEmpty || busy)
if busy { ProgressView().controlSize(.small) }
}
Text("bcrypt hash ($2y$12$)").font(.headline)
TextEditor(text: .constant(hashed))
.font(.system(.body, design: .monospaced))
.frame(height: 86)
.border(Color(nsColor: .separatorColor))
if let errorText {
Text(errorText).foregroundStyle(.red).textSelection(.enabled)
}
Text("Copy the plaintext password before closing. It cannot be recovered from its hash.")
.font(.caption).foregroundStyle(.secondary)
HStack {
Button("Copy Hash") { copy(hashed) }.disabled(hashed.isEmpty)
Spacer()
Button("Cancel") { dismiss() }
Button("Use Hash in Row") { onUseHash(hashed); dismiss() }
.buttonStyle(.borderedProminent)
.disabled(hashed.isEmpty || busy)
}
}
.padding(20)
.frame(width: 750)
.onChange(of: plaintext) { _, _ in hashed = "" }
}
private func copy(_ value: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(value, forType: .string)
}
private func generateHash() {
let secret = plaintext
let path = phpPath
busy = true
errorText = nil
Task {
do {
let value = try await Task.detached(priority: .userInitiated) {
try LaravelBcryptService.hash(secret, phpPath: path)
}.value
if plaintext == secret { hashed = value }
} catch {
errorText = error.localizedDescription
}
busy = false
}
}
}
// MARK: - Connection Manager
private struct DBSynConnectionManager: View {
@ObservedObject var model: DatabaseModel