Compare commits

...

18 Commits

Author SHA1 Message Date
d4cf0b2c39 Add archive notice 2021-03-28 22:53:25 -07:00
1dfa60703b Remove unneeded template remnant netlify.toml 2020-11-15 15:47:08 -08:00
df1513387a Remove database file to allow fluent to create its own 2020-11-15 15:46:08 -08:00
c32880ee3e Configure sessions and store them in databases 2020-11-15 15:43:44 -08:00
5d8cf17e22 Add comments 2020-11-15 11:51:49 -08:00
04ab5e3002 Change timeout to 5s, then assume offline 2020-11-15 11:36:10 -08:00
7927b515f2 Fix down status 2020-11-15 01:43:27 -08:00
7789ebc0f9 Use URLSession and DispatchSemaphore to create custom implementation of status API 2020-11-15 01:37:57 -08:00
362f68108c Use URLSession and DispatchSemaphore to create custom implementation of status API 2020-11-15 01:34:52 -08:00
c5d4d82c42 Remove debug code 2020-11-12 13:36:44 -08:00
43bdf9a746 Add login functionality 2020-11-12 13:23:25 -08:00
7e8a6d6590 Add private config and password hash to example 2020-11-12 13:17:58 -08:00
ad8fc19750 Add error pages 2020-11-11 18:11:12 -08:00
a6aab8f975 Merge branch 'master' of ssh://192.168.100.157:2222/Arsen6331/statusboard 2020-11-11 17:03:05 -08:00
6f184e1083 Add loading on status 2020-11-11 17:02:50 -08:00
7a1143ca9b Add LICENSE 2020-11-11 16:15:46 -08:00
249f949967 Change href on source button 2020-11-11 16:11:19 -08:00
acc391e8e6 Add toggleable source 2020-11-11 16:10:33 -08:00
20 changed files with 350 additions and 72 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

@@ -16,7 +16,11 @@
<a class="header-brand" href="/">#(config.title)</a>
<div class="d-flex order-lg-2 ml-auto">
<div class="nav-item d-none d-md-flex">
<a href="https://github.com/tabler/tabler" class="btn btn-sm btn-outline-primary" target="_blank">Source code</a>
#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>
</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>
#if(!service["private"] == "true"):
#inline("card")
#else:
#if(loggedIn):
#inline("card")
#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
</div>
#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,5 +1,6 @@
{
"title": "Statusboard",
"passwordHash": "ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f",
"services": {
"Node 1": [
{
@@ -7,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,5 +3,6 @@ import Vapor
struct Config: Codable {
let title: String
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 = "*"