This repository has been archived on 2024-08-25. You can view files and clone it, but cannot push or open issues or pull requests.
Burgernotes-iOS/Burgernotes/ContentView.swift

635 lines
30 KiB
Swift

//
// ContentView.swift
// Burgernotes
//
// Created by ffqq on 23/02/2024.
//
// CONTENT VIEW
//
// This is where the app lives. Everything that happens,
// happens here.
//
// (This'll need a rewrite some day)
import SwiftUI
import CryptoKit
import SafariServices
import KeychainSwift
import JavaScriptCore
struct ContentView: View {
// Initialize everything
@State private var username = ""
@State private var password = ""
@State private var notes: [Note] = []
@State private var noteContent = ""
@State private var selectedNote = ""
@State private var showAddNote = false
// User info
@State private var noteCount: Int = 0
@State private var sessionID: Int = 0
@State private var maxStorage = ""
@State private var usedStorage = ""
// UI activators
@State private var isEditing = false
@State private var usingSettings = false
@State private var isOnline = Reach().isConnectedToNetwork()
@State private var loggingIn = true
@State private var signingUp = false
@State private var showErrorLabel = false
@State private var errorMessage = ""
@AppStorage("Username") var storedUsername: String? // Usernames are stored in UserDefaults for the sake of convenience
let keychain = KeychainSwift()
func login() {
let hashHelper = HashHelper()
// Use HashHelper to hash our password
let hashedPassword = hashHelper.hashPassword_sha3(password)
let SHA512key = hashHelper.hashPassword_sha512(password)
if password.count < 8 {
showErrorLabel = true
errorMessage = "Password must be 8+ characters!"
return
}
if loggingIn {
// Prepare data
let parameters = ["username": username, "password": hashedPassword, "passwordchange": "no", "newpass": "null"] // Legacy accounts should be migrated from Argon2 through the browser.
// Create the URL request
guard let url = URL(string: "https://notes.hectabit.org/api/login") else {
showErrorLabel = true
errorMessage = "Invalid URL" // Usually we'd do `guard let url = URL(string: "https://foo.bar/api/foo") else { return }`, but we will display a user-visible error for the sake of UX.
return
}
JSONHelper.sendJSONRequest(url: url, parameters: parameters as [String : Any]) { result in
switch result {
case .success(let data):
if let data = data {
// Handle the response
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let keyValue = JSONHelper.processJSONResponse(json: json, key: "key") {
let secretKey = keyValue
keychain.delete("secretKey")
keychain.set(secretKey, forKey: "secretKey")
storedUsername = username // This will also trigger SwiftUI to change vstacks
keychain.delete("encryptionKey")
keychain.set(SHA512key, forKey: "encryptionKey")
password = ""
}
} else {
// Show error label
showErrorLabel = true
errorMessage = "Unknown error"
}
case .failure(let error):
if (error as NSError).code == 401 {
showErrorLabel = true
errorMessage = "Invalid username or password."
} else {
// Show error label
showErrorLabel = true
errorMessage = error.localizedDescription
}
}
}
}
if signingUp {
// Prepare data
let parameters = ["username": username, "password": hashedPassword] // Legacy accounts should be migrated from Argon2 through the browser.
// Create the URL request
guard let url = URL(string: "https://notes.hectabit.org/api/signup") else {
showErrorLabel = true
errorMessage = "Invalid URL" // Usually we'd do `guard let url = URL(string: "https://foo.bar/api/foo") else { return }`, but we will display a user-visible error for the sake of UX.
return
}
JSONHelper.sendJSONRequest(url: url, parameters: parameters as [String : Any]) { result in
switch result {
case .success(let data):
if let data = data {
// Handle the response
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let keyValue = JSONHelper.processJSONResponse(json: json, key: "key") {
let secretKey = keyValue
keychain.delete("secretKey")
keychain.set(secretKey, forKey: "secretKey")
storedUsername = username // This will also trigger SwiftUI to change vstacks
keychain.delete("encryptionKey")
keychain.set(SHA512key, forKey: "encryptionKey")
signingUp = false
loggingIn = true
password = ""
}
} else {
// Show error label
showErrorLabel = true
errorMessage = "Unknown error"
}
case .failure(let error):
if (error as NSError).code == 409 {
// Show error label
showErrorLabel = true
errorMessage = "Username already taken!"
} else {
// Show error label
showErrorLabel = true
errorMessage = error.localizedDescription
}
}
}
}
}
var body: some View {
if storedUsername != nil {
// Note list
let secretKey = keychain.get("secretKey")
let encryptionKey = keychain.get("encryptionKey")
VStack {
HStack {
// Refresh notes
Button(action: {
fetchNotes() // Refresh notes
}) {
Image(systemName: "arrow.clockwise.circle")
.font(.title)
}
.disabled(isEditing) .disabled(usingSettings) .disabled(!isOnline) // Make the buttons disabled while the note editor (or settings) is open, or while offline.
Spacer()
// Settings
Button(action: {
if usingSettings == false {
fetchUserInfo()
usingSettings = true
} else {
usingSettings = false
}
}) {
Image(systemName: "gear")
.font(.title)
}
.disabled(isEditing) .disabled(!isOnline) // Make the buttons disabled while the note editor is open, or while offline.
// New note
Button(action: {
// New note creation
let dialog = UIAlertController(title: "New Note", message: "Enter note name", preferredStyle: .alert)
dialog.addTextField { $0.placeholder = "My Diary" }
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel)
let createAction = UIAlertAction(title: "Create", style: .default) { _ in
if let noteName = dialog.textFields?.first?.text, let url = URL(string: "https://notes.hectabit.org/api/newnote") {
let parameters = ["secretKey": "\(secretKey ?? "bum")", "noteName": "\(noteName.encrypt(password: encryptionKey ?? "this bum ain't got an encryption key!!!"))"]
JSONHelper.sendJSONRequest(url: url, parameters: parameters) { result in
switch result {
case .success:
fetchNotes()
case .failure(let error):
print("Error sending JSON request: \(error)")
}
}
}
fetchNotes()
}
dialog.addAction(cancelAction);dialog.addAction(createAction)
if let topVC = UIApplication.shared.keyWindow?.rootViewController { topVC.present(dialog, animated: true) } // We can keep using keyWindow, it's not a big deal for this specific usecase that it's deprecated
}) {
Image(systemName: "plus.circle")
.font(.title)
}
.disabled(isEditing) .disabled(usingSettings) .disabled(!isOnline) // Make the buttons disabled while the note editor (or settings) is open, or while offline.
}
.padding()
Group {
if isOnline {
if !usingSettings {
if !isEditing {
Text("Burgernotes")
.font(.title)
.padding()
List {
ForEach(notes, id: \.id) { note in
let noteTitle = note.title.decrypt(password: encryptionKey ?? "this bum ain't got an encryption key!!!")
Button(action: {
fetchNoteContent(note: note)
}) {
Text(noteTitle)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(action: {
guard let url = URL(string: "https://notes.hectabit.org/api/removenote") else { return }
let parameters = ["secretKey": "\(secretKey ?? "bum")", "noteId": "\(note.id)"]
JSONHelper.sendJSONRequest(url: url, parameters: parameters) { result in
switch result {
case .success(_):
print("Note deleted successfully.")
withAnimation {
notes.removeAll(where: { $0.id == note.id })
}
case .failure(let error):
print("Error: \(error)")
}
}
}) {
Image(systemName: "trash")
}
.tint(.red)
.animation(.default, value: notes)
}
}
}
.listStyle(.plain)
Spacer()
}
}
}
if !isOnline {
Text("You are currently offline.")
.foregroundStyle(Color.red)
Button(action: {
isOnline = Reach().isConnectedToNetwork()
if isOnline {
fetchNotes()
}
}) {
Text("Refresh connection status")
.padding()
.foregroundStyle(Color.blue)
}
Spacer()
}
if isEditing {
TextEditor(text: $noteContent)
.padding()
HStack {
Button(action: {
isEditing = false
noteContent = ""
}) {
Image(systemName: "chevron.left")
.imageScale(.large)
Text("Back")
}
.padding()
Spacer()
Button(action: {
saveEditedNoteContent()
}) {
Text("Save")
}
.padding()
}
} else {
Text(noteContent)
.padding()
.onTapGesture {
isEditing = true
}
}
}
.padding()
.onAppear {
fetchNotes()
}
}
if usingSettings {
List {
Section {
Text((storedUsername ?? "No stored username :("))
} header: {
Text("Username")
}
Section {
Text(maxStorage)
} header: {
Text("Maximum available storage")
}
Section {
Text(usedStorage)
} header: {
Text("Used storage")
}
Button(action: {
let dialog = UIAlertController(title: "Sign out", message: "Are you sure you want to sign out?", preferredStyle: .alert)
// In the case of a signout:
let signOut = UIAlertAction(title: "Yes", style: .destructive) { _ in
fetchSessionID()
guard let url = URL(string: "https://notes.hectabit.org/api/sessions/remove") else { return }
let parameters = ["secretKey": "\(secretKey ?? "bum")", "sessionId": sessionID]
JSONHelper.sendJSONRequest(url: url, parameters: parameters) { result in
switch result {
case .success:
keychain.delete("secretKey")
UserDefaults.standard.removeObject(forKey: "Username") // This will also trigger SwiftUI to change vstacks
keychain.delete("encryptionKey")
usingSettings = false
case .failure(let error):
print("Failed to sign out: \(error)") // This is not supposed to happen under any circumstances unless the session was deauthorized remotely
}
}
}
let no = UIAlertAction(title: "No", style: .default)
dialog.addAction(signOut);dialog.addAction(no)
if let topVC = UIApplication.shared.keyWindow?.rootViewController { topVC.present(dialog, animated: true) } // We can keep using keyWindow, it's not a big deal for this specific usecase that it's deprecated
}) {
Text("Sign out")
.foregroundStyle(Color.red) // Set the text color to red
}
Button(action: {
let dialog = UIAlertController(title: "Delete my account", message: "Are you sure you want to delete your account? THIS CANNOT BE REVERSED", preferredStyle: .alert)
// In the case of a signout:
let deleteAccount = UIAlertAction(title: "Yes", style: .destructive) { _ in
guard let url = URL(string: "https://notes.hectabit.org/api/deleteaccount") else { return }
let parameters = ["secretKey": "\(secretKey ?? "bum")"]
JSONHelper.sendJSONRequest(url: url, parameters: parameters) { result in
switch result {
case .success:
keychain.delete("secretKey")
UserDefaults.standard.removeObject(forKey: "Username") // This will also trigger SwiftUI to change vstacks
keychain.delete("encryptionKey")
usingSettings = false
case .failure(let error):
print("Failed to delete account: \(error)") // This is not supposed to happen under any circumstances unless the session was deauthorized remotely
}
}
}
let no = UIAlertAction(title: "No", style: .default)
dialog.addAction(deleteAccount);dialog.addAction(no)
if let topVC = UIApplication.shared.keyWindow?.rootViewController { topVC.present(dialog, animated: true) } // We can keep using keyWindow, it's not a big deal for this specific usecase that it's deprecated
}) {
Text("Delete my account")
.foregroundStyle(Color.red) // Set the text color to red
}
}
}
} else {
// Login screen
VStack {
Image("org.hectabit.burgernotes")
.resizable()
.frame(width: 100, height: 100)
.padding(.bottom, 16)
Text("Burgernotes")
.font(.title)
.fontWeight(.bold)
Text("Secure, encrypted notes.")
.padding(.bottom, 16)
VStack(spacing: 16) {
TextField("Username", text: $username)
.textFieldStyle(RoundedBorderTextFieldStyle())
.autocapitalization(.none)
SecureField("Password", text: $password)
.textFieldStyle(RoundedBorderTextFieldStyle())
.autocapitalization(.none)
Button(action: login) {
Text(loggingIn ? "Sign In" : (signingUp ? "Sign Up" : ""))
.foregroundColor(.white)
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.cornerRadius(8)
}
HStack {
Button(action: {
if signingUp {
signingUp = false
loggingIn = true
} else if loggingIn {
loggingIn = false
signingUp = true
}
}) {
Text(loggingIn ? "Sign up instead" : (signingUp ? "Sign in instead" : ""))
.font(.body)
.foregroundColor(.blue)
}
Text("|")
.font(.body)
.foregroundColor(.blue)
Button(action: {
if let url = URL(string: "https://notes.hectabit.org/privacy") {
let safariViewController = SFSafariViewController(url: url)
UIApplication.shared.windows.first?.rootViewController?.present(safariViewController, animated: true, completion: nil)
}
}) {
Text("Privacy Policy")
.font(.body)
.foregroundColor(.blue)
}
}
Text(errorMessage)
.foregroundColor(.red)
.opacity(showErrorLabel ? 1 : 0)
.padding(.top, 8)
}
.padding(.horizontal, 32)
.padding(.bottom, 16)
}
.padding()
.background(Color(.systemBackground))
.edgesIgnoringSafeArea(.all)
}
}
// Fetch notes
private func fetchNotes() {
let secretKey = keychain.get("secretKey")
guard let url = URL(string: "https://notes.hectabit.org/api/listnotes") else { return }
let parameters = ["secretKey": "\(secretKey ?? "bum")"]
// Call a JSON request
JSONHelper.sendJSONRequest(url: url, parameters: parameters) { result in
switch result {
case .success(let data):
if let data = data {
// Decode the received notes
if let remoteNotes = try? JSONDecoder().decode([Note].self, from: data) {
DispatchQueue.main.async {
// Remove notes that are not present remotely
notes = notes.filter { note in
remoteNotes.contains { $0.id == note.id }
}
// Filter new notes from existing notes
let newNotes = remoteNotes.filter { newNote in
!notes.contains { $0.id == newNote.id }
}
// Append the new notes to the existing array
notes.append(contentsOf: newNotes)
}
}
}
case .failure(let error):
print("Error: \(error)") // Aww shit!
}
}
}
private func fetchNoteContent(note: Note) {
// We need to get the secret and encryptionkeys again here
let secretKey = keychain.get("secretKey")
let encryptionKey = keychain.get("encryptionKey")
guard let url = URL(string: "https://notes.hectabit.org/api/readnote") else { return }
let parameters = ["secretKey": "\(secretKey ?? "bum")", "noteId": "\(note.id)"]
// Call a JSON request
JSONHelper.sendJSONRequest(url: url, parameters: parameters) { result in
switch result {
case .success(let data):
guard let data = data else {
print("No data received")
return
}
do {
let json = try JSONSerialization.jsonObject(with: data, options: [])
if let responseDict = json as? [String: Any],
let encryptedContent = responseDict["content"] as? String {
let decryptedContent = encryptedContent.decrypt(password: encryptionKey ?? "this bum ain't got an encryption key!!!")
DispatchQueue.main.async {
// Set up & open the text editor
noteContent = decryptedContent
selectedNote = "\(note.id)"
isEditing = true
}
}
} catch {
print("Error decoding JSON response 🙁: \(error)")
}
case .failure(let error):
print("Error: \(error)") // Aww shit!
}
}
}
// Fetch user info (for the settings page)
private func fetchUserInfo() {
let secretKey = keychain.get("secretKey")
guard let url = URL(string: "https://notes.hectabit.org/api/userinfo") else { return }
let parameters = ["secretKey": "\(secretKey ?? "bum")"]
// Call a JSON request
JSONHelper.sendJSONRequest(url: url, parameters: parameters) { result in
switch result {
case .success(let data):
if let data = data {
do {
let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]
fetchSessionID()
DispatchQueue.main.async {
// Update the States ('MURICA!!! 🇺🇸🇺🇸🦅) to match that of the API response
noteCount = json?["notecount"] as? Int ?? 0
maxStorage = SizeHelper().humanReadable(json?["storagemax"] as? String ?? "") // Why is this a string?
usedStorage = SizeHelper().humanReadable(json?["storageused"] as? Int ?? 0)
}
} catch {
print("Error decoding JSON response 🙁: \(error)")
}
}
case .failure(let error):
print("Error: \(error)") // Aww shit!
}
}
}
// Fetch session ID (for logging out)
private func fetchSessionID() {
let secretKey = keychain.get("secretKey")
guard let url = URL(string: "https://notes.hectabit.org/api/sessions/list") else { return }
let parameters = ["secretKey": "\(secretKey ?? "bum")"]
// Call a JSON request
JSONHelper.sendJSONRequest(url: url, parameters: parameters) { result in
switch result {
// In the case of a successful JSON response
case .success(let data):
if let data = data {
do {
let json = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]]
// Search for an entry with "thisSession" set to true
if let session = json?.first(where: { $0["thisSession"] as? Bool == true }) {
DispatchQueue.main.async {
// Set our sessionID state to "id" from this response
sessionID = session["id"] as? Int ?? 0
}
} else {
// HOW was this called when the user was logged off? (I mean, this will be used for session authorization)
print("Is this guy even logged in?")
}
} catch {
print("Error decoding JSON response 🙁: \(error)")
}
}
// In the case of a failed json response
case .failure(let error):
print("Error: \(error)") // Aww shit!
}
}
}
private func saveEditedNoteContent() {
// We need to get the secret and encryptionkeys again here
let secretKey = keychain.get("secretKey")
let encryptionKey = keychain.get("encryptionKey")
guard let url = URL(string: "https://notes.hectabit.org/api/editnote") else { return }
let formattedContent = noteContent.replacingOccurrences(of: "\n", with: "NEWLINEHERETHISISHACKY") // Hacky solution to a real problem
let encryptedContent = formattedContent.encrypt(password: encryptionKey ?? "this bum ain't got an encryption key!!!")
let parameters = ["secretKey": "\(secretKey ?? "bum")", "noteId": "\(selectedNote)", "content": encryptedContent]
// Call a JSON request
JSONHelper.sendJSONRequest(url: url, parameters: parameters) { result in
switch result {
case .success(_):
print("Note edited and saved successfully")
// Close the text editor
DispatchQueue.main.async {
isEditing = false
noteContent = ""
}
case .failure(let error):
print("Error: \(error)") // Aww shit!
}
}
}
}
struct Note: Codable, Equatable {
let id: Int
let title: String
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ForEach(ColorScheme.allCases, id: \.self, content: ContentView().preferredColorScheme)
}
}