MultiTenantTestApp.java

/***************************************************************************
   Copyright 2015 Emily Estes

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.
***************************************************************************/
package net.metanotion.multitenant;


import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import javax.inject.Named;
import javax.sql.DataSource;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import net.metanotion.authident.UserToken;
import net.metanotion.contentstore.BasicStore;
import net.metanotion.contentstore.Collection;
import net.metanotion.contentstore.ContentStore;
import net.metanotion.contentstore.Entry;
import net.metanotion.contentstore.Header;
import net.metanotion.contentstore.StoreUtils;
import net.metanotion.formsauth.AuthAPI;
import net.metanotion.formsauth.AuthFactory;
import net.metanotion.formsauth.AuthFsm;
import net.metanotion.formsauth.AuthMailer;
import net.metanotion.formsauth.AuthStates;
import net.metanotion.formsauth.FormsAuth;
import net.metanotion.formsauth.FormsAuthAPI;
import net.metanotion.io.ClassLoaderFileSystem;
import net.metanotion.io.JavaFileSystem;
import net.metanotion.multitenant.adminapp.AdminAppFactory;
import net.metanotion.multitenant.BasicTenantExport;
import net.metanotion.multitenant.BasicTenantUsers;
import net.metanotion.multitenant.ExportImport;
import net.metanotion.multitenant.MultiTenant;
import net.metanotion.multitenant.SimpleMultiTenantAppFactory;
import net.metanotion.multitenant.TenantExport;
import net.metanotion.multitenant.TenantUsers;
import net.metanotion.scripting.DictionaryServer;
import net.metanotion.scripting.ObjectServer;
import net.metanotion.scripting.Scriptable;
import net.metanotion.scripting.ScriptingObjectMagic;
import net.metanotion.simpletemplate.ChainedResourceFactory;
import net.metanotion.simpletemplate.MountPointResources;
import net.metanotion.simpletemplate.ResourceDispatcher;
import net.metanotion.simpletemplate.ResourceFactory;
import net.metanotion.simpletemplate.StaticResources;
import net.metanotion.simpletemplate.TemplateResources;
import net.metanotion.sql.DbUtil;
import net.metanotion.sql.SchemaPoolSwitcher;
import net.metanotion.sql.SchemaGenerator;
import net.metanotion.sql.SequenceExecutor;
import net.metanotion.sqlauthident.SQLRealm;
import net.metanotion.util.AppUtil;
import net.metanotion.util.ChainedIterator;
import net.metanotion.util.DictionaryDispatcher;
import net.metanotion.util.Dispatcher;
import net.metanotion.util.DispatcherGenerator;
import net.metanotion.util.Extends;
import net.metanotion.util.Json;
import net.metanotion.util.MapDictionaryAdapter;
import net.metanotion.util.Service;
import net.metanotion.util.StateMachine;
import net.metanotion.util.Unknown;
import net.metanotion.util.UnknownMagic;
import net.metanotion.web.concrete.HttpException;
import net.metanotion.web.concrete.HttpUtil;
import net.metanotion.web.concrete.InstanceSessionFactory;
import net.metanotion.web.concrete.JsonUtil;
import net.metanotion.web.concrete.SessionFactoryAsInstance;
import net.metanotion.web.concrete.StaticWebApp;
import net.metanotion.web.concrete.URIPrefixDispatcher;
import net.metanotion.web.concrete.WebInterfaceDispatcher;
import net.metanotion.web.servlets.ServerUtil;
import net.metanotion.web.AppFactory;
import net.metanotion.web.FileUpload;
import net.metanotion.web.HttpGet;
import net.metanotion.web.HttpPost;
import net.metanotion.web.HttpValues;
import net.metanotion.web.Instance;
import net.metanotion.web.Session;

public final class MultiTenantTestApp implements AppFactory {
	private static final Logger logger = LoggerFactory.getLogger(MultiTenantTestApp.class);

