Compare commits

..

16 Commits

20 changed files with 353 additions and 79 deletions

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ xcuserdata
DerivedData/
.DS_Store
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>

19
LICENSE Normal file
View File

@@ -0,0 +1,19 @@
MIT License Copyright (c) 2020 Arsen Musayelyan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next
paragraph) shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -28,6 +28,33 @@
"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",
"repositoryURL": "https://github.com/vapor/leaf",
@@ -64,6 +91,33 @@
"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",
"repositoryURL": "https://github.com/swift-server/swift-backtrace.git",
@@ -78,8 +132,8 @@
"repositoryURL": "https://github.com/apple/swift-crypto.git",
"state": {
"branch": null,
"revision": "9b9d1868601a199334da5d14f4ab2d37d4f8d0c5",
"version": "1.0.2"
"revision": "9680b7251cd2be22caaed8f1468bd9e8915a62fb",
"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-kit", .exact("1.0.0-tau.1.1")),
// 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: [
.target(
@@ -21,6 +24,9 @@ let package = Package(
dependencies: [
.product(name: "Vapor", package: "vapor"),
.product(name: "Leaf", package: "leaf"),
.product(name: "Crypto", package: "swift-crypto"),
.product(name: "Fluent", package: "fluent"),
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
"LeafErrorMiddleware"
],
swiftSettings: [

View File

@@ -1,5 +1,5 @@
# 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
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,13 +14,15 @@
<div class="container">
<div class="d-flex">
<a class="header-brand" href="/">#(config.title)</a>
#if(config.showSourceBtn):
<div class="d-flex order-lg-2 ml-auto">
<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>
</div>
<div class="d-flex order-lg-2 ml-auto">
<div class="nav-item d-none d-md-flex">
#if(loggedIn):
<a href="/logout" class="btn btn-sm btn-outline-primary" target="_self">Log Out</a>
#else:
<a href="/login" class="btn btn-sm btn-outline-primary" target="_self">Log In</a>
#endif
</div>
#endif
</div>
</div>
</div>
</div>

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>
<div class="row row-cards row-deck">
#for(service in services):
<div class="col">
<div class="card">
<div class="card-header">
<h3 class="card-title">#(service["name"])</h3>
<div class="card-options">
<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
</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>
#if(!service["private"] == "true"):
#inline("card")
#else:
#if(loggedIn):
#inline("card")
#endif
</div>
#endif
#endfor
</div>
#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",
"showSourceBtn": true,
"passwordHash": "ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f",
"services": {
"Node 1": [
{
@@ -8,6 +8,14 @@
"url": "https://example.com",
"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 {
let title: String
let showSourceBtn: Bool
let passwordHash: String
let services: [String:[[String:String]]]
}

View File

@@ -1,12 +1,24 @@
import Fluent
import Vapor
import Leaf
import LeafErrorMiddleware
import FluentSQLiteDriver
// configures your application
public func configure(_ app: Application) throws {
app.middleware.use(LeafErrorMiddleware())
// Serve files from /Public
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
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 Foundation
import FoundationNetworking
import Crypto
import Leaf
func routes(_ app: Application) throws {
// Render home page when root domain is loaded
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)
// Decode JSON file to Config struct
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
let url = URL(string: req.parameters.get("url")!)
return req.client.get("https://isitdown.site/api/v3/\(url!)").flatMapThrowing { res in
try res.content.decode(isItUp.self)
}.map { json in
["down": String(json.isitdown), "code": String(json.response_code)]
// Return website status when /status/:url is loaded
app.get("status", ":url") { req -> [String: String] in
// Get URL from request parameters
let url = URL(string: "http://\(req.parameters.get("url")!)")
// Configure URLSession
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 = "*"