diff --git a/app/src/main/java/lightcontainer/App.java b/app/src/main/java/lightcontainer/App.java index 8faa7a9..862211f 100644 --- a/app/src/main/java/lightcontainer/App.java +++ b/app/src/main/java/lightcontainer/App.java @@ -20,7 +20,6 @@ import java.nio.file.Paths; public class App { - public static void main(String[] args) { setupVM(); Repository repositoryStorage = prepareStorage(); @@ -37,18 +36,10 @@ public class App { FileFrontEnd ffe = new FileFrontEnd(clientRep, storeRep, protocolRep); new UnicastServerListener(ffe, clientRep, protocolRep, repositoryStorage, repositoryStorage.getUnicastPort()); new MulticastServerListener(ffe, storeRep, protocolRep, repositoryStorage.getMulticastIp(), repositoryStorage.getMulticastPort(), repositoryStorage.getNetworkInterface()); - - // close repo et client et server. - - // Thread.sleep(60000); - - // clientRep.close(); - // storeRep.close(); } private static void initProtocols(Repository repositoryStorage, ProtocolRepository protocolRep) { initReadersProtocols(repositoryStorage, protocolRep); - initWritersProtocols(repositoryStorage, protocolRep); } diff --git a/app/src/main/java/lightcontainer/domains/client/StoreProcessor.java b/app/src/main/java/lightcontainer/domains/client/StoreProcessor.java index cc4fc98..d7158f6 100644 --- a/app/src/main/java/lightcontainer/domains/client/StoreProcessor.java +++ b/app/src/main/java/lightcontainer/domains/client/StoreProcessor.java @@ -8,6 +8,7 @@ import lightcontainer.protocol.ProtocolWriter; import java.io.*; import java.net.Socket; +import java.net.SocketException; import java.nio.charset.StandardCharsets; import java.util.Objects; @@ -82,18 +83,21 @@ public class StoreProcessor extends Thread implements AutoCloseable { this.client_run = true; while (this.client_run) { try { - if (!store.isConnected()) { - // TODO : Gérer déconnection - break; - } waitAction(); System.out.println("[SBE] Envoie commande : " + protocolResult.getCommand()); // Request this.writer.write(protocolResult.getCommand()); this.writer.flush(); - protocolResult.write(this.store.getOutputStream()); + try { + protocolResult.write(this.store.getOutputStream()); + } catch (IOException writeException) { // Si SBE fermé + System.out.println("STOPPER"); + // Envoie au client que la requête n'a pu être traitée + alertAvalaible(null); + break; + } // Response String responseCommand = this.reader.readLine(); @@ -116,10 +120,21 @@ public class StoreProcessor extends Thread implements AutoCloseable { alertAvalaible(null); } - } catch (IOException ignore) { - // TODO : Gérer déconnection + } catch (IOException exception) { + System.out.println("[ERROR] Problem with SBE (" + domain + ") : " + exception.getMessage()); + this.close(); } } + + // Fermeture du SBE + try { + this.reader.close(); + this.writer.close(); + this.store.close(); + this.fileFrontEnd.onStoreDisconnect(this.domain); + } catch (IOException ioException) { + System.out.println("[ERROR] Error while closing SBE (" + domain + ") : " + ioException.getMessage()); + } } /** @@ -140,6 +155,9 @@ public class StoreProcessor extends Thread implements AutoCloseable { private void alertAvalaible(ProtocolWriter.ProtocolResult responseCommand) { synchronized (this) { this.protocolResult = null; + if (responseCommand == null) { + this.close(); + } fileFrontEnd.onStoreAvailable(this, responseCommand); } } @@ -164,11 +182,8 @@ public class StoreProcessor extends Thread implements AutoCloseable { @Override public void close() { if (this.client_run) { - try { - this.client_run = false; - this.store.close(); - // TODO : Gérer déconnection (enlever du repo et prévenir client et FileFrontEnd) - } catch (IOException ignored) { } + this.client_run = false; + // TODO : Gérer déconnection (enlever du repo et prévenir client et FileFrontEnd) } } diff --git a/app/src/main/java/lightcontainer/domains/server/MulticastServerListener.java b/app/src/main/java/lightcontainer/domains/server/MulticastServerListener.java index c3d8b2d..ba40f10 100644 --- a/app/src/main/java/lightcontainer/domains/server/MulticastServerListener.java +++ b/app/src/main/java/lightcontainer/domains/server/MulticastServerListener.java @@ -10,6 +10,7 @@ import lightcontainer.utils.NetChooser; import java.io.IOException; import java.net.*; +import java.util.logging.Logger; /** * StoreMulticastRunnable @@ -69,27 +70,32 @@ public class MulticastServerListener implements Runnable { while (true) { // Read the packet received and build a string of characters this.listener.receive(packet); - String data = new String(packet.getData(), 0, packet.getLength()); - // Create a new StoreBacked (try used in the case of an error to maintain the listening loop) - try { - // TODO Récupérer le port du message du packet et le setup (add description of the line). - HelloRule.Result readerResult = protocolRep.executeReader(null, data); - System.out.printf("Nouveau SBE : Domain=%s | Port=%d\n", readerResult.getDomain(), readerResult.getPort()); - if (!this.repository.hasDomain(readerResult.getDomain())){ - Socket socket = new Socket(packet.getAddress(), readerResult.getPort()); - - // Create the store processor - StoreProcessor storeProcessor = new StoreProcessor(socket, readerResult.getDomain(), ffe, protocolRep); // TODO : Voir comment on procède get via repo ou ici ?! - - // Add the store processor to its repository - this.repository.addStore(storeProcessor); - } - } catch (IOException ignore) { - ignore.printStackTrace(); - } + onNewSbe(packet); } - } catch (Exception ignore) { + } catch (IOException ioException) { + System.out.println("[ERREUR] Multicast server can't start : " + ioException.getMessage()); + } + } + + private void onNewSbe(DatagramPacket packet) { + try { + String data = new String(packet.getData(), 0, packet.getLength()); + HelloRule.Result readerResult = protocolRep.executeReader(null, data); + + System.out.printf("Nouveau SBE : Domain=%s | Port=%d\n", readerResult.getDomain(), readerResult.getPort()); + + if (!this.repository.hasDomain(readerResult.getDomain())){ + Socket socket = new Socket(packet.getAddress(), readerResult.getPort()); + + // Create the store processor + StoreProcessor storeProcessor = new StoreProcessor(socket, readerResult.getDomain(), ffe, protocolRep); // TODO : Voir comment on procède get via repo ou ici ?! + + // Add the store processor to its repository + this.repository.addStore(storeProcessor); + } + } catch (IOException | ClassCastException exception) { + System.out.println("[ERREUR] Une SBE essaye de se connecter avec une mauvaise configuration : " + exception.getMessage()); } } @@ -117,6 +123,7 @@ public class MulticastServerListener implements Runnable { * @since 1.0 */ public void stop() { + repository.disconnectDomains(); this.listener.close(); } } diff --git a/app/src/main/java/lightcontainer/interfaces/MulticastSPR.java b/app/src/main/java/lightcontainer/interfaces/MulticastSPR.java index 385d556..5727721 100644 --- a/app/src/main/java/lightcontainer/interfaces/MulticastSPR.java +++ b/app/src/main/java/lightcontainer/interfaces/MulticastSPR.java @@ -28,4 +28,15 @@ public interface MulticastSPR { boolean hasDomain(String domain); int domainCount(); + + /** + * Déconnecte tous les SBE + */ + void disconnectDomains(); + + /** + * Permet de déconnecter un SBE + * @param domain Le domaine du SBE à déconnecter + */ + void closeStore(String domain); } diff --git a/app/src/main/java/lightcontainer/interfaces/StoreProcessorFFE.java b/app/src/main/java/lightcontainer/interfaces/StoreProcessorFFE.java index 26be68b..15e3561 100644 --- a/app/src/main/java/lightcontainer/interfaces/StoreProcessorFFE.java +++ b/app/src/main/java/lightcontainer/interfaces/StoreProcessorFFE.java @@ -14,4 +14,10 @@ public interface StoreProcessorFFE { * @param responseCommand */ void onStoreAvailable(StoreProcessor store, ProtocolWriter.ProtocolResult response); + + /** + * Permet de déconnecter un SBE + * @param domain Le domaine du SBE à déconnecter + */ + void onStoreDisconnect(String domain); } diff --git a/app/src/main/java/lightcontainer/protocol/ProtocolWriter.java b/app/src/main/java/lightcontainer/protocol/ProtocolWriter.java index cdcaa56..6449229 100644 --- a/app/src/main/java/lightcontainer/protocol/ProtocolWriter.java +++ b/app/src/main/java/lightcontainer/protocol/ProtocolWriter.java @@ -2,6 +2,7 @@ package lightcontainer.protocol; import lightcontainer.domains.client.Context; +import java.io.IOException; import java.io.OutputStream; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -51,7 +52,7 @@ public abstract class ProtocolWriter { * Redéfinissez cette méthode pour l'utiliser. * @param writer Buffer à remplir qui sera envoyer via le réseau */ - public void write(OutputStream writer) {} + public void write(OutputStream writer) throws IOException {} /** * Accesseur au contexte courant sur lequel opère la commande diff --git a/app/src/main/java/lightcontainer/protocol/rules/reader/RetrieveOkRule.java b/app/src/main/java/lightcontainer/protocol/rules/reader/RetrieveOkRule.java index 76be719..0cc6891 100644 --- a/app/src/main/java/lightcontainer/protocol/rules/reader/RetrieveOkRule.java +++ b/app/src/main/java/lightcontainer/protocol/rules/reader/RetrieveOkRule.java @@ -55,7 +55,7 @@ public class RetrieveOkRule extends ProtocolReader { FileReceiver fileReceiver = new FileReceiver(storagePath); fileReceiver.receiveFile(reader, this.filename, this.filesize); - // TODO fingerprint + // TODO fingerprint } } @@ -68,6 +68,11 @@ public class RetrieveOkRule extends ProtocolReader { @Override protected RetrieveOkRule.Result onExecuted(Context context, String... data) { RetrieveOkRule.Result result = new RetrieveOkRule.Result(context, data[HASHED_FILE_NAME], Integer.parseInt(data[FILE_SIZE]), data[HASHED_FILE_CONTENT]); + + // save encrypted file size into bundle + context.putDataInt("encryptedFileSize", Integer.parseInt(data[FILE_SIZE])); // TODO to long ?! + + // Set result command result.setResultCommand(protocolRep.executeWriter(context, GetFileOkRule.NAME, context.getDataString("fileName"), String.valueOf(context.getDataInt("fileSize"))), ResultCmdReceiver.CLIENT); return result; } diff --git a/app/src/main/java/lightcontainer/protocol/rules/writer/GetFileOkRule.java b/app/src/main/java/lightcontainer/protocol/rules/writer/GetFileOkRule.java index 83d355e..87dc71d 100644 --- a/app/src/main/java/lightcontainer/protocol/rules/writer/GetFileOkRule.java +++ b/app/src/main/java/lightcontainer/protocol/rules/writer/GetFileOkRule.java @@ -4,11 +4,11 @@ import lightcontainer.domains.client.Context; import lightcontainer.protocol.ProtocolWriter; import lightcontainer.utils.FileSender; +import java.io.IOException; import java.io.OutputStream; public class GetFileOkRule extends ProtocolWriter { // Constants - //"^GETFILE_OK ([^ !]{1,20}) ([0-9]{1,10})\r\n$" private static final String PATTERN = "^GETFILE_OK ([^ !]{1,20}) ([0-9]{1,10})\r\n$"; public static final String NAME = "GETFILE_OK"; // -- params @@ -37,17 +37,22 @@ public class GetFileOkRule extends ProtocolWriter { } /** - * Oh yeahh baby - * + * Oh yeahh baby by tonton EndMove ;-) * @param writer Buffer à remplir qui sera envoyer via le réseau */ @Override - public void write(OutputStream writer) { - super.write(writer); + public void write(OutputStream writer) throws IOException { System.out.printf("Sauvegarde du fichier : %s %d\n", this.filename, this.filesize); + System.out.println("Encrypted size for parsing: " + getContext().getDataInt("encryptedFileSize")+" normal: "+getContext().getDataInt("fileSize")); FileSender fileSender = new FileSender(storagePath); - fileSender.sendFile(getContext().getHashedFileName(this.filename), writer, this.filesize, getContext().getAesKey(), getContext().getDataString("fileIV")); + fileSender.sendFile( + getContext().getHashedFileName(this.filename), + writer, + getContext().getDataInt("encryptedFileSize"), // Encrypted file size (because data is parsing into AES system) + getContext().getAesKey(), + getContext().getDataString("fileIV") + ); } } diff --git a/app/src/main/java/lightcontainer/protocol/rules/writer/SendfileRule.java b/app/src/main/java/lightcontainer/protocol/rules/writer/SendfileRule.java index 2895da1..3eae4bd 100644 --- a/app/src/main/java/lightcontainer/protocol/rules/writer/SendfileRule.java +++ b/app/src/main/java/lightcontainer/protocol/rules/writer/SendfileRule.java @@ -4,6 +4,7 @@ import lightcontainer.domains.client.Context; import lightcontainer.protocol.ProtocolWriter; import lightcontainer.utils.FileSender; +import java.io.IOException; import java.io.OutputStream; /** @@ -40,7 +41,7 @@ public class SendfileRule extends ProtocolWriter { } @Override - public void write(OutputStream writer) { + public void write(OutputStream writer) throws IOException { super.write(writer); System.out.println("Envoie du fichier au SBE"); diff --git a/app/src/main/java/lightcontainer/repository/FileFrontEnd.java b/app/src/main/java/lightcontainer/repository/FileFrontEnd.java index fa1a129..acde83e 100644 --- a/app/src/main/java/lightcontainer/repository/FileFrontEnd.java +++ b/app/src/main/java/lightcontainer/repository/FileFrontEnd.java @@ -63,6 +63,11 @@ public class FileFrontEnd implements ClientHandlerFFE, StoreProcessorFFE { assignOtherTask(store); } + @Override + public void onStoreDisconnect(String domain) { + this.storeRepository.closeStore(domain); + } + private void assignOtherTask(StoreProcessor store) { Iterator it = tasks.iterator(); @@ -86,7 +91,6 @@ public class FileFrontEnd implements ClientHandlerFFE, StoreProcessorFFE { @Override public boolean canExecuteCommand(String domain) { - System.out.println("Peut on exécuter la commande ? " + domain); return domain == null ? storeRepository.domainCount() > 0 : storeRepository.hasDomain(domain); } } diff --git a/app/src/main/java/lightcontainer/repository/StoreProcessorRepository.java b/app/src/main/java/lightcontainer/repository/StoreProcessorRepository.java index f5219aa..163ee96 100644 --- a/app/src/main/java/lightcontainer/repository/StoreProcessorRepository.java +++ b/app/src/main/java/lightcontainer/repository/StoreProcessorRepository.java @@ -6,6 +6,7 @@ import lightcontainer.domains.server.MulticastServerListener; import lightcontainer.interfaces.MulticastSPR; import java.util.HashSet; +import java.util.Iterator; import java.util.Set; // TODO : C'est genre un ClientHandlerManager quoi hein, normal qu'il fasse blinder de chose ;) /** @@ -97,6 +98,33 @@ public class StoreProcessorRepository implements AutoCloseable, MulticastSPR { return handlers.size(); } + @Override + public void disconnectDomains() { + for (StoreProcessor handler : handlers) { + handler.close(); + } + } + + /** + * Permet de déconnecter un SBE + * + * @param domain Le domaine du SBE à déconnecter + */ + @Override + public void closeStore(String domain) { + Iterator it = this.handlers.iterator(); + + System.out.println("1 Nombre de SBE : " + handlers.size()); + while (it.hasNext()) { + StoreProcessor storeProcessor = it.next(); + if (storeProcessor.getDomain().equals(domain)) { + storeProcessor.close(); + it.remove(); + return; + } + } + } + /** * AutoClosable Function * Closes all StoreProcessor stored in this repository and deallocates all resources. diff --git a/app/src/main/java/lightcontainer/utils/AES_GCM.java b/app/src/main/java/lightcontainer/utils/AES_GCM.java index 4b7f09f..020d5de 100644 --- a/app/src/main/java/lightcontainer/utils/AES_GCM.java +++ b/app/src/main/java/lightcontainer/utils/AES_GCM.java @@ -202,10 +202,11 @@ public class AES_GCM { /** * Encrypt stream, with AES GCM. + * The output stream is automatically closed after processing. * * @param in InputStream to the input, flux to encrypt. * @param out OutputStream to the output, encrypted flux. - * @param fileSize Stream/file size. + * @param fileSize Stream/file size (! unencrypted size !). * @param key Base64 encoded secret key. * @param IV Base64 encoded vector. * @@ -213,7 +214,7 @@ public class AES_GCM { */ public static void encryptStream(InputStream in, OutputStream out, long fileSize, String key, String IV) throws AesGcmException { byte[] buffer = new byte[1024]; - int currentSize = 0; + long currentSize = 0; int bytes; try { // Make the cipher for encryption @@ -257,10 +258,11 @@ public class AES_GCM { /** * Decrypt stream, with AES GCM. + * The input stream is automatically closed after processing. * * @param in InputStream to the input, flux to decrypt. * @param out OutputStream to the output, decrypted flux. - * @param fileSize Stream/file size. + * @param fileSize Stream/file size (! encrypted size !). * @param key Base64 encoded secret key. * @param IV Base64 encoded vector. * @@ -268,7 +270,7 @@ public class AES_GCM { */ public static void decryptStream(InputStream in, OutputStream out, long fileSize, String key, String IV) throws AesGcmException { byte[] buffer = new byte[1024]; - int currentSize = 0; + long currentSize = 0; int bytes; try { // Make the cipher for decryption diff --git a/app/src/main/java/lightcontainer/utils/BCryptHasher.java b/app/src/main/java/lightcontainer/utils/BCryptHasher.java index 91c633e..2a4aafa 100644 --- a/app/src/main/java/lightcontainer/utils/BCryptHasher.java +++ b/app/src/main/java/lightcontainer/utils/BCryptHasher.java @@ -4,7 +4,6 @@ import org.mindrot.jbcrypt.BCrypt; public class BCryptHasher { - public static String hashPassword(String plainTextPassword) { return BCrypt.hashpw(plainTextPassword, BCrypt.gensalt()); } diff --git a/app/src/main/java/lightcontainer/utils/FileReceiver.java b/app/src/main/java/lightcontainer/utils/FileReceiver.java index fd6fc65..826bb1f 100644 --- a/app/src/main/java/lightcontainer/utils/FileReceiver.java +++ b/app/src/main/java/lightcontainer/utils/FileReceiver.java @@ -15,11 +15,8 @@ public class FileReceiver { AES_GCM.encryptStream(input, bosFile, fileSize, key, iv); - bosFile.flush(); - bosFile.close(); - File f = new File(String.format("%s/%s", path, fileName)); - return (int)f.length(); + return (int)f.length(); // TODO change the size to LONG } catch(IOException | AES_GCM.AesGcmException ex) { ex.printStackTrace(); if(bosFile != null) { try { bosFile.close(); } catch(Exception e) {} } diff --git a/app/src/main/java/lightcontainer/utils/FileSender.java b/app/src/main/java/lightcontainer/utils/FileSender.java index bc58fc5..008618a 100644 --- a/app/src/main/java/lightcontainer/utils/FileSender.java +++ b/app/src/main/java/lightcontainer/utils/FileSender.java @@ -8,27 +8,23 @@ public class FileSender { public FileSender(String path) { this.path = path; } - public boolean sendFile(String filename, OutputStream out, int fileSize, String aesKey, String iv) { + public boolean sendFile(String filename, OutputStream out, int fileSize, String aesKey, String iv) throws IOException { BufferedInputStream bisFile; System.out.printf("Envoie fichier : %s - %s - %s \n", filename, aesKey, iv); try { File f = new File(String.format("%s/%s", path, filename)); if(f.exists()) { bisFile = new BufferedInputStream(new FileInputStream(f)); - AES_GCM.decryptStream(bisFile, out, fileSize, aesKey, iv); - - bisFile.close(); return true; } else return false; } catch(IOException | AES_GCM.AesGcmException ex) { - ex.printStackTrace(); - return false; + throw new IOException(); } } - public boolean sendFile(String filename, OutputStream out) { + public boolean sendFile(String filename, OutputStream out) throws IOException { BufferedInputStream bisFile; int bytesReaded = 0; @@ -48,8 +44,7 @@ public class FileSender { } else return false; } catch(IOException ex) { - ex.printStackTrace(); - return false; + throw ex; } } diff --git a/app/src/main/java/lightcontainer/utils/Log.java b/app/src/main/java/lightcontainer/utils/Log.java new file mode 100644 index 0000000..0aa9e26 --- /dev/null +++ b/app/src/main/java/lightcontainer/utils/Log.java @@ -0,0 +1,10 @@ +package lightcontainer.utils; + +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +public class Log { + + +} diff --git a/app/src/main/java/lightcontainer/utils/NetChooser.java b/app/src/main/java/lightcontainer/utils/NetChooser.java index 066b7fb..d7ebcbe 100644 --- a/app/src/main/java/lightcontainer/utils/NetChooser.java +++ b/app/src/main/java/lightcontainer/utils/NetChooser.java @@ -59,7 +59,7 @@ public class NetChooser { if(interfaces.size() > 0) { String[] result = new String[interfaces.size()]; for (int i = 0; i < interfaces.size(); ++i) { - result[i] = interfaces.get(i).getDisplayName(); + result[i] = interfaces.get(i).getDisplayName() + " --> " + interfaces.get(i).getName(); } return result; } else diff --git a/app/src/main/java/lightcontainer/utils/SHA.java b/app/src/main/java/lightcontainer/utils/SHA.java index 6eb1c56..0f76efa 100644 --- a/app/src/main/java/lightcontainer/utils/SHA.java +++ b/app/src/main/java/lightcontainer/utils/SHA.java @@ -27,10 +27,10 @@ public class SHA { /* * BORROWING ENCRYPTION DEMO */ - File inFile = new File("D:\\HELMo.txt"); + File inFile = new File("D:\\HELMoCrypted.png"); System.out.println(hashStream( new FileInputStream(inFile), - (int)inFile.length() + inFile.length() )); System.out.println(hashFile( // caca5439dc02f2ced5094e95f1a3403d42127cda29feecd2eb1c68ff38a6fee3 @@ -44,12 +44,12 @@ public class SHA { * * @param in InputStream to the input, flux to hash. * @param fileSize Stream/file size. - *ichier, utilisé + * * @return Borrowing of the full current flux. * * @throws ShaException if an error occur. */ - public static String hashStream(InputStream in, int fileSize) throws ShaException { + public static String hashStream(InputStream in, long fileSize) throws ShaException { StringBuilder sb = new StringBuilder(); byte[] buffer = new byte[1024]; int currentSize = 0; @@ -87,7 +87,7 @@ public class SHA { public static String hashFile(String rootPath, String fileName) throws ShaException { try { File file = new File(String.format("%s/%s", rootPath, fileName)); - return hashStream(new FileInputStream(file), (int)file.length()); + return hashStream(new FileInputStream(file), file.length()); } catch (Exception e) { throw new ShaException(e); } diff --git a/app/src/main/resources/appdata.json b/app/src/main/resources/appdata.json index e1dc4af..1b71617 100644 --- a/app/src/main/resources/appdata.json +++ b/app/src/main/resources/appdata.json @@ -1,17 +1 @@ -{ - "unicast_port": 8000, - "multicast_ip": "224.66.66.1", - "multicast_port": 15502, - "network_interface": "", - "tls": true, - "storagePath": "C:\\Users\\ledou\\Documents\\ffe", - "users": [ - { - "name": "aaaaa", - "password": "$2a$10$nDCEDVwbNO/YDQ4qdRcxfuES4.aboluLzWouXXsk6vDoaWocv516W", - "aes_key": "kYtwHy9qJBg30WS6axWTFGVE0Ge5kpYiJJlC+COIEI4=", - "files": [ - ] - } - ] -} \ No newline at end of file +{"unicast_port":8000,"multicast_ip":"224.66.66.1","multicast_port":15502,"network_interface":"","tls":true,"storagePath":"/home/benjamin/ffe","users":[{"name":"aaaaa","password":"$2a$10$nDCEDVwbNO/YDQ4qdRcxfuES4.aboluLzWouXXsk6vDoaWocv516W","aes_key":"kYtwHy9qJBg30WS6axWTFGVE0Ge5kpYiJJlC+COIEI4=","files":[{"name":"README.md","fileNameSalt":"QnjgVrrW2KADUYUdeD/KcQ==","size":17,"iv":"E5JtY/JrH1B447F/my8Hkg==","storage":["lightcontainerSB01"]}]}]} \ No newline at end of file