	private static final ResourceFactory anon;
	private static final ResourceFactory user;
	static {
		try {
			anon = new TemplateResources(
				new JavaFileSystem(System.getProperty("user.dir") +
					"/src/main/resources/net/metanotion/multitenant/adminapp/assets/anon/templates"),
				new StaticResources(
					new JavaFileSystem(System.getProperty("user.dir") +
						"/src/main/resources/net/metanotion/multitenant/adminapp/assets/anon/files"),
					new MountPointResources(new StaticResources(JsonUtil.getJavaScript()), "/js")));

			user = new ChainedResourceFactory(
				new TemplateResources(
					new JavaFileSystem(System.getProperty("user.dir") +
						"/src/main/resources/net/metanotion/multitenant/adminapp/assets/user/templates"),
					new StaticResources(
						new JavaFileSystem(System.getProperty("user.dir") +
							"/src/main/resources/net/metanotion/multitenant/adminapp/assets/user/files"))),
				anon);
		} catch (java.io.IOException ioe) {
			throw new RuntimeException(ioe);
		}
	}

	private static final AdminAppFactory.ResourceFactoryConfiguration resourcesConfig =
		new AdminAppFactory.ResourceFactoryConfiguration() {
			@Override public ResourceFactory userResources() { return user; }
			@Override public ResourceFactory anonResources() { return anon; }
		};

	public static final String INSTANCE_PREFIX = "/example_";
	public static final String FRONT_PAGE_PREFIX = "/";
	public static final String ADMIN_PREFIX = "/admin";
	public static final String ADMIN_SCHEMA = "public";

	public static final int ARGS_PORT = 0;
	public static final int ARGS_DB_URL = 1;
	public static final int ARGS_DB_USER = 2;
	public static final int ARGS_DB_PASS = 3;
	public static final int ARGS_BACKUP_FOLDER = 4;
	public static final int ARGS_LOG_CONFIG = 5;
	public static final int ARGS_SOLO_SCHEMA = 6;

	/** This is a simple scripting object to be used by the front page app to list the currently running tenant instances. */
	public static final class TenantLister {
		private final Iterable<String> instances;
		/** Create a new listing object. This works well with the
		{@link net.metanotion.multitenant.adminapp.AdminAppFactory#getDefaultInstanceList()} method.
			@param instances The list of instances (this is presumably "backed" and each iteration should reflect the
				current reality.
		*/
		public TenantLister(Iterable<String> instances) { this.instances = instances; }
		/** Retrieve an HTML formatted sequence of list items linking to the currently running stances.
			@return The HTML list of running instances.
		*/
		public String listTenants() {
			final StringBuilder sb = new StringBuilder();
			for(final String prefix: instances) {
				sb.append("<li><a href=\"");
				sb.append(prefix);
				sb.append("/index.html\">");
				sb.append(prefix);
				sb.append("</a></li>");
			}
			return sb.toString();
		}
	}

