connection updates
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user