summaryrefslogtreecommitdiffstats
path: root/EssentialsXMPP/src
diff options
context:
space:
mode:
Diffstat (limited to 'EssentialsXMPP/src')
-rw-r--r--EssentialsXMPP/src/com/earth2me/essentials/xmpp/Commandsetxmpp.java26
-rw-r--r--EssentialsXMPP/src/com/earth2me/essentials/xmpp/Commandxmpp.java39
-rw-r--r--EssentialsXMPP/src/com/earth2me/essentials/xmpp/Commandxmppspy.java46
-rw-r--r--EssentialsXMPP/src/com/earth2me/essentials/xmpp/EssentialsXMPP.java115
-rw-r--r--EssentialsXMPP/src/com/earth2me/essentials/xmpp/EssentialsXMPPPlayerListener.java55
-rw-r--r--EssentialsXMPP/src/com/earth2me/essentials/xmpp/UserManager.java78
-rw-r--r--EssentialsXMPP/src/com/earth2me/essentials/xmpp/XMPPManager.java283
-rw-r--r--EssentialsXMPP/src/config.yml17
-rw-r--r--EssentialsXMPP/src/plugin.yml20
9 files changed, 679 insertions, 0 deletions
diff --git a/EssentialsXMPP/src/com/earth2me/essentials/xmpp/Commandsetxmpp.java b/EssentialsXMPP/src/com/earth2me/essentials/xmpp/Commandsetxmpp.java
new file mode 100644
index 000000000..bb81d8ec0
--- /dev/null
+++ b/EssentialsXMPP/src/com/earth2me/essentials/xmpp/Commandsetxmpp.java
@@ -0,0 +1,26 @@
+package com.earth2me.essentials.xmpp;
+
+import com.earth2me.essentials.User;
+import com.earth2me.essentials.commands.EssentialsCommand;
+import com.earth2me.essentials.commands.NotEnoughArgumentsException;
+import org.bukkit.Server;
+
+
+public class Commandsetxmpp extends EssentialsCommand
+{
+ public Commandsetxmpp()
+ {
+ super("setxmpp");
+ }
+
+ @Override
+ protected void run(Server server, User user, String commandLabel, String[] args) throws Exception
+ {
+ if (args.length < 1)
+ {
+ throw new NotEnoughArgumentsException();
+ }
+
+ EssentialsXMPP.getInstance().setAddress(user, args[0]);
+ }
+}
diff --git a/EssentialsXMPP/src/com/earth2me/essentials/xmpp/Commandxmpp.java b/EssentialsXMPP/src/com/earth2me/essentials/xmpp/Commandxmpp.java
new file mode 100644
index 000000000..49aa2a6ea
--- /dev/null
+++ b/EssentialsXMPP/src/com/earth2me/essentials/xmpp/Commandxmpp.java
@@ -0,0 +1,39 @@
+package com.earth2me.essentials.xmpp;
+
+import com.earth2me.essentials.Console;
+import com.earth2me.essentials.commands.EssentialsCommand;
+import com.earth2me.essentials.commands.NotEnoughArgumentsException;
+import org.bukkit.Server;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+
+
+public class Commandxmpp extends EssentialsCommand
+{
+ public Commandxmpp()
+ {
+ super("xmpp");
+ }
+
+ @Override
+ protected void run(Server server, CommandSender sender, String commandLabel, String[] args) throws Exception
+ {
+ if (args.length < 2)
+ {
+ throw new NotEnoughArgumentsException();
+ }
+
+ final String message = getFinalArg(args, 1);
+ final String address = EssentialsXMPP.getInstance().getAddress(args[0]);
+ if (address == null)
+ {
+ sender.sendMessage("§cThere are no players matching that name.");
+ }
+ else
+ {
+ final String senderName = sender instanceof Player ? ess.getUser(sender).getDisplayName() : Console.NAME;
+ sender.sendMessage("[" + senderName + ">" + address + "] " + message);
+ EssentialsXMPP.getInstance().sendMessage(address, "[" + senderName + "] " + message);
+ }
+ }
+}
diff --git a/EssentialsXMPP/src/com/earth2me/essentials/xmpp/Commandxmppspy.java b/EssentialsXMPP/src/com/earth2me/essentials/xmpp/Commandxmppspy.java
new file mode 100644
index 000000000..c42049b22
--- /dev/null
+++ b/EssentialsXMPP/src/com/earth2me/essentials/xmpp/Commandxmppspy.java
@@ -0,0 +1,46 @@
+package com.earth2me.essentials.xmpp;
+
+import com.earth2me.essentials.commands.EssentialsCommand;
+import com.earth2me.essentials.commands.NotEnoughArgumentsException;
+import java.util.List;
+import org.bukkit.Server;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+
+
+public class Commandxmppspy extends EssentialsCommand
+{
+ public Commandxmppspy()
+ {
+ super("xmppspy");
+ }
+
+ @Override
+ protected void run(Server server, CommandSender sender, String commandLabel, String[] args) throws Exception
+ {
+ if (args.length < 1)
+ {
+ throw new NotEnoughArgumentsException();
+ }
+
+ final List<Player> matches = server.matchPlayer(args[0]);
+
+ if (matches.isEmpty())
+ {
+ sender.sendMessage("§cThere are no players matching that name.");
+ }
+
+ for (Player p : matches)
+ {
+ try
+ {
+ final boolean toggle = EssentialsXMPP.getInstance().toggleSpy(p);
+ sender.sendMessage("XMPP Spy " + (toggle ? "enabled" : "disabled") + " for " + p.getDisplayName());
+ }
+ catch (Exception ex)
+ {
+ sender.sendMessage("Error: " + ex.getMessage());
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/EssentialsXMPP/src/com/earth2me/essentials/xmpp/EssentialsXMPP.java b/EssentialsXMPP/src/com/earth2me/essentials/xmpp/EssentialsXMPP.java
new file mode 100644
index 000000000..212275738
--- /dev/null
+++ b/EssentialsXMPP/src/com/earth2me/essentials/xmpp/EssentialsXMPP.java
@@ -0,0 +1,115 @@
+package com.earth2me.essentials.xmpp;
+
+import com.earth2me.essentials.Essentials;
+import com.earth2me.essentials.IEssentials;
+import com.earth2me.essentials.Util;
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import org.bukkit.command.Command;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+import org.bukkit.event.Event.Priority;
+import org.bukkit.event.Event.Type;
+import org.bukkit.plugin.PluginManager;
+import org.bukkit.plugin.java.JavaPlugin;
+
+
+public class EssentialsXMPP extends JavaPlugin implements IEssentialsXMPP
+{
+ private static final Logger LOGGER = Logger.getLogger("Minecraft");
+ private static EssentialsXMPP instance = null;
+ private transient UserManager users;
+ private transient XMPPManager xmpp;
+
+ public static IEssentialsXMPP getInstance()
+ {
+ return instance;
+ }
+
+ @Override
+ public void onEnable()
+ {
+ instance = this;
+
+ final IEssentials ess = Essentials.getStatic();
+ if (ess == null)
+ {
+ LOGGER.log(Level.SEVERE, "Failed to load Essentials before EssentialsXMPP");
+ }
+
+ final PluginManager pluginManager = getServer().getPluginManager();
+ final EssentialsXMPPPlayerListener playerListener = new EssentialsXMPPPlayerListener(ess);
+ pluginManager.registerEvent(Type.PLAYER_JOIN, playerListener, Priority.Monitor, this);
+ pluginManager.registerEvent(Type.PLAYER_CHAT, playerListener, Priority.Monitor, this);
+ pluginManager.registerEvent(Type.PLAYER_QUIT, playerListener, Priority.Monitor, this);
+
+ users = new UserManager(this.getDataFolder());
+ xmpp = new XMPPManager(this);
+
+ ess.addReloadListener(users);
+
+ if (!this.getDescription().getVersion().equals(Essentials.getStatic().getDescription().getVersion())) {
+ LOGGER.log(Level.WARNING, Util.i18n("versionMismatchAll"));
+ }
+ LOGGER.info(Util.format("loadinfo", this.getDescription().getName(), this.getDescription().getVersion(), Essentials.AUTHORS));
+ }
+
+ @Override
+ public void onDisable()
+ {
+ xmpp.disconnect();
+ }
+
+ @Override
+ public boolean onCommand(final CommandSender sender, final Command command, final String commandLabel, final String[] args)
+ {
+ return Essentials.getStatic().onCommandEssentials(sender, command, commandLabel, args, EssentialsXMPP.class.getClassLoader(), "com.earth2me.essentials.xmpp.Command");
+ }
+
+ @Override
+ public void setAddress(final Player user, final String address) throws Exception
+ {
+ final String username = user.getName().toLowerCase();
+ instance.users.setAddress(username, address);
+ }
+
+ @Override
+ public String getAddress(final String name)
+ {
+ return instance.users.getAddress(name);
+ }
+
+ @Override
+ public boolean toggleSpy(final Player user) throws Exception
+ {
+ final String username = user.getName().toLowerCase();
+ final boolean spy = !instance.users.isSpy(username);
+ instance.users.setSpy(username, spy);
+ return spy;
+ }
+
+ @Override
+ public String getAddress(final Player user)
+ {
+ return instance.users.getAddress(user.getName());
+ }
+
+ @Override
+ public void sendMessage(final Player user, final String message)
+ {
+ instance.xmpp.sendMessage(instance.users.getAddress(user.getName()), message);
+ }
+
+ @Override
+ public void sendMessage(final String address, final String message)
+ {
+ instance.xmpp.sendMessage(address, message);
+ }
+
+ @Override
+ public List<String> getSpyUsers()
+ {
+ return instance.users.getSpyUsers();
+ }
+}
diff --git a/EssentialsXMPP/src/com/earth2me/essentials/xmpp/EssentialsXMPPPlayerListener.java b/EssentialsXMPP/src/com/earth2me/essentials/xmpp/EssentialsXMPPPlayerListener.java
new file mode 100644
index 000000000..a64bd17af
--- /dev/null
+++ b/EssentialsXMPP/src/com/earth2me/essentials/xmpp/EssentialsXMPPPlayerListener.java
@@ -0,0 +1,55 @@
+package com.earth2me.essentials.xmpp;
+
+import com.earth2me.essentials.IEssentials;
+import com.earth2me.essentials.User;
+import org.bukkit.event.player.PlayerChatEvent;
+import org.bukkit.event.player.PlayerJoinEvent;
+import org.bukkit.event.player.PlayerListener;
+import org.bukkit.event.player.PlayerQuitEvent;
+
+
+class EssentialsXMPPPlayerListener extends PlayerListener
+{
+ private final transient IEssentials ess;
+
+ EssentialsXMPPPlayerListener(final IEssentials ess)
+ {
+ super();
+ this.ess = ess;
+ }
+
+ @Override
+ public void onPlayerJoin(final PlayerJoinEvent event)
+ {
+ final User user = ess.getUser(event.getPlayer());
+ sendMessageToSpyUsers("Player " + user.getDisplayName() + " joined the game");
+ }
+
+ @Override
+ public void onPlayerChat(final PlayerChatEvent event)
+ {
+ final User user = ess.getUser(event.getPlayer());
+ sendMessageToSpyUsers(String.format(event.getFormat(), user.getDisplayName(), event.getMessage()));
+ }
+
+ @Override
+ public void onPlayerQuit(final PlayerQuitEvent event)
+ {
+ final User user = ess.getUser(event.getPlayer());
+ sendMessageToSpyUsers("Player " + user.getDisplayName() + " left the game");
+ }
+
+ private void sendMessageToSpyUsers(final String message)
+ {
+ try
+ {
+ for (String address : EssentialsXMPP.getInstance().getSpyUsers())
+ {
+ EssentialsXMPP.getInstance().sendMessage(address, message);
+ }
+ }
+ catch (Exception ex)
+ {
+ }
+ }
+}
diff --git a/EssentialsXMPP/src/com/earth2me/essentials/xmpp/UserManager.java b/EssentialsXMPP/src/com/earth2me/essentials/xmpp/UserManager.java
new file mode 100644
index 000000000..878a6b28e
--- /dev/null
+++ b/EssentialsXMPP/src/com/earth2me/essentials/xmpp/UserManager.java
@@ -0,0 +1,78 @@
+package com.earth2me.essentials.xmpp;
+
+import com.earth2me.essentials.EssentialsConf;
+import com.earth2me.essentials.IConf;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+
+public class UserManager implements IConf
+{
+ private final transient EssentialsConf users;
+ private final transient List<String> spyusers = new ArrayList<String>();
+ private final static String ADDRESS = "address";
+ private final static String SPY = "spy";
+
+ public UserManager(final File folder)
+ {
+ users = new EssentialsConf(new File(folder, "users.yml"));
+ reloadConfig();
+ }
+
+ public final boolean isSpy(final String username)
+ {
+ return users.getBoolean(username.toLowerCase() + "." + SPY, false);
+ }
+
+ public void setSpy(final String username, boolean spy) throws Exception
+ {
+ setUser(username.toLowerCase(), getAddress(username), spy);
+ }
+
+ public final String getAddress(final String username)
+ {
+ return users.getString(username.toLowerCase() + "." + ADDRESS, null);
+ }
+
+ public void setAddress(final String username, final String address) throws Exception
+ {
+ setUser(username.toLowerCase(), address, isSpy(username));
+ }
+
+ public List<String> getSpyUsers()
+ {
+ return spyusers;
+ }
+
+ private void setUser(String username, String address, boolean spy) throws Exception
+ {
+ final Map<String, Object> userdata = new HashMap<String, Object>();
+ userdata.put(ADDRESS, address);
+ userdata.put(SPY, spy);
+ users.setProperty(username, userdata);
+ users.save();
+ reloadConfig();
+ }
+
+ @Override
+ public final void reloadConfig()
+ {
+ users.load();
+ spyusers.clear();
+ final List<String> keys = users.getKeys(null);
+ for (String key : keys)
+ {
+ if (isSpy(key))
+ {
+ final String address = getAddress(key);
+ if (address != null)
+ {
+ spyusers.add(address);
+ }
+ }
+ }
+ }
+}
diff --git a/EssentialsXMPP/src/com/earth2me/essentials/xmpp/XMPPManager.java b/EssentialsXMPP/src/com/earth2me/essentials/xmpp/XMPPManager.java
new file mode 100644
index 000000000..e9da61440
--- /dev/null
+++ b/EssentialsXMPP/src/com/earth2me/essentials/xmpp/XMPPManager.java
@@ -0,0 +1,283 @@
+package com.earth2me.essentials.xmpp;
+
+import com.earth2me.essentials.EssentialsConf;
+import com.earth2me.essentials.IConf;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
+import org.bukkit.command.ConsoleCommandSender;
+import org.bukkit.craftbukkit.CraftServer;
+import org.bukkit.entity.Player;
+import org.bukkit.plugin.java.JavaPlugin;
+import org.jivesoftware.smack.Chat;
+import org.jivesoftware.smack.ChatManager;
+import org.jivesoftware.smack.ChatManagerListener;
+import org.jivesoftware.smack.ConnectionConfiguration;
+import org.jivesoftware.smack.MessageListener;
+import org.jivesoftware.smack.Roster.SubscriptionMode;
+import org.jivesoftware.smack.XMPPConnection;
+import org.jivesoftware.smack.XMPPException;
+import org.jivesoftware.smack.packet.Message;
+import org.jivesoftware.smack.packet.Presence;
+
+
+public class XMPPManager extends Handler implements MessageListener, ChatManagerListener, IConf
+{
+ private static final Logger LOGGER = Logger.getLogger("Minecraft");
+ private final transient EssentialsConf config;
+ private transient XMPPConnection connection;
+ private transient ChatManager chatManager;
+ private final transient Map<String, Chat> chats = Collections.synchronizedMap(new HashMap<String, Chat>());
+ private final transient JavaPlugin parent;
+ private transient List<String> logUsers;
+ private transient Level logLevel;
+ private transient boolean ignoreLagMessages = true;
+
+ public XMPPManager(final JavaPlugin parent)
+ {
+ super();
+ this.parent = parent;
+ config = new EssentialsConf(new File(parent.getDataFolder(), "config.yml"));
+ config.setTemplateName("/config.yml", EssentialsXMPP.class);
+ reloadConfig();
+ }
+
+ public void sendMessage(final String address, final String message)
+ {
+ Chat chat = null;
+ try
+ {
+ if (address == null || address.isEmpty())
+ {
+ return;
+ }
+ startChat(address);
+ chat = chats.get(address);
+ if (chat == null)
+ {
+ return;
+ }
+ chat.sendMessage(message.replaceAll("§[0-9a-f]", ""));
+ }
+ catch (XMPPException ex)
+ {
+ if (chat != null)
+ {
+ chat.removeMessageListener(this);
+ chats.remove(address);
+ LOGGER.log(Level.WARNING, "Failed to send xmpp message.", ex);
+ }
+ }
+ }
+
+ @Override
+ public void processMessage(final Chat chat, final Message msg)
+ {
+ final String message = msg.getBody();
+ if (message.length() > 0)
+ {
+ switch (message.charAt(0))
+ {
+ case '@':
+ sendPrivateMessage(chat, message);
+ break;
+ case '/':
+ sendCommand(chat, message);
+ break;
+ default:
+ parent.getServer().broadcastMessage("<XMPP:" + chat.getParticipant() + "> " + message);
+ }
+ }
+ }
+
+ private void connect()
+ {
+ final String server = config.getString("xmpp.server");
+ if (server == null)
+ {
+ LOGGER.log(Level.WARNING, "config broken for xmpp");
+ return;
+ }
+ final int port = config.getInt("xmpp.port", 5222);
+ final String serviceName = config.getString("xmpp.servicename", server);
+ final String xmppuser = config.getString("xmpp.user");
+ final String password = config.getString("xmpp.password");
+ final ConnectionConfiguration cc = new ConnectionConfiguration(server, port, serviceName);
+ final StringBuilder sb = new StringBuilder();
+ sb.append("Connecting to xmpp server ").append(server).append(":").append(port);
+ sb.append(" as user ").append(xmppuser).append(".");
+ LOGGER.log(Level.INFO, sb.toString());
+ cc.setSASLAuthenticationEnabled(config.getBoolean("xmpp.sasl-enabled", false));
+ cc.setSendPresence(true);
+ cc.setReconnectionAllowed(true);
+ connection = new XMPPConnection(cc);
+ try
+ {
+ connection.connect();
+ connection.login(xmppuser, password);
+ connection.getRoster().setSubscriptionMode(SubscriptionMode.accept_all);
+ chatManager = connection.getChatManager();
+ chatManager.addChatListener(this);
+ }
+ catch (XMPPException ex)
+ {
+ LOGGER.log(Level.WARNING, "Failed to connect to server: " + server, ex);
+ }
+ }
+
+ public final void disconnect()
+ {
+ if (connection != null)
+ {
+ connection.disconnect(new Presence(Presence.Type.unavailable));
+ }
+ }
+
+ @Override
+ public void chatCreated(final Chat chat, final boolean createdLocally)
+ {
+ if (!createdLocally)
+ {
+ chat.addMessageListener(this);
+ final Chat old = chats.put(chat.getParticipant(), chat);
+ if (old != null)
+ {
+ old.removeMessageListener(this);
+ }
+ }
+ }
+
+ @Override
+ public final void reloadConfig()
+ {
+ config.load();
+ synchronized (chats)
+ {
+ disconnect();
+ chats.clear();
+ connect();
+ }
+ LOGGER.removeHandler(this);
+ if (config.getBoolean("log-enabled", false))
+ {
+ LOGGER.addHandler(this);
+ logUsers = config.getStringList("log-users", new ArrayList<String>());
+ final String level = config.getString("log-level", "info");
+ try
+ {
+ logLevel = Level.parse(level.toUpperCase());
+ }
+ catch (IllegalArgumentException e)
+ {
+ logLevel = Level.INFO;
+ }
+ ignoreLagMessages = config.getBoolean("ignore-lag-messages", true);
+ }
+ }
+
+ @Override
+ public void publish(final LogRecord logRecord)
+ {
+ try
+ {
+ if (ignoreLagMessages && logRecord.getMessage().equals("Can't keep up! Did the system time change, or is the server overloaded?"))
+ {
+ return;
+ }
+ if (logRecord.getLevel().intValue() >= logLevel.intValue())
+ {
+ for (String user : logUsers)
+ {
+ startChat(user);
+ final Chat chat = chats.get(user);
+ if (chat != null)
+ {
+ chat.sendMessage(String.format("[" + logRecord.getLevel().getLocalizedName() + "] " + logRecord.getMessage(), logRecord.getParameters()));
+ }
+ }
+ }
+ }
+ catch (Exception e)
+ {
+ // Ignore all exception and just print them to the console
+ // Otherwise we create a loop.
+ System.out.println(e.getMessage());
+ e.printStackTrace();
+ }
+ }
+
+ @Override
+ public void flush()
+ {
+ }
+
+ @Override
+ public void close() throws SecurityException
+ {
+ }
+
+ private void startChat(final String address) throws XMPPException
+ {
+ if (chatManager == null)
+ {
+ return;
+ }
+ synchronized (chats)
+ {
+ if (!chats.containsKey(address))
+ {
+ final Chat chat = chatManager.createChat(address, this);
+ if (chat == null)
+ {
+ throw new XMPPException("Could not start Chat with " + address);
+ }
+ chats.put(address, chat);
+ }
+ }
+ }
+
+ private void sendPrivateMessage(final Chat chat, final String message)
+ {
+ final String[] parts = message.split(" ", 2);
+ if (parts.length == 2)
+ {
+ final List<Player> matches = parent.getServer().matchPlayer(parts[0].substring(1));
+
+ if (matches.isEmpty())
+ {
+ try
+ {
+ chat.sendMessage("User " + parts[0] + " not found");
+ }
+ catch (XMPPException ex)
+ {
+ LOGGER.log(Level.WARNING, "Failed to send xmpp message.", ex);
+ }
+ } else {
+ for (Player p : matches)
+ {
+ p.sendMessage("[" + chat.getParticipant() + ">" + p.getDisplayName() + "] " + message);
+ }
+ }
+ }
+ }
+
+ private void sendCommand(final Chat chat, final String message)
+ {
+ if (config.getStringList("op-users", new ArrayList<String>()).contains(chat.getParticipant()))
+ {
+ final CraftServer craftServer = (CraftServer)parent.getServer();
+ if (craftServer != null)
+ {
+ craftServer.dispatchCommand(new ConsoleCommandSender(craftServer), message.substring(1));
+ }
+ }
+ }
+}
diff --git a/EssentialsXMPP/src/config.yml b/EssentialsXMPP/src/config.yml
new file mode 100644
index 000000000..82e2887aa
--- /dev/null
+++ b/EssentialsXMPP/src/config.yml
@@ -0,0 +1,17 @@
+xmpp:
+ server: 'example.com'
+ user: 'name@example.com'
+ password: 'password'
+# servicename: 'example.com'
+# port: 5222
+# sasl-enabled: false
+
+op-users:
+# - 'name@example.com'
+
+
+log-enabled: false
+# Level is minimum level that should be send: info, warning, severe
+log-level: warning
+log-users:
+# - 'name@example.com' \ No newline at end of file
diff --git a/EssentialsXMPP/src/plugin.yml b/EssentialsXMPP/src/plugin.yml
new file mode 100644
index 000000000..3ccc0b55e
--- /dev/null
+++ b/EssentialsXMPP/src/plugin.yml
@@ -0,0 +1,20 @@
+# This determines the command prefix when there are conflicts (/name:home, /name:help, etc.)
+name: EssentialsXMPP
+main: com.earth2me.essentials.xmpp.EssentialsXMPP
+# Note to developers: This next line cannot change, or the automatic versioning system will break.
+version: TeamCity
+website: http://www.earth2me.net:8001/
+description: Provides xmpp communication.
+authors:
+ - snowleo
+depend: [Essentials]
+commands:
+ setxmpp:
+ description: set your xmpp address
+ usage: /<command> address
+ xmpp:
+ description: send a message to a player
+ usage: /<command> player message
+ xmppspy:
+ description: toggle xmpp spy for all message
+ usage: /<command> player \ No newline at end of file