Please wait. This can take some minutes ...
Many resources are needed to download a project. Please understand that we have to compensate our server costs. Thank you in advance.
Project price only 1 $
You can buy this project and download/modify it how often you want.
org.boothub.web.BootHubWebApp.groovy Maven / Gradle / Ivy
/*
* Copyright 2017 the original author or authors.
*
* 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 org.boothub.web
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import groovy.json.JsonParserType
import groovy.json.JsonSlurper
import groovy.transform.stc.ClosureParams
import groovy.transform.stc.SimpleType
import groovy.util.logging.Slf4j
import org.beryx.textio.web.RatpackTextIoApp
import org.beryx.textio.web.WebTextTerminal
import org.boothub.BootHub
import org.boothub.GitHubUtil
import org.boothub.Result
import org.boothub.Result.Type
import org.boothub.repo.*
import org.boothub.repo.heroku.HerokuDBApi
import org.boothub.repo.postgresql.PGJobDAO
import org.kohsuke.github.GitHub
import org.pac4j.core.context.Pac4jConstants
import org.pac4j.core.profile.UserProfile
import org.pac4j.oauth.client.GitHubClient
import org.pac4j.oauth.profile.OAuth20Profile
import ratpack.exec.Blocking
import ratpack.exec.Promise
import ratpack.func.Action
import ratpack.func.Factory
import ratpack.handlebars.HandlebarsModule
import ratpack.handling.Context
import ratpack.pac4j.RatpackPac4j
import ratpack.session.Session
import ratpack.session.SessionData
import java.nio.charset.StandardCharsets
import java.nio.file.Paths
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.function.Function
import static org.boothub.Result.Type.*
@Slf4j
class BootHubWebApp {
static final String DEFAULT_BASE_PATH = System.properties["java.io.tmpdir"]
static final int DEFAULT_PORT = 4567
static final String ENV_APP_CFG = "BOOTHUB_WEB_APP_CFG"
static final String DEFAULT_APP_CFG = "boothub-web-app.cfg"
static final String OAUTH_CFG = "boothub-oauth.cfg"
static final String ENV_OAUTH_NAME = "BOOTHUB_OAUTH_NAME"
static final String ENV_OAUTH_SCOPE = "BOOTHUB_OAUTH_SCOPE"
static final String ENV_OAUTH_CALLBACK_URL = "BOOTHUB_OAUTH_CALLBACK_URL"
static final String ENV_OAUTH_KEY = "BOOTHUB_OAUTH_KEY"
static final String ENV_OAUTH_SECRET = "BOOTHUB_OAUTH_SECRET"
static final String ROOT_REDIRECT = (System.getenv('BOOTHUB_ROOT_REDIRECT') ?: 'app')
static final long CLI_URL_DELAY_MINUTES = (System.getenv('BOOTHUB_CLI_URL_DELAY_MINUTES') ?: '11') as long
static final long BOT_DELAY_MINUTES = (System.getenv('BOOTHUB_BOT_DELAY_MINUTES') ?: '10') as long
static final long HOUSEKEEPING_DELAY_MINUTES = (System.getenv('BOOTHUB_HOUSEKEEPING_DELAY_MINUTES') ?: '17') as long
static final long HOUSEKEEPING_AGE_MINUTES = (System.getenv('BOOTHUB_HOUSEKEEPING_AGE_MINUTES') ?: '120') as long
static final String BOT_USER = System.getenv('BOOTHUB_BOT_USER')
static final String BOT_PASSWORD = System.getenv('BOOTHUB_BOT_PASSWORD')
final RepoManager repoManager
int port = DEFAULT_PORT
String outputDirBasePath = DEFAULT_BASE_PATH
String zipFilesBasePath = DEFAULT_BASE_PATH
private boolean browserAutoStart
private String cliDownloadUrl = ''
private static final JsonSlurper jsonSlurper = new JsonSlurper().setType(JsonParserType.INDEX_OVERLAY);
private static final Gson gson = new GsonBuilder()
.registerTypeAdapter(Version, new Version.GsonSerializer())
.setPrettyPrinting()
.create()
final GitHubClient gitHubClient = createGitHubClient()
BootHubWebApp(RepoManager repoManager) {
this.repoManager = repoManager
String cfgFileName = System.getenv(ENV_APP_CFG) ?: DEFAULT_APP_CFG
def cfgFile = new File(cfgFileName)
ConfigObject cfg = null
if(cfgFile.isFile()) {
log.debug("Reading configuration file: $cfgFile")
cfg = new ConfigSlurper().parse(cfgFile.toURI().toURL())
} else {
def cfgResource = BootHubWebApp.getClass().getResource("/$cfgFileName")
if(cfgResource) {
log.debug("Reading configuration resource: $cfgResource")
cfg = new ConfigSlurper().parse(cfgResource)
}
}
if(cfg) {
log.debug("Using configuration: $cfg")
if(cfg.port) withPort(cfg.port)
if(cfg.outputDirBasePath) withOutputDirBasePath(cfg.outputDirBasePath)
if(cfg.zipFilesBasePath) withZipFilesBasePath(cfg.zipFilesBasePath)
if(cfg.browserAutoStart) withBrowserAutoStart(cfg.browserAutoStart)
}
log.info("""
Starting BootHubWebApp with:
port = $port
outputDirBasePath = $outputDirBasePath
zipFilesBasePath = $zipFilesBasePath
""".stripIndent())
}
BootHubWebApp withPort(int port) {
this.port = port
this
}
BootHubWebApp withBrowserAutoStart(boolean browserAutoStart) {
this.browserAutoStart = browserAutoStart
this
}
BootHubWebApp withOutputDirBasePath(String outputDirBasePath) {
this.outputDirBasePath = outputDirBasePath ?: DEFAULT_BASE_PATH
this
}
void setOutputDirBasePath(String outputDirBasePath) {
withOutputDirBasePath(outputDirBasePath)
}
BootHubWebApp withZipFilesBasePath(String zipFilesBasePath) {
this.zipFilesBasePath = zipFilesBasePath ?: DEFAULT_BASE_PATH
this
}
void setZipFilesBasePath(String zipFilesBasePath) {
withZipFilesBasePath(zipFilesBasePath)
}
private static String getZipId(String outputPath) {
def zipId = outputPath
if(outputPath) {
def startPos = outputPath.lastIndexOf(BootHub.ZIP_FILE_PREFIX)
if(startPos >= 0) {
zipId = outputPath.substring(startPos + BootHub.ZIP_FILE_PREFIX.length())
}
if(zipId.endsWith(BootHub.ZIP_FILE_SUFFIX)) {
zipId = zipId.substring(0, zipId.length() - BootHub.ZIP_FILE_SUFFIX.length())
}
}
zipId
}
void execute() {
def webTextTerminal = new WebTextTerminal()
webTextTerminal.setTimeoutNotEmpty(1000)
webTextTerminal.setTimeoutHasAction(100)
webTextTerminal.registerUserInterruptHandler({textTerm -> textTerm.abort()}, true)
WebTextIoExecutor webTextIoExecutor = new WebTextIoExecutor()
.withPort(port)
.withBrowserAutoStart(browserAutoStart)
def app = new RatpackTextIoApp({textIO, runnerData ->
def resultData = new BootHubWeb(textIO, repoManager, repoManager.repoCache, runnerData)
.withOutputDirBasePath(outputDirBasePath)
.withZipFilesBasePath(zipFilesBasePath)
.execute()
if(!resultData) {
textIO.dispose()
} else {
if(resultData.outputPath) {
resultData.outputPath = getZipId(resultData.outputPath)
}
def jsonResultData = gson.toJson(resultData)
textIO.dispose(jsonResultData)
}
}, webTextTerminal)
.withSessionDataProvider{session ->
def sessionData = [:]
session.get(Pac4jConstants.USER_PROFILE)
.then {
it.ifPresent { UserProfile profile ->
sessionData.accessToken = profile.attributes.access_token
sessionData.ghUserId = profile.attributes.login
sessionData.ghUserName = profile.attributes.name
}
sessionData.completed = true
}
sessionData
}
Executors.newSingleThreadScheduledExecutor().scheduleWithFixedDelay({ -> updateCliUrl()}, 0, CLI_URL_DELAY_MINUTES, TimeUnit.MINUTES)
Executors.newSingleThreadScheduledExecutor().scheduleWithFixedDelay({ -> updateJsonRepo()}, 1, BOT_DELAY_MINUTES, TimeUnit.MINUTES)
Executors.newSingleThreadScheduledExecutor().scheduleWithFixedDelay({ -> performHousekeeping()}, HOUSEKEEPING_DELAY_MINUTES, HOUSEKEEPING_DELAY_MINUTES, TimeUnit.MINUTES)
app.server.bindings << ({binding -> binding.module(HandlebarsModule, {cfg -> cfg.templatesPath('static')})} as Action)
app.server.handlers << ({ chain ->
chain
.all(RatpackPac4j.authenticator("callback", gitHubClient))
.path("") {ctx ->
ctx.redirect(ROOT_REDIRECT)
}
.path("index.html") {ctx ->
ctx.redirect(ROOT_REDIRECT)
}
.path("app") {ctx ->
Session session = ctx.get(Session)
session.data.then{sessionData ->
def model = getModel(sessionData)
sessionData.set("autoRun", false)
ctx.render(Paths.get(BootHubWebApp.getClass().getResource("/static/app.html").toURI()))
}
}
.prefix("zip/:fName") { zipChain ->
zipChain
.all{ ctx ->
def fName = ctx.pathTokens.fName
log.debug("fname: $fName")
def zipFilePath = Paths.get("$zipFilesBasePath/${BootHub.ZIP_FILE_PREFIX}${fName}${BootHub.ZIP_FILE_SUFFIX}")
log.debug("zipFilePath: $zipFilePath")
def fileId = ctx.request.queryParams.ghProjectId ?: "project"
ctx.response.contentType("application/zip")
ctx.response.headers.add("Content-Disposition", "attachment; filename=\"${fileId}.zip\"")
ctx.render(zipFilePath)
}
}
.path("state") { ctx ->
Session session = ctx.get(Session)
session.data.then { sessionData ->
def stateMap = getModel(sessionData)
ctx.render(gson.toJson(stateMap))
}
}
.prefix("auth") { authChain ->
authChain
.path("logout") { ctx ->
def route = ctx.pathTokens.route ?: 'home'
log.debug("route: $route")
RatpackPac4j.logout(ctx).then { -> ctx.render("You have been signed out") }
}
.prefix("login/:route") { loginChain ->
loginChain
.all(RatpackPac4j.requireAuth(GitHubClient))
.all{ ctx ->
def route = ctx.pathTokens.route ?: 'home'
log.info("### initial route: $route")
switch(route) {
case 'home':
def skeletonUrl = ctx.request.queryParams.skeletonUrl
def exec = Boolean.parseBoolean(ctx.request.queryParams.exec)
if(skeletonUrl || exec) {
route += "/$exec"
if(skeletonUrl) {
route += "/${URLEncoder.encode(skeletonUrl, StandardCharsets.UTF_8.name())}"
}
}
break;
default:
break;
}
log.debug("Redirecting login to: $route")
ctx.redirect("/app#/$route")
}
}
}
.prefix("api") { apiChain -> apiChain
.get("cliDownloadUrl") { ctx -> renderSuccessValue(ctx, cliDownloadUrl) }
//########################################################
//############# REPO MANAGER ###########################
.get("skeletons") { ctx ->
def searchOpts = SkeletonSearchOptions.fromParameterMap(ctx.request.queryParams)
handleQuerySkeletons(ctx, searchOpts)
}
.post("querySkeletons") { ctx ->
ctx.request.body.map{it.text}.then{ bodyText ->
log.debug("bodyText: $bodyText")
def searchOpts = jsonSlurper.parseText(bodyText) as SkeletonSearchOptions
handleQuerySkeletons(ctx, searchOpts)
}
}
.post("addSkeleton") { ctx -> handleAddSkeleton(ctx) }
.post("deleteSkeleton") { ctx -> handleDeleteSkeleton(ctx) }
.post("deleteSkeletonEntry") { ctx -> handleDeleteSkeletonEntry(ctx) }
.post("queryTags") { ctx -> handleQueryTags(ctx) }
.post("addTag") { ctx -> handleAddTag(ctx) }
.post("deleteTag") { ctx -> handleDeleteTag(ctx) }
.post("queryOwners") { ctx -> handleQueryOwners(ctx) }
.post("addOwner") { ctx -> handleAddOwner(ctx) }
.post("deleteOwner") { ctx -> handleDeleteOwner(ctx) }
}
} as Action)
webTextIoExecutor.execute(app)
}
private void updateJsonRepo() {
try {
log.debug("Start updating boothub-repo")
if(!BOT_USER || !BOT_PASSWORD) {
log.warn("updateJsonRepo(): bot credentials not set.")
return
}
def result = repoManager.getSkeletons(SkeletonSearchOptions.ALL_COMPACT)
if(!result.successful) {
log.warn("updateJsonRepo(): getSkeletons() returned: $result")
return
}
def jsonText = gson.toJson(result.value)
def ghApi = GitHubUtil.connectUsingPassword(BOT_USER, BOT_PASSWORD)
def ghRepo = ghApi.getRepository('boothub-org/boothub-repo')
if(!ghRepo) {
log.warn("updateJsonRepo(): cannot retrieve boothub-repo")
return
}
boolean updated = GitHubUtil.updateContent(ghRepo, jsonText, "updated by $BOT_USER", 'repo.json', false)
if(updated) {
log.info("boothub-repo updated")
}
} catch (Exception e) {
log.error("Failed to update boothub-repo.", e)
}
}
private void performHousekeeping() {
try {
log.debug("Start performing housekeeping")
String tmpDir = System.properties['java.io.tmpdir'] ?: '/tmp'
long maxTime = System.currentTimeMillis() - 60000L * HOUSEKEEPING_AGE_MINUTES
File[] toDelete = new File(tmpDir).listFiles({f ->
if(!f.name.startsWith('boothub-')) return false
if(f.name.startsWith('boothub-cache')) return false
return (f.lastModified() < maxTime)
} as FileFilter)
if(toDelete) {
toDelete.each {f ->
boolean deleted = f.file ? f.delete() : f.directory ? f.deleteDir() : false
log.info("Housekeeping: $f deleted: $deleted")
}
}
} catch (Exception e) {
log.error("Failed to perform housekeeping.", e)
}
}
private void updateCliUrl() {
def url = retrieveCliUrl()
log.info("CLI download URL: $url")
if(url) cliDownloadUrl = url
}
private String retrieveCliUrl() {
try {
def github = GitHub.connectAnonymously()
def repo = github.getRepository('boothub-org/boothub')
def zipAsset = {it.name.matches("boothub-.+\\.zip")}
def asset = repo.latestRelease.assets.find(zipAsset)
if(asset) return asset.browserDownloadUrl
log.warn("Latest release has no downloadable zip.")
repo.listReleases().find {it.assets.find {it.name.matches("boothub-.+\\.zip")}}
for(def rel : repo.listReleases()) {
asset = rel.assets.find(zipAsset)
if(asset) return asset.browserDownloadUrl
}
null
} catch (Exception e) {
log.error("Failed to retrieve the CLI URL.", e)
}
}
private void handleQuerySkeletons(Context ctx, SkeletonSearchOptions searchOpts) {
log.debug("searchOptions: $searchOpts")
handleResultFactory(ctx) { repoManager.getSkeletons(searchOpts) }
}
private void handleAddSkeleton(Context ctx) {
withAuthenticatedUser(ctx) { parameters, userId ->
def url = parameters.url
handleResultFactory(ctx) { repoManager.addSkeleton(url, userId) }
}
}
private void handleDeleteSkeleton(Context ctx) {
handleDataManipulation(ctx,
{ prm -> "Skeleton $prm.skeletonId not found."},
{ parameters ->
def skeletonId = parameters.skeletonId
repoManager.deleteSkeleton(skeletonId)
})
}
private void handleDeleteSkeletonEntry(Context ctx) {
handleDataManipulation(ctx,
{ prm -> "Skeleton $prm.skeletonId version $prm.version not found."},
{ parameters ->
def skeletonId = parameters.skeletonId
def version = parameters.version
repoManager.deleteEntry(skeletonId, version)
})
}
private void handleQueryTags(Context ctx) {
withParameters(ctx) { parameters ->
def skeletonId = parameters.skeletonId
handleResultFactory(ctx) { repoManager.getTags(skeletonId) }
}
}
private void handleAddTag(Context ctx) {
handleDataManipulation(ctx,
{ prm -> "Tag $prm.tag of $prm.skeletonId already exists."},
{ parameters ->
def skeletonId = parameters.skeletonId
def tag = parameters.tag
repoManager.addTag(skeletonId, tag)
})
}
private void handleDeleteTag(Context ctx) {
handleDataManipulation(ctx,
{ prm -> "Tag $prm.tag of $prm.skeletonId not found."},
{ parameters ->
def skeletonId = parameters.skeletonId
def tag = parameters.tag
repoManager.deleteTag(skeletonId, tag)
})
}
private void handleQueryOwners(Context ctx) {
withParameters(ctx) { parameters ->
def skeletonId = parameters.skeletonId
handleResultFactory(ctx) { repoManager.getOwners(skeletonId) }
}
}
private void handleAddOwner(Context ctx) {
handleDataManipulation(ctx,
{ prm -> "User $prm.ownerId is already owner of skeleton $prm.skeletonId."},
{ parameters ->
String skeletonId = parameters.skeletonId
String ownerId = parameters.ownerId
repoManager.addOwner(skeletonId, ownerId)
})
}
private void handleDeleteOwner(Context ctx) {
handleDataManipulation(ctx,
{ prm -> "User $prm.ownerId is not an owner of $prm.skeletonId."},
{ parameters ->
String skeletonId = parameters.skeletonId
String ownerId = parameters.ownerId
repoManager.deleteOwner(skeletonId, ownerId)
})
}
private void handleDataManipulation(Context ctx, Function noUpdateMessageProvider,
@ClosureParams(value=SimpleType, options="java.util.Map") Closure handler) {
withOwnerCheck(ctx) { Map parameters, String userId ->
safeBlocking(ctx) { handler.call(parameters) }
.then { Result result ->
if(!result.successful) renderResult(ctx, result)
else {
int count = result.value
if(count > 0) {
renderSuccess(ctx)
} else {
renderWarningMessage(ctx, noUpdateMessageProvider.apply(parameters))
}
}
}
}
}
private void withOwnerCheck(Context ctx,
@ClosureParams(value=SimpleType, options="java.util.Map, java.lang.String") Closure handler) {
withAuthenticatedUser(ctx) { Map parameters, String userId ->
def skeletonId = parameters.skeletonId
safeBlocking(ctx) { repoManager.getOwners(skeletonId) }
.then { Result ownersResult ->
if(!ownersResult.successful) renderResult(ctx, ownersResult)
else {
def owners = ownersResult.value
if(!(userId in owners)) {
renderErrorMessage(ctx, "User $userId is not an owner of skeleton $skeletonId.")
} else {
handler.call(parameters, userId)
}
}
}
}
}
private static void withAuthenticatedUser(Context ctx,
@ClosureParams(value=SimpleType, options="java.util.Map, java.lang.String") Closure handler) {
ctx.get(Session).data.then { sessionData ->
def userId = getModel(sessionData)?.loggedInUserId
if (!userId) {
renderErrorMessage(ctx, "You must be signed in to perform this operation.")
} else {
withParameters(ctx) { parameters -> handler.call(parameters, userId)}
}
}
}
private static void withParameters(Context ctx, @ClosureParams(value=SimpleType, options="java.util.Map") Closure handler) {
ctx.request.body.map { body -> jsonSlurper.parseText(body.text) as Map }.
then { parameters -> handler.call(parameters) }
}
private static Promise> handleResultFactory(Context ctx, Factory> factory) {
safeBlocking(ctx) { factory.create() }
.then{result -> renderResult(ctx, result)}
}
private static Promise safeBlocking(Context ctx, Factory factory) {
Blocking.get {
factory.create()
}
.onError { throwable ->
log.error("Operation failed.", throwable)
renderErrorMessage(ctx, "Operation failed.")
}
}
private static void renderResult(Context ctx, Result result) {
ctx.render(gson.toJson(result))
}
private static void renderResult(Context ctx, Type resultType, String message, V value) {
renderResult(ctx, new Result(type: resultType, message: message, value: value))
}
private static void renderSuccess(Context ctx) {
renderResult(ctx, SUCCESS, null, null)
}
private static void renderSuccessValue(Context ctx, V value) {
renderResult(ctx, SUCCESS, null, value)
}
private static void renderWarningMessage(Context ctx, String message) {
renderResult(ctx, WARNING, message, null)
}
private static void renderErrorMessage(Context ctx, String message) {
renderResult(ctx, ERROR, message, null)
}
private static Map getModel(SessionData sessionData) {
Map model = [:]
sessionData.get(Pac4jConstants.USER_PROFILE).ifPresent { UserProfile profile ->
if(profile instanceof OAuth20Profile) {
OAuth20Profile oAuth20Profile = profile;
model.loggedInUserId = oAuth20Profile.username
model.loggedInDisplayName = oAuth20Profile.displayName
model.loggedInPictureUrl = oAuth20Profile.pictureUrl
model.loggedInProfileUrl = oAuth20Profile.profileUrl
}
}
model += sessionData.keys
.findAll {key -> !(key.name in [Pac4jConstants.USER_PROFILE])}
.collectEntries {key ->
def value = sessionData.get(key)
if(value instanceof Optional) {
value = ((Optional)value).orElse("")
}
[key.name, value]
}
.findAll {k,v -> v}
log.debug("model: $model")
model
}
private static GitHubClient createGitHubClient() {
def oauthCfg = BootHubWebApp.getClass().getResource("/$OAUTH_CFG")
ConfigObject cfg = oauthCfg ? new ConfigSlurper().parse(oauthCfg) : null
def ghClient = new GitHubClient()
ghClient.name = System.getenv(ENV_OAUTH_NAME) ?: cfg?.name ?: 'BootHub'
ghClient.scope = System.getenv(ENV_OAUTH_SCOPE) ?: cfg?.scope ?: 'repo'
ghClient.callbackUrl = System.getenv(ENV_OAUTH_CALLBACK_URL) ?: cfg?.callbackUrl
ghClient.key = System.getenv(ENV_OAUTH_KEY) ?: cfg?.key
ghClient.secret = System.getenv(ENV_OAUTH_SECRET) ?: cfg?.secret
ghClient
}
static void main(String[] args) {
new BootHubWebApp(
new DBRepoManager(
new HerokuDBApi(),
new PGJobDAO(),
new DefaultRepoCache()
)
).execute()
}
}