connection updates
This commit is contained in:
@@ -4,6 +4,7 @@ import Charts
|
|||||||
import AppKit
|
import AppKit
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
import Security
|
||||||
|
|
||||||
// MARK: - Classic Workbench Colours
|
// MARK: - Classic Workbench Colours
|
||||||
|
|
||||||
@@ -7476,6 +7477,7 @@ private struct DBSynInlineRowEditor: View {
|
|||||||
@State private var values: [String]
|
@State private var values: [String]
|
||||||
@State private var executing = false
|
@State private var executing = false
|
||||||
@State private var errorText: String?
|
@State private var errorText: String?
|
||||||
|
@State private var passwordColumnIndex: Int?
|
||||||
|
|
||||||
init(model: DatabaseModel, database: String, table: String,
|
init(model: DatabaseModel, database: String, table: String,
|
||||||
columns: [String], original: [String]) {
|
columns: [String], original: [String]) {
|
||||||
@@ -7554,6 +7556,15 @@ private struct DBSynInlineRowEditor: View {
|
|||||||
TextField(columns[i], text: $values[i])
|
TextField(columns[i], text: $values[i])
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.disabled(primaryIndices.contains(i) || executing)
|
.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) {
|
if primaryIndices.contains(i) {
|
||||||
Text("PRIMARY KEY").font(.caption).foregroundStyle(.secondary)
|
Text("PRIMARY KEY").font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -7590,6 +7601,22 @@ private struct DBSynInlineRowEditor: View {
|
|||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
.frame(width: 780, height: 620)
|
.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() {
|
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
|
// MARK: - Connection Manager
|
||||||
private struct DBSynConnectionManager: View {
|
private struct DBSynConnectionManager: View {
|
||||||
@ObservedObject var model: DatabaseModel
|
@ObservedObject var model: DatabaseModel
|
||||||
|
|||||||
Reference in New Issue
Block a user