	/** This starts up the app server for the multitenant example app. While this app uses only command line
	arguments, the quantity of parameters would be better served by a configuration file, but since this is an example
	app server, we will just document the parameters.
		@param args An array of command line parameters, the following parameters must be given in order:
			<ul>
				<li>Port - The TCP port the app server should listen on.</li>
				<li>JDBC URL - The database connection URL for the JDBC driver.</li>
				<li>JDBC username - The database username for the JDBC driver.</li>
				<li>JDBC password - The database password for the JDBC driver.</li>
				<li>Content store folder - The file folder to store backups and user uploaded assets in.</li>
				<li>The XML file used to configure logging engine(SLF4J).</li>
			</ul>
	*/
	public static void main(final String[] args) {
		try {
			AppUtil.startLogging(args, ARGS_LOG_CONFIG);

			final DataSource dataSource = DbUtil.startDBConnectionPool(args[ARGS_DB_URL],
				args[ARGS_DB_USER],
				args[ARGS_DB_PASS]);

			final MultiTenant server = new MultiTenant(dataSource);

			final AppFactory appFactory = new MultiTenantTestApp(new File(args[ARGS_BACKUP_FOLDER]));

			/// Create an instance of the built-in basic tenant instance control panel web application.
			final AdminAppFactory admin = new AdminAppFactory(server,
				new SchemaPoolSwitcher(dataSource, ADMIN_SCHEMA),
				INSTANCE_PREFIX,
				resourcesConfig);

			/// Manually set up scriptable objects so the front page app can list tenant instances via server side templating.
			final HashMap<String,Object> objs = new HashMap<>();
			objs.put("", new TenantLister(admin.getDefaultInstanceList()));
			final HashMap<String,Dispatcher> disp = new HashMap<>();
			final DispatcherGenerator d = new DispatcherGenerator();
			disp.put("", d.get(TenantLister.class));
			final DictionaryDispatcher objServerDispatchers = new DictionaryDispatcher(new MapDictionaryAdapter<>(disp));

			///. Create an instance of the "static" web app to serve as a front page with some links.
			final AppFactory frontPage = new StaticWebApp(new TemplateResources(
					new ClassLoaderFileSystem(MultiTenantTestApp.class.getClassLoader(),
						"net/metanotion/web/examples/multitenant/frontpage")),
				new DictionaryServer(objServerDispatchers, new MapDictionaryAdapter<>(objs)));

			final HashMap<String,AppFactory> fixedApps = new HashMap<>();
			fixedApps.put(ADMIN_PREFIX, admin);
			fixedApps.put(FRONT_PAGE_PREFIX, frontPage);

			final SimpleMultiTenantAppFactory tenantContainer =
				new SimpleMultiTenantAppFactory(new MapDictionaryAdapter<>(fixedApps),
					appFactory,
					INSTANCE_PREFIX,
					admin.getDefaultInstanceList());

			logger.debug("Begin Listening");
			ServerUtil.launchJettyServer(Integer.parseInt(args[ARGS_PORT]),
				server.dispatcher(),
				server.sessionInitializer(tenantContainer));
		} catch (final Exception e) {
			logger.error("Critical error, {}", e);
		}
	}

	/** This starts up one of the example app instances running as a simple single tenant application running at "/". */
	public static final class Solo {
		/** This starts up one of the example app instances running as a simple single tenant application running at "/".
			@param args An array of command line parameters, the following parameters must be given in order:
				<ul>
					<li>Port - The TCP port the app server should listen on.</li>
					<li>JDBC URL - The database connection URL for the JDBC driver.</li>
					<li>JDBC username - The database username for the JDBC driver.</li>
					<li>JDBC password - The database password for the JDBC driver.</li>
					<li>Content store folder - The file folder to store backups and user uploaded assets in.</li>
					<li>The XML file used to configure logging engine(SLF4J).</li>
					<li>Postgresql schema path for the application instance.</li>
				</ul>
		*/
		public static void main(final String[] args) {
			try {
				AppUtil.startLogging(args, ARGS_LOG_CONFIG);

				final DataSource dataSource = DbUtil.startDBConnectionPool(args[ARGS_DB_URL],
					args[ARGS_DB_USER],
					args[ARGS_DB_PASS]);

				final AppFactory appFactory = new MultiTenantTestApp(new File(args[ARGS_BACKUP_FOLDER]));

				final Instance app = appFactory.newInstance(new SchemaPoolSwitcher(dataSource, args[ARGS_SOLO_SCHEMA]),
					"",
					null);

				logger.debug("Begin Listening");
				ServerUtil.launchJettyServer(Integer.parseInt(args[ARGS_PORT]),
					appFactory.dispatcher(),
					new InstanceSessionFactory(app));
			} catch (final Exception e) {
				logger.error("Critical error, {}", e);
			}
		}
	}

	public static final String AUTH_PREFIX = "/auth/";
	public static final String FILE_API_PREFIX = "/fileManager/";

	// AppFactory methods
	private static final URIPrefixDispatcher dispatcher;
	static {
		try {
			dispatcher = new URIPrefixDispatcher()
				.addDispatcher(AUTH_PREFIX, FormsAuth.dispatcher())
				.addDispatcher(FILE_API_PREFIX, new WebInterfaceDispatcher<>(FileManager.class))
				.addDispatcher("", new ResourceDispatcher());
		} catch (final IOException ioe) { throw new RuntimeException(ioe); }
	}

	@Override public URIPrefixDispatcher dispatcher() { return dispatcher; }

	public static final String FILE_COLLECTION = "/Files";
	public static final String BACKUPS_COLLECTION = "/Backups";
	public static final List<String> COLLECTIONS = Arrays.asList(new String[] { FILE_COLLECTION, BACKUPS_COLLECTION });

	@Override public Instance newInstance(final DataSource ds, final String prefix, final Unknown container) {
		try {
			final ContentStore store = StoreUtils.getStore(ds, es, prefix.replace("/", ""), storeFolder, COLLECTIONS);
			final SQLRealm authDatabase = new SQLRealm(ds);
			final AuthMailer mailer = AuthFactory.LOGGING_AUTH_MAILER;
			final AuthAPI authApi = AuthFactory.instance(ds, authDatabase, mailer, prefix + AUTH_PREFIX);
			final TenantUsers tu = new BasicTenantUsers(ds, authDatabase, authDatabase, mailer);
			final BasicTenantExport bte = new BasicTenantExport(store, BACKUPS_COLLECTION, es, new EI(ds));
			final Example e = new Example(ds, prefix, authApi, store, bte, tu);
			return SessionFactoryAsInstance.wrap(AuthFsm.wrap(e, e), e);
		} catch (final IOException ioe) {
			throw new RuntimeException(ioe);
		}
	}

	public static final String SQL_SCHEMA = "INSERT INTO RoleSet(RoleName) VALUES ('Role 1'), ('Test');";

	private static final SchemaGenerator appSchema = new SchemaGenerator() {
		@Override public Iterator<String> openSchema() {
			return new ChainedIterator<String>(Arrays.asList(SQLRealm.schemaFactory().openSchema(),
				BasicStore.schemaFactory().openSchema(),
				SequenceExecutor.readerSequencer(new StringReader(SQL_SCHEMA))));
		}
	};

	@Override public Iterator<String> openSchema() { return appSchema.openSchema(); }

	private static final SchemaGenerator schema = new SchemaGenerator() {
		@Override public Iterator<String> openSchema() {
			return AdminAppFactory.schemaFactory().openSchema();
		}
	};

	/** Static interface method used by the {@link net.metanotion.sql.BuildDB} utility.
		@return A schema generator that provides the schema needed by this application.
	*/
	public static SchemaGenerator schemaFactory() { return schema; }

	public static final int DEFAULT_POOL_SIZE = 5;
	public static final ScheduledExecutorService es = Executors.newScheduledThreadPool(DEFAULT_POOL_SIZE);

	private final File storeFolder;
	public MultiTenantTestApp(final File storeFolder) {
		this.storeFolder = storeFolder;
	}

	public static final class EI implements ExportImport {
		private final DataSource ds;
		public EI(final DataSource ds) {
			this.ds = ds;
		}
		@Override public String getBackupFilename() { return "test"; }
		@Override public String getBackupMimeType() { return "text/plain"; }

		@Override public void resetInstance() {
			try {
				Thread.sleep(30000);
			} catch (final InterruptedException ie) {
				throw new RuntimeException(ie);
			}
		}

		@Override public void exportTo(final OutputStream out) {
			try {
				logger.debug("Starting Export");
				final OutputStreamWriter o = new OutputStreamWriter(out, java.nio.charset.StandardCharsets.UTF_8);
				o.write("Testing\n");
				o.write("1.\n");
				o.write("2.\n");
				o.write("3.");
				o.close();
				Thread.sleep(30000);
				logger.debug("Finishing Export");
			} catch (final InterruptedException | IOException ex) {
				throw new RuntimeException(ex);
			}
		}

		@Override public void importFrom(final InputStream in) {
			try {
				Thread.sleep(30000);
			} catch (final InterruptedException ie) {
				throw new RuntimeException(ie);
			}
		}
	}


	/////////////////
	public static final class Example implements AuthStates<Example, Session<Example>>, Unknown {
		private static final UnknownMagic u = new UnknownMagic(Example.class);
		private static final ResourceFactory authResources = new TemplateResources(
			new ClassLoaderFileSystem(
					MultiTenantTestApp.class.getClassLoader(),
					"net/metanotion/web/examples/multitenant/formsauth"),
			new TemplateResources(FormsAuth.DEFAULT_TEMPLATE_FS));

		@Service public final ResourceFactory appResources;
		{
			try {
			this.appResources = new TemplateResources(
				new JavaFileSystem(System.getProperty("user.dir") +
					"/src/main/resources/net/metanotion/web/examples/multitenant/app"),
			new MountPointResources(new StaticResources(JsonUtil.getJavaScript()), "/js"));
			} catch (final IOException ioe) { throw new RuntimeException(ioe); }
		}

		protected final String prefix;
		protected final FormsAuth formsAuth;
		protected final Object fileApi;
		@Service public final ContentStore store;
		@Service public final TenantExport te;
		@Service public final TenantUsers tu;
		@Service public final DataSource ds;
		@Service public final FormsAuthAPI formsApi;

		public Example(final DataSource ds,
				final String prefix,
				final AuthAPI authApi,
				final ContentStore store,
				final TenantExport te,
				final TenantUsers tu) {
			this.prefix = prefix;
			this.ds = ds;
			this.formsAuth = new FormsAuth(this.prefix + AUTH_PREFIX, authApi, authResources);
			this.formsApi = this.formsAuth.instance();
			this.te = te;
			this.tu = tu;
			this.store = store;
			this.fileApi = JsonUtil.makeWebApi(FileManager.class, prefix + FILE_API_PREFIX);
		}

		// Unknown
		@Override public <I> I lookupInterface(final Class<I> theInterface) { return u.lookupInterface(theInterface, this); }

		// AuthStates
		@Override public Session<Example> login(final UserToken uid, final Session<Example> currentState) {
			return new User(currentState.appInstance(), uid);
		}

		@Override public Session<Example> logout(final Session<Example> currentState) {
			return new Anonymous(currentState.appInstance());
		}

		@Override public Session<Example> newSession(final Example app, final StateMachine sm) {
			return new Anonymous(app);
		}

		public FileManager.EntryList list(final int count, int offset) {
			final ArrayList<FileManager.FileEntry> list = new ArrayList<>();
			final Collection files = this.store.getCollection(FILE_COLLECTION);
			for(final Header h: files.header(count, offset)) {
				final FileManager.FileEntry f = new FileManager.FileEntry();
				f.description = h.Title;
				f.fileId = h.id;
				f.offset = offset;
				offset++;
				list.add(f);
			}
			return new FileManager.EntryList(list);
		}

		public HttpValues download(final long fileId) {
			final Entry e = this.store.getEntry(fileId);
			if(e.getCollection().oid() != store.getCollection(FILE_COLLECTION).oid()) {
				return HttpUtil.new404("Invalid File");
			}
			return StoreUtils.get(e);
		}
	}

	public static final String FILE_RESPONSE_ID = "IFRAMERESPONSEID";
	public static final String FILE_ID = "fileId";

	/** This interface describes the operations to list, view, delete, and upload files to the example web
	application. */
	public interface FileManager {
		/** Retrieve the JSON description of this API endpoint.
			@return A JSON object describing the API.
		*/
		@HttpGet @Json public Object api();

		/** This class represents information about a file. */
		public static final class FileEntry {
			public long fileId;
			public String filename;
			public String description;
			public int offset;
		}

		public static final class EntryList {
			/** The list of file entries. */
			public final List<FileEntry> entries;
			/** Create a new entry list object.
				@param entries list of file entries.
			*/
			public EntryList(final List<FileEntry> entries) { this.entries = entries; }
		}

		/** Retrieve a list of files available.
			@param count The maximum number of files to return.
			@param offset The starting offset of the files to return.
			@return A list of files.
		*/
		@HttpGet @Json public EntryList list(@Named("count") int count, @Named("offset") int offset);

		/** Download a file.
			@param fileId The file to download.
			@return The HTTP response to send the file.
		*/
		@HttpGet public HttpValues download(@Named(FILE_ID) long fileId);

		/** Upload a file.
			@param responseTag The identifier to use for notifications in the response for the iframeFormManager.js lib.
			@param file The file to store.
			@return A web page with a JS payload notifying the iframeFormManager.js lib that the file was uploaded successfully.
		*/
		@HttpPost public String upload(@Named(FILE_RESPONSE_ID) String responseTag, @Named("file") FileUpload file);

		/** Delete a file.
			@param fileId The file to delete.
			@return The HTTP response to send the file.
		*/
		@HttpPost @Json public Object remove(@Named(FILE_ID) long fileId);
	}

	public static final class Anonymous implements Session<Example>, FileManager {
		private static final ScriptingObjectMagic<Anonymous> som = new ScriptingObjectMagic<>(Anonymous.class);
		private static final UnknownMagic u = new UnknownMagic(Anonymous.class);

		@Extends public final Example app;
		@Service public final ObjectServer os = som.instance(this);
		@Service public final UserToken uid = null;
		@Scriptable("AuthUI") public final Unknown authUI;
		public Anonymous(final Example app) {
			this.app = app;
			this.authUI = app.formsAuth.newSession(this);
		}

		@Override public <I> I lookupInterface(final Class<I> theInterface) { return u.lookupInterface(theInterface, this); }
		@Override public Example appInstance() { return app; }

		// FileManager
		@Override public Object api() { return app.fileApi; }
		@Override public EntryList list(final int count, final int offset) { return app.list(count, offset); }
		@Override public HttpValues download(final long fileId) { return app.download(fileId); }
		@Override public String upload(final String responseTag, final FileUpload file) {
			throw new HttpException(HttpUtil.new403("Forbidden"));
		}
		@Override public Object remove(final long fileId) {
			throw new HttpException(HttpUtil.new403("Forbidden"));
		}
	}

	public static final class User implements Session<Example>, FileManager {
		private static final ScriptingObjectMagic<User> som = new ScriptingObjectMagic<>(User.class);
		private static final UnknownMagic u = new UnknownMagic(User.class);

		@Extends public final Example app;
		@Service public final ObjectServer os = som.instance(this);
		@Service public final UserToken uid;
		@Scriptable("AuthUI") public final Unknown authUI;

		public User(final Example app, final UserToken uid) {
			this.app = app;
			this.uid = uid;
			this.authUI = app.formsAuth.newSession(this);
		}

		@Override public <I> I lookupInterface(final Class<I> theInterface) { return u.lookupInterface(theInterface, this); }
		@Override public Example appInstance() { return app; }

		// FileManager
		@Override public Object api() { return app.fileApi; }
		@Override public EntryList list(final int count, int offset) { return app.list(count, offset); }
		@Override public HttpValues download(final long fileId) { return app.download(fileId); }
		@Override public String upload(final String responseTag, final FileUpload file) {
			final Collection files = app.store.getCollection(FILE_COLLECTION);
			final Entry e = files.append(file.getClientFilename());
			e.update(file, file.getClientFilename(), file.getMIMEType());
			return "<!DOCTYPE html><html><head><meta charset=\"UTF-8\">" +
				"<script>window.parent.iframeLoadDequeue('" + responseTag + "', { });</script>" +
				"</head><body></body></html>";
		}
		@Override public Object remove(final long fileId) {
			final Entry e = app.store.getEntry(fileId);
			if(e.getCollection().oid() != app.store.getCollection(FILE_COLLECTION).oid()) {
				throw new RuntimeException("Invalid file id");
			}
			e.delete();
			return "{ \"success\": true }";
		}
	}
}