Compare commits

...

15 Commits

19 changed files with 334 additions and 79 deletions

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ xcuserdata
DerivedData/ DerivedData/
.DS_Store .DS_Store
file:* file:*
Resources/config.json
Resources/db.sqlite

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -28,6 +28,33 @@
"version": "4.2.1" "version": "4.2.1"
} }
}, },
{
"package": "fluent",
"repositoryURL": "https://github.com/vapor/fluent.git",
"state": {
"branch": null,
"revision": "e681c93df3201a2d8ceef15e8a9a0634578df233",
"version": "4.0.0"
}
},
{
"package": "fluent-kit",
"repositoryURL": "https://github.com/vapor/fluent-kit.git",
"state": {
"branch": null,
"revision": "31d96b547cc1f869f2885d932a8a9a7ae2103fc6",
"version": "1.10.0"
}
},
{
"package": "fluent-sqlite-driver",
"repositoryURL": "https://github.com/vapor/fluent-sqlite-driver.git",
"state": {
"branch": null,
"revision": "6f29f6f182c812075f09c7575c18ac5535c26824",
"version": "4.0.1"
}
},
{ {
"package": "leaf", "package": "leaf",
"repositoryURL": "https://github.com/vapor/leaf", "repositoryURL": "https://github.com/vapor/leaf",
@@ -64,6 +91,33 @@
"version": "4.2.0" "version": "4.2.0"
} }
}, },
{
"package": "sql-kit",
"repositoryURL": "https://github.com/vapor/sql-kit.git",
"state": {
"branch": null,
"revision": "ea9928b7f4a801b175a00b982034d9c54ecb6167",
"version": "3.7.0"
}
},
{
"package": "sqlite-kit",
"repositoryURL": "https://github.com/vapor/sqlite-kit.git",
"state": {
"branch": null,
"revision": "2ec279b9c845cec254646834b66338551a024561",
"version": "4.0.2"
}
},
{
"package": "sqlite-nio",
"repositoryURL": "https://github.com/vapor/sqlite-nio.git",
"state": {
"branch": null,
"revision": "6481dd0b01112d082dd7eb362782126e81964138",
"version": "1.1.0"
}
},
{ {
"package": "swift-backtrace", "package": "swift-backtrace",
"repositoryURL": "https://github.com/swift-server/swift-backtrace.git", "repositoryURL": "https://github.com/swift-server/swift-backtrace.git",
@@ -78,8 +132,8 @@
"repositoryURL": "https://github.com/apple/swift-crypto.git", "repositoryURL": "https://github.com/apple/swift-crypto.git",
"state": { "state": {
"branch": null, "branch": null,
"revision": "9b9d1868601a199334da5d14f4ab2d37d4f8d0c5", "revision": "9680b7251cd2be22caaed8f1468bd9e8915a62fb",
"version": "1.0.2" "version": "1.1.2"
} }
}, },
{ {

View File

@@ -13,7 +13,10 @@ let package = Package(
.package(url: "https://github.com/vapor/leaf", .exact("4.0.0-tau.1")), .package(url: "https://github.com/vapor/leaf", .exact("4.0.0-tau.1")),
.package(url: "https://github.com/vapor/leaf-kit", .exact("1.0.0-tau.1.1")), .package(url: "https://github.com/vapor/leaf-kit", .exact("1.0.0-tau.1.1")),
// Leaf Error Middleware for custom error pages // Leaf Error Middleware for custom error pages
.package(name: "LeafErrorMiddleware", url: "https://github.com/brokenhandsio/leaf-error-middleware.git", from: "2.0.0-beta") .package(name: "LeafErrorMiddleware", url: "https://github.com/brokenhandsio/leaf-error-middleware.git", from: "2.0.0-beta"),
.package(url: "https://github.com/apple/swift-crypto.git", from: "1.1.2"),
.package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"),
.package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.0.0")
], ],
targets: [ targets: [
.target( .target(
@@ -21,6 +24,9 @@ let package = Package(
dependencies: [ dependencies: [
.product(name: "Vapor", package: "vapor"), .product(name: "Vapor", package: "vapor"),
.product(name: "Leaf", package: "leaf"), .product(name: "Leaf", package: "leaf"),
.product(name: "Crypto", package: "swift-crypto"),
.product(name: "Fluent", package: "fluent"),
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
"LeafErrorMiddleware" "LeafErrorMiddleware"
], ],
swiftSettings: [ swiftSettings: [

View File

@@ -1,5 +1,5 @@
# Statusboard # Statusboard
## Status dashboard written in Swift using Vapor ## This repository is archived as [simpledash](https://gitea.arsenm.dev/Arsen6331/simpledash) is now able to replace it
### Configuration ### Configuration
Statusboard can be configured using the JSON config at `/Resources/config.json` Statusboard can be configured using the JSON config at `/Resources/config.json`

24
Resources/Views/404.leaf Normal file
View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Not Found</title>
<link rel="stylesheet" type="text/css" href="/tabler/css/tabler.css">
<link rel="stylesheet" type="text/css" href="/tabler/css/dashboard.css">
<script src="/tabler/js/core.js"></script>
<script src="/tabler/js/dashboard.js"></script>
</head>
<body class="">
<div class="page">
<div class="page-content">
<div class="container text-center">
<div class="display-1 text-muted mb-5"><i class="si si-exclamation"></i> 404</div>
<h1 class="h2 mb-3">This page wasn't found on our server</h1>
<p class="h4 text-muted font-weight-normal mb-7">Either the page was removed or you clicked on a bad link&hellip;</p>
<a class="btn btn-primary" href="javascript:history.back()">
<i class="fe fe-arrow-left mr-2"></i>Go back
</a>
</div>
</div>
</div>
</body>
</html>

View File

@@ -14,16 +14,18 @@
<div class="container"> <div class="container">
<div class="d-flex"> <div class="d-flex">
<a class="header-brand" href="/">#(config.title)</a> <a class="header-brand" href="/">#(config.title)</a>
#if(config.showSourceBtn):
<div class="d-flex order-lg-2 ml-auto"> <div class="d-flex order-lg-2 ml-auto">
<div class="nav-item d-none d-md-flex"> <div class="nav-item d-none d-md-flex">
<a href="https://gitea.arsenm.dev/Arsen6331/statusboard" class="btn btn-sm btn-outline-primary" target="_blank">Source code</a> #if(loggedIn):
</div> <a href="/logout" class="btn btn-sm btn-outline-primary" target="_self">Log Out</a>
</div> #else:
<a href="/login" class="btn btn-sm btn-outline-primary" target="_self">Log In</a>
#endif #endif
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<div class="header d-lg-flex p-0"> <div class="header d-lg-flex p-0">
<div class="container"> <div class="container">
<div class="row align-items-center"> <div class="row align-items-center">

43
Resources/Views/card.leaf Normal file
View File

@@ -0,0 +1,43 @@
<div class="col">
<div class="card">
<div class="card-header">
<h3 class="card-title">#(service["name"])</h3>
<div class="card-options">
<span class="tag #if(service["url"]): btn-loading #endif">
Status
<span id="#(service["name"])Status" class="tag-addon">Unavailable</span>
</span>
</div>
</div>
<div class="card-body">
#(service["description"])
</div>
#if(service["url"]):
<div class="card-footer">
URL: <a href="#(service["url"])">#(service["url"])</a>
</div>
#endif
</div>
#if(service["url"]):
<script>
fullURL = '#(service["url"])'
var url = fullURL.replace("https://", "")
url = url.replace("http://", "")
var request = new XMLHttpRequest()
request.open('GET', "/status/" + url, true)
request.onload = function () {
var data = JSON.parse(this.response)
if (data.down === "true" || parseInt(data.code) > 500 && parseInt(data.code) < 600 ) {
document.getElementById('#(service["name"])Status').classList.add("tag-danger")
document.getElementById('#(service["name"])Status').parentElement.classList.remove("btn-loading")
document.getElementById('#(service["name"])Status').innerHTML = "Offline"
} else {
document.getElementById('#(service["name"])Status').classList.add("tag-success")
document.getElementById('#(service["name"])Status').parentElement.classList.remove("btn-loading")
document.getElementById('#(service["name"])Status').innerHTML = "Online"
}
}
request.send()
</script>
#endif
</div>

View File

@@ -7,47 +7,13 @@
<h6>#(node)</h6> <h6>#(node)</h6>
<div class="row row-cards row-deck"> <div class="row row-cards row-deck">
#for(service in services): #for(service in services):
<div class="col"> #if(!service["private"] == "true"):
<div class="card"> #inline("card")
<div class="card-header"> #else:
<h3 class="card-title">#(service["name"])</h3> #if(loggedIn):
<div class="card-options"> #inline("card")
<span class="tag">
Status
<span id="#(service["name"])Status" class="tag-addon">Unavailable</span>
</span>
</div>
</div>
<div class="card-body">
#(service["description"])
</div>
#if(service["url"]):
<div class="card-footer">
URL: <a href="#(service["url"])">#(service["url"])</a>
</div>
#endif #endif
</div>
#if(service["url"]):
<script>
fullURL = '#(service["url"])'
var url = fullURL.replace("https://", "")
url = url.replace("http://", "")
var request = new XMLHttpRequest()
request.open('GET', "/status/" + url, true)
request.onload = function () {
var data = JSON.parse(this.response)
if (data.down === "true" || parseInt(data.code) > 500 && parseInt(data.code) < 600 ) {
document.getElementById('#(service["name"])Status').classList.add("tag-danger")
document.getElementById('#(service["name"])Status').innerHTML = "Offline"
} else {
document.getElementById('#(service["name"])Status').classList.add("tag-success")
document.getElementById('#(service["name"])Status').innerHTML = "Online"
}
}
request.send()
</script>
#endif #endif
</div>
#endfor #endfor
</div> </div>
#endfor #endfor

View File

@@ -0,0 +1,30 @@
#define(body):
<div class="page">
<div class="page-single">
<div class="container">
<div class="row">
<div class="col col-login mx-auto">
<div class="text-center mb-6">
<img src="" class="h-6" alt="">
</div>
<form class="card" action="/login" method="post">
<div class="card-body p-6">
<div class="card-title">Login to #(config.title)</div>
<div class="form-group">
<label class="form-label">
Password
</label>
<input type="password" class="form-control" id="passwordInput" name="password" value="password" placeholder="Password">
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary btn-block">Sign in</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
#enddefine
#inline("base")

View File

@@ -0,0 +1,31 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<title>#(statusMessage)</title>
<link rel="stylesheet" type="text/css" href="/tabler/css/tabler.css">
<link rel="stylesheet" type="text/css" href="/tabler/css/dashboard.css">
<script src="/tabler/js/core.js"></script>
<script src="/tabler/js/dashboard.js"></script>
</head>
<body class="">
<div class="page">
<div class="page-content">
<div class="container text-center">
<div class="display-1 text-muted mb-5"><i class="si si-exclamation"></i> #(status)</div>
<h1 class="h2 mb-3">
#if(reason ?? false):
#(reason)
#else:
#(statusMessage)
#endif
</h1>
<p class="h4 text-muted font-weight-normal mb-7">If you have done everything properly, please try again later&hellip;</p>
<a class="btn btn-primary" href="javascript:history.back()">
<i class="fe fe-arrow-left mr-2"></i>Go back
</a>
</div>
</div>
</div>
</body>
</html>

View File

@@ -1,6 +1,6 @@
{ {
"title": "Statusboard", "title": "Statusboard",
"showSourceBtn": true, "passwordHash": "ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f",
"services": { "services": {
"Node 1": [ "Node 1": [
{ {
@@ -8,6 +8,14 @@
"url": "https://example.com", "url": "https://example.com",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
} }
],
"Secret Cluster 01": [
{
"name": "Secret Example",
"url": "https://example.net",
"private": "true",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
}
] ]
} }
} }

View File

@@ -3,6 +3,6 @@ import Vapor
struct Config: Codable { struct Config: Codable {
let title: String let title: String
let showSourceBtn: Bool let passwordHash: String
let services: [String:[[String:String]]] let services: [String:[[String:String]]]
} }

View File

@@ -1,12 +1,24 @@
import Fluent
import Vapor import Vapor
import Leaf import Leaf
import LeafErrorMiddleware import LeafErrorMiddleware
import FluentSQLiteDriver
// configures your application // configures your application
public func configure(_ app: Application) throws { public func configure(_ app: Application) throws {
app.middleware.use(LeafErrorMiddleware())
// Serve files from /Public // Serve files from /Public
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
app.middleware.use(LeafErrorMiddleware())
app.sessions.use(.fluent)
app.sessions.configuration.cookieName = "statusboard-session"
app.sessions.configuration.cookieFactory = { sessionID in
.init(string: sessionID.string, expires: Date(timeIntervalSinceNow: 60*60*24*365), isSecure: true, isHTTPOnly: true, sameSite: HTTPCookies.SameSitePolicy.none)
}
app.middleware.use(app.sessions.middleware)
app.databases.use(.sqlite(.file("\(app.directory.resourcesDirectory)/db.sqlite")), as: .sqlite)
app.migrations.add(SessionRecord.migration)
// Configure Leaf // Configure Leaf
LeafOption.caching = app.environment.isRelease ? .default : .bypass LeafOption.caching = app.environment.isRelease ? .default : .bypass

View File

@@ -0,0 +1,7 @@
import Foundation
import Vapor
struct LContext: Codable {
let config: Config
let loggedIn: Bool
}

View File

@@ -1,7 +0,0 @@
import Foundation
import Vapor
struct isItUp: Codable {
let isitdown: Bool
let response_code: Int
}

6
Sources/App/login.swift Normal file
View File

@@ -0,0 +1,6 @@
import Foundation
import Vapor
struct Login: Codable {
let password: String?
}

View File

@@ -1,21 +1,104 @@
import Vapor import Vapor
import Foundation import Foundation
import FoundationNetworking
import Crypto
import Leaf import Leaf
func routes(_ app: Application) throws { func routes(_ app: Application) throws {
// Render home page when root domain is loaded // Render home page when root domain is loaded
app.get { req -> EventLoopFuture<View> in app.get { req -> EventLoopFuture<View> in
// Get data from config file at /Resources/config.json
let fileData = try String(contentsOfFile: "\(app.directory.resourcesDirectory)/config.json").data(using: .utf8) let fileData = try String(contentsOfFile: "\(app.directory.resourcesDirectory)/config.json").data(using: .utf8)
// Decode JSON file to Config struct
let config: Config = try! JSONDecoder().decode(Config.self, from: fileData!) let config: Config = try! JSONDecoder().decode(Config.self, from: fileData!)
return req.view.render("home", ["config": config]) // Check if user is logged in
let loginStatus = req.session.data["loggedIn"] ?? "false"
// Change loginStatus to a boolean
let loginBool = loginStatus == "true" ? true : false
// Render home.leaf with config and login status as context
return req.view.render("home", LContext(config: config, loggedIn: loginBool))
} }
app.get("status", ":url") { req -> EventLoopFuture<[String: String]> in // Return website status when /status/:url is loaded
let url = URL(string: req.parameters.get("url")!) app.get("status", ":url") { req -> [String: String] in
return req.client.get("https://isitdown.site/api/v3/\(url!)").flatMapThrowing { res in // Get URL from request parameters
try res.content.decode(isItUp.self) let url = URL(string: "http://\(req.parameters.get("url")!)")
}.map { json in // Configure URLSession
["down": String(json.isitdown), "code": String(json.response_code)] let config = URLSessionConfiguration.default
// Set request timeouts to 5s, then assume offline
config.timeoutIntervalForRequest = 5
config.timeoutIntervalForResource = 5
// Declare statusDict for storing website status
var statusDict: [String:String] = [:]
// Declare URLSession request using URL from request parameters
var headReq = URLRequest(url: url!)
// Set HEAD request
headReq.httpMethod = "HEAD"
// Set request timeout to 5s, then assume offline
headReq.timeoutInterval = 5
// Create DispatchSemaphore to block thread until request is resolved and status stored
let semaphore = DispatchSemaphore(value: 0)
// Run Async URLSession dataTask
URLSession.shared.dataTask(with: headReq, completionHandler: { (_, response, error) in
// If the response is valid
if let httpURLResponse = response as? HTTPURLResponse {
// Store status code in statusDict
statusDict["code"] = String(httpURLResponse.statusCode)
// Store website status in statusDict
statusDict["down"] = "false"
}
// Signal DispatchSemaphore to stop blocking
semaphore.signal()
}).resume()
// Wait for semaphore signal
semaphore.wait()
// If statusDict is empty, return code 0, down true. Otherwise, return statusdict
return statusDict.count > 0 ? statusDict : ["code": "0", "down": "true"]
}
// Render login page on GET request to /login
app.get("login") { req -> EventLoopFuture<View> in
// Get data from config file at /Resources/config.json
let fileData = try String(contentsOfFile: "\(app.directory.resourcesDirectory)/config.json").data(using: .utf8)
// Decode JSON file to Config struct
let config: Config = try! JSONDecoder().decode(Config.self, from: fileData!)
// Render home.leaf with config as context
return req.view.render("login", LContext(config: config, loggedIn: false))
}
// Verify credentials and log in on POST request to /login
app.post("login") { req -> Response in
// Decode POST request data into Login struct
let data = try req.content.decode(Login.self)
// Get data from config file at /Resources/config.json
let fileData = try String(contentsOfFile: "\(app.directory.resourcesDirectory)/config.json").data(using: .utf8)
// Decode JSON file to Config struct
let config: Config = try! JSONDecoder().decode(Config.self, from: fileData!)
// Get password from POST request data
let loginPassData = data.password?.data(using: .utf8)
// Hash password in POST data using SHA256.hash() from SwiftCrypto
let loginPassHash = SHA256.hash(data: loginPassData ?? "".data(using: .utf8)!)
// Convert hash to string
let stringHash = loginPassHash.map { String(format: "%02hhx", $0) }.joined()
// If hash in config matches provided hash
if stringHash == config.passwordHash {
// Set logged in to true in session
req.session.data["loggedIn"] = "true"
// Redirect back to /
return try req.redirect(to: "/")
} else {
// If hashes do not match, return unauthorized error
throw Abort(.unauthorized)
} }
} }
// Destroy session on GET request to logout
app.get("logout") { req -> Response in
// Destroy session
req.session.destroy()
// Redirect back to /
return try req.redirect(to: "/")
}
} }

View File

@@ -1,5 +0,0 @@
[[headers]]
# Define which paths this specific [[headers]] block will cover.
for = "/*"
[headers.values]
Access-Control-Allow-Origin = "*"