Initial commit

This commit is contained in:
Alan Bridgeman 2025-02-17 16:19:35 -06:00
commit 9277a804bb
18 changed files with 851 additions and 0 deletions

33
src/entity/Chart.ts Normal file
View file

@ -0,0 +1,33 @@
import { Entity, BaseEntity, PrimaryColumn, Column, OneToMany, ManyToOne, Relation } from 'typeorm';
/** Represents a Helm Chart */
@Entity("Charts")
export class Chart extends BaseEntity {
/** The unique ID of the Helm Chart */
@PrimaryColumn()
id!: string;
/** When the Helm Chart was pushed/created */
@Column()
created!: Date;
/** The user that pushed the Helm Chart (operator from Harbor webhook) */
@Column()
user!: string;
/** Helm Chart Name */
@Column()
name!: string;
/** Helm chart digest */
@Column()
digest!: string;
/** Helm chart tag */
@Column()
tag!: string;
/** Helm Chart URL */
@Column()
url!: string;
}

145
src/pages/base.ejs Normal file
View file

@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<% if(typeof description !== 'undefined') { %>
<%# If the description is explicitly set use it %>
<meta name="description" content="<%= description %>">
<% } else if(typeof company !== 'undefined') { %>
<%# If the description isn't explicitly set but the company name is (company variable) then assume this a generic company Helm chart repository and set the description as such %>
<meta name="description" content="<% if(typeof company !== 'undefined') { %><%= company %> <% } %> Helm Repository">
<% } %>
<% if (typeof keywords !== 'undefined') { %>
<%# If the keywords are explicitly set use them %>
<meta name="keywords" content="<%= keywords %>">
<% } else { %>
<%# If the keywords aren't explicitly set then use the default keywords %>
<meta name="keywords" content="helm, kubernetes, repository, charts, <% if(typeof company !== 'undefined') { %><%= company %> <% } %>">
<% } %>
<% if (typeof author !== 'undefined') { %>
<%# If the author is explicitly set use it %>
<meta name="author" content="<%= author %>">
<% } else if (typeof company !== 'undefined') { %>
<%# If the author isn't explicitly set but the company name is (company variable) then use it as the author %>
<meta name="author" content="<%= company %>">
<% } %>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<% if(typeof title !== 'undefined' && title.length > 0) { %>
<title><% if (typeof titlePrefix !== 'undefined') { %><%= titlePrefix %><% } %><%= title %><% if (typeof titleSuffix !== 'undefined') { %><%= titleSuffix %><% } %></title>
<% } %>
<!-- Styles -->
<!-- Custom styling -->
<link rel="stylesheet" type="text/css" href="/css/style.css">
<link rel="stylesheet" type="text/css" href="/css/accessibility.css">
<%# <!-- Foundation Framework --> %>
<%# <link rel="stylesheet" href="/css/app.css"> %>
<%# <!-- SASS components --> %>
<%# <link rel="stylesheet" type="text/css" href="/css/components/system-themed-background.css"> %>
<!-- Controller specific styling -->
<%# Add any additional stylesheets specified within a controller etc... %>
<%# This can either be a singular string or a array of strings %>
<%# Note, that the string should be the name of the stylesheet WITHOUT the `.css` extension and exist in the `css/` directory %>
<% if (typeof extraStyles !== 'undefined') { %>
<% if (Array.isArray(extraStyles)) { %>
<%# Because it's an array, we need to loop through each stylesheet and include it %>
<% for (let style of extraStyles) { %>
<link rel="stylesheet" type="text/css" href="/css/<%= style %>.css">
<% } %>
<% } else { %>
<%# Include the singular stylesheet %>
<link rel="stylesheet" type="text/css" href="/css/<%= extraStyles %>.css">
<% } %>
<% } %>
<!-- Scripts -->
<!-- Third-party scripts -->
<%# <script src="https://js.stripe.com/v3" async></script> %>
<%# <script src="https://kit.fontawesome.com/c754d91c7e.js" crossorigin="anonymous"></script> %>
<%# <!-- Foundation Framework --> %>
<%# <script type="application/javascript" src="/js/foundation/main.js" defer></script> %>
<!-- Controller specific scripts -->
<%# Add any additional scripts specified within a controller etc... %>
<%# %>
<%# Note, that these can come in multiple formats as described in the table below: %>
<%# | Type | Description | Format | Use Cases | %>
<%# | ------ | --------------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------- | %>
<%# | string | The name of the script to include | `<script name>` | Simple include of the script | %>
<%# | object | An object about the script to include | `{ script: '<script name>', defer: <true/false> }` | Being more explicit about script's properties (ex. defer vs. async, etc...) | %>
<%# | array | An array of strings or objects (as described above) | `[ '<script name>', { script: '<script name>', defer: <true/false> } ]` | Include multiple scripts | %>
<%# %>
<%# The string or `.script` property of the object should be the script name WITHOUT the `.js` extension and exist in the `js/` directory if it's a "local" script. %>
<%# Or should be the full URL if it's a "external" script %>
<% if (typeof extraScripts !== 'undefined') { %>
<% if (Array.isArray(extraScripts)) { %>
<%# Because it's an array, we need to loop through each script and include it %>
<% for (let script of extraScripts) { %>
<% if(typeof script === 'object') { %>
<%# Because the current array items is an object we use the `.script` and `.defer` properties to include it %>
<% if(script.script.startsWith('http') || script.script.startsWith('https')) { %>
<%# Because the `.script` property starts with `http` or `https` we assume it's an "external" script and include it as a straight URL %>
<script type="application/javascript" src="<%= script.script %>" <% if(script.defer) { %>defer<% } %>></script>
<% } else { %>
<%# Because the `.script` property doesn't start with `http` or `https` we assume it's a "local" script and include it as a local script (from the `js/` folder and with a `.js` extension) %>
<script type="application/javascript" src="/js/<%= script.script %>.js" <% if(script.defer) { %>defer<% } %>></script>
<% } %>
<% } else { %>
<% if(script.startsWith('http') || script.startsWith('https')) { %>
<%# Because the string starts with `http` or `https` we assume it's an "external" script and include it as a straight URL %>
<script type="application/javascript" src="<%= script %>"></script>
<% } else { %>
<%# Because the string doesn't start with `http` or `https` we assume it's a "local" script and include it as a local script (from the `js/` folder and with a `.js` extension) %>
<script type="application/javascript" src="/js/<%= script %>.js" defer></script>
<% } %>
<% } %>
<% } %>
<% } else if (typeof extraScripts === 'object') { %>
<% if(extraScripts.script.startsWith('http') || extraScripts.script.startsWith('https')) { %>
<%# Because the `.script` property of the singular object starts with `http` or `https` we assume it's an "external" script and include it as a straight URL %>
<script type="application/javascript" src="<%= extraScripts.script %>" <% if(extraScripts.defer) { %>defer<% } %>></script>
<% } else { %>
<%# Because the `.script` property of the singular object doesn't start with `http` or `https` we assume it's a "local" script and include it as a local script (from the `js/` folder and with a `.js` extension) %>
<script type="application/javascript" src="/js/<%= extraScripts.script %>.js" <% if(extraScripts.defer) { %>defer<% } %>></script>
<% } %>
<% } else { %>
<% if(extraScripts.startsWith('http') || extraScripts.startsWith('https')) { %>
<%# Because the singular string starts with `http` or `https` we assume it's an "external" script and include it as a straight URL %>
<script type="application/javascript" src="<%= extraScripts %>"></script>
<% } else { %>
<%# Because the singular string doesn't start with `http` or `https` we assume it's a "local" script and include it as a local script (from the `js/` folder and with a `.js` extension) %>
<script type="application/javascript" src="/js/<%= extraScripts %>.js" defer></script>
<% } %>
<% } %>
<% } %>
</head>
<body>
<a id="skip-link" href="#main">Skip to main content</a>
<div id="contents">
<header>
<% if(typeof header !== 'undefined') { %>
<%# Because the controller has explicitly set the header, we use the specified header here (within the `<header></header>` tags) %>
<%- include(header) %>
<% } else { %>
<%# Because no explicitly header was set use the default header %>
<%- include('includes/header.ejs') %>
<% } %>
</header>
<div style="width: 100%">
<main id="main">
<div class="container">
<div class="content">
<%- include(page) %>
</div>
</div>
</main>
<footer>
<% if(typeof footer !== 'undefined') { %>
<%# Because the controller has explicitly set the footer, we use the specified footer here (within the `<footer></footer>` tags) %>
<%- include(footer) %>
<% } else { %>
<%# Because no explicitly footer was set use the default footer %>
<%- include('includes/footer.ejs') %>
<% } %>
</footer>
</div>
</div>
</body>
</html>

View file

View file

13
src/pages/index.ejs Normal file
View file

@ -0,0 +1,13 @@
<h1>Welcome to the <%= company %> Helm Repository</h1>
<p>
To use this repository, you need to add it to your Helm client.
</p>
<pre>helm repo add <%= company.replaceAll(' ', '') %> https://<%= hostname %>/</pre>
<p>
Once you have added the repository, you can search for charts in the repository.
</p>
<pre>helm search repo <%= company.replaceAll(' ', '') %></pre>
<p>
To install a chart from the repository, use the following command.
</p>
<pre>helm install my-release <%= company.replaceAll(' ', '') %>/some-chart</pre>

View file

@ -0,0 +1,61 @@
import express, { Request, Response } from 'express';
import { v4 as newUUID } from 'uuid';
import { Controller, POST, BaseController } from '@BridgemanAccessible/ba-web-framework';
import { Chart } from '../entity/Chart';
type WebhookResponse = {
type: 'PUSH_ARTIFACT' | 'DELETE_ARTIFACT',
occur_at: number,
operator: string,
event_data: {
resources: {
digest: string,
tag: string,
resource_url: string
}[],
repository: {
date_created?: number,
name: string,
namespace: string,
repo_full_name: string,
repo_type: string
}
}
};
@Controller()
export class HarborRoutes extends BaseController {
@POST('/harbor', express.json())
async webhook(req: Request, res: Response) {
const response: WebhookResponse = req.body;
if(response.type === 'PUSH_ARTIFACT') {
console.log('Pushed a new Helm Chart');
// Create a new database entry for the Helm Chart that got pushed
await Chart.create({
id: newUUID(),
created: new Date(response.occur_at),
user: response.operator,
name: response.event_data.repository.name,
digest: response.event_data.resources[0].digest,
tag: response.event_data.resources[0].tag,
url: response.event_data.resources[0].resource_url
}).save();
}
else if(response.type === 'DELETE_ARTIFACT') {
console.log('Deleted a Helm Chart');
// Delete the Helm Chart from the database
Chart.delete({ digest: response.event_data.resources[0].digest });
}
console.log(`Happened At: ${response.occur_at}`);
console.log(`User (Operator): ${response.operator}`);
console.log(`Name: ${response.event_data.repository.name}`);
console.log(`Digest: ${response.event_data.resources[0].digest}`);
console.log(`Tag: ${response.event_data.resources[0].tag}`);
console.log(`URL: ${response.event_data.resources[0].resource_url}`);
res.status(200).send('ok');
}
}

63
src/routes/HelmRoutes.ts Normal file
View file

@ -0,0 +1,63 @@
import { Request, Response } from 'express';
import { Controller, GET, BaseController } from '@BridgemanAccessible/ba-web-framework';
import { dump as writeYAML } from 'js-yaml';
import { Chart } from '../entity/Chart';
type HelmRepoIndexEntry = {
apiVersion: 'v1',
appVersion: `${string}.${string}.${string}`,
dependencies: {
name: string,
repository: string,
version: `${string}.${string}.${string}`
}[],
description: string,
digest: string,
home: string,
name: string,
sources: string[],
urls: string[],
version: `${string}.${string}.${string}`
};
type HelmRepoIndex = {
apiVersion: 'v1',
entries: {
[key: string]: HelmRepoIndexEntry[]
}
};
@Controller()
export class HelmRoutes extends BaseController {
@GET('/index.yaml')
private async index(req: Request, res: Response) {
const index: HelmRepoIndex = {
apiVersion: 'v1',
entries: {}
};
// Get all Helm Charts from the database and add them to the index
const charts = await Chart.find();
charts.forEach(chart => {
index.entries[chart.name] = [
{
apiVersion: 'v1',
appVersion: '1.0.0',
dependencies: [],
description: chart.name,
digest: chart.digest,
home: `https://${process.env.HOSTNAME}/${chart.url.substring(chart.url.indexOf('/', chart.url.indexOf('/') + 1) + 1, chart.url.indexOf(':'))}`,
name: chart.name,
sources: [],
urls: [`oci://${chart.url}`],
version: chart.tag as `${string}.${string}.${string}`
}
];
})
res.status(200)
.setHeader('Content-Type', 'application/yaml') // Per IANA Media Types listing as noted in [RFC 9512](https://datatracker.ietf.org/doc/html/rfc9512)
.send(writeYAML(index));
}
}

9
src/routes/HomeRoutes.ts Normal file
View file

@ -0,0 +1,9 @@
import { Request, Response } from 'express';
import { Controller, GET, Page, BaseController } from '@BridgemanAccessible/ba-web-framework';
@Controller()
export class HomeRoutes extends BaseController {
@Page('Home', 'index.ejs')
@GET('/')
async home(req: Request, res: Response) {}
}

39
src/server.ts Normal file
View file

@ -0,0 +1,39 @@
import path from 'path';
import { Application } from 'express';
import { App, Initializer, globalTemplateValues } from '@BridgemanAccessible/ba-web-framework';
import { createConn } from './utils/db';
/**
* Create the initial database connection
*
* This includes setting up the connection options and creating the connection itself.
*/
async function createInitialDatabaseConnection() {
// Setup the database connection options
const connOptions = {
synchronize: process.env.NODE_ENV !== 'production',
logging: process.env.NODE_ENV !== 'production'
}
// Create a connection to the database
await createConn('postgres', connOptions);
}
async function onStart(app: Application) {
// Create the initial database connection
createInitialDatabaseConnection();
}
async function main() {
await new App().run(new Initializer({
controllersPath: path.join(__dirname, 'routes'),
staticFilesPath: path.join(__dirname, 'static'),
view: {
engine: 'ejs',
filesPath: path.join(__dirname, 'pages')
}
}, globalTemplateValues({ company: process.env.COMPANY, titleSuffix: process.env.WEBSITE_TITLE_SUFFIX, hostname: process.env.HOSTNAME })), onStart);
}
main()

View file

@ -0,0 +1,28 @@
/* Style for the "skip to content" link found at the top of the page */
#skip-link {
position: absolute;
top: -40px;
left: 0;
background: #fff;
color: #000;
padding: 8px;
z-index: 100;
height: 0;
}
/* Style to visually show the "skip to content" link when keyboard has focus */
#skip-link:focus {
top: 0;
height: auto;
}
/* For elements that should be hidden visually bt NOT from screen readers */
/* Ex. input form labels that are labeled differently visually */
.visually-hidden {
position: absolute;
height: 1px;
width: 1px;
overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap;
}

87
src/static/css/style.css Normal file
View file

@ -0,0 +1,87 @@
:root {
min-height: 100%;
}
html, body {
position: relative;
width: 100%;
min-height: 100%;
margin: 0;
padding: 0;
/*font-family: 'Arial', sans-serif;
font-size: 14pt;
color: #000;
background-color: #fff;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;*/
}
main {
min-height: 100%;
padding-top: 5px;
padding-left: 10px;
padding-right: 10px;
padding-bottom: 60px;
}
.hidden {
display: none;
visibility: hidden;
}
/*#contents {
display: flex;
flex-direction: row;
}
#contents #sidebar {
display: flex;
flex-direction: column;
width: 384px;
min-height: 100vh;
background-color: #C2C2C2;
padding: 20px;
}
#contents #sidebar button:hover {
cursor: pointer;
}
#contents #sidebar button:hover,
#contents #sidebar button:focus,
#contents #sidebar a:hover,
#contents #sidebar a:focus {
background-color: #000000;
color: #FFFFFF;
}
#contents #sidebar hr {
background-color: #000000;
width: 100%;
height: 1px;
}
#contents main {
display: flex;
flex-direction: column;
flex-grow: 1;
padding: 20px;
}
.btn {
display: inline-block;
padding: 10px 20px;
background-color: #C2C2C2;
color: #000000;
text-decoration: none;
text-align: center;
border-radius: 5px;
border: 1px solid #000000;
}
.btn-panel {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-bottom: 20px;
}*/

82
src/utils/db.ts Normal file
View file

@ -0,0 +1,82 @@
import path from 'path';
import { DataSource } from 'typeorm';
/**
* Creates a connection to the database.
*
* @param kwargs Other keyword arguments to pass to the TypeORM DataSource. (ex. { logging: true })
* @returns A connection to the database.
*/
async function createPostgreSQLConn(kwargs: object): Promise<DataSource> {
// Parse the database connection information from the environment variables
const DB_HOST = process.env.DB_HOST;
const DB_PORT: number = typeof process.env.DB_PORT !== 'undefined' ? parseInt(process.env.DB_PORT) : 5432;
const DB_USER = process.env.DB_USER || 'postgres';
const DB_PASSWORD = process.env.DB_PASSWORD || 'db_password';
const DB_NAME = process.env.DB_NAME || 'db';
console.log(`Attempting to connect to ${DB_HOST} at port ${DB_PORT} on database ${DB_NAME} as ${DB_USER} with password ${DB_PASSWORD}`);
// Create the connection
return new DataSource({
type: "postgres",
host: DB_HOST,
port: DB_PORT,
username: DB_USER,
password: DB_PASSWORD,
database: DB_NAME,
//ssl: process.env.NODE_ENV === 'production' ? true : false,
...kwargs,
entities: [
path.resolve(__dirname, '../', './entity/**/*.ts'),
path.resolve(__dirname, '../', './entity/**/*.js')
]
}).initialize();
}
/**
* Creates a connection to the SQLite database.
*
* @param kwargs Other keyword arguments to pass to the TypeORM DataSource. (ex. { logging: true })
* @returns The connection to the database.
*/
async function createSQLiteConn(kwargs: object): Promise<DataSource> {
// Parse the database connection information from the environment variables
const DB_NAME = process.env.DATABASE_FILE || 'database.sqlite';
console.log(`Attempting to connect to ${DB_NAME}`);
// Create the connection
return new DataSource({
type: "sqlite",
database: DB_NAME,
...kwargs,
entities: [
path.resolve(__dirname, '../', './entity/**/*.ts'),
path.resolve(__dirname, '../', './entity/**/*.js')
]
}).initialize();
}
/**
* The types of databases that can be connected to.
*/
type DBTypes = 'sqlite' | 'postgres';
/**
* Creates a connection to the database.
*
* @param kwargs The keyword arguments to pass to the TypeORM DataSource. (ex. { logging: true })
* @returns The connection to the database.
*/
export async function createConn(dbType: DBTypes, kwargs: object): Promise<DataSource> {
// Return the connection based on the database type
switch(dbType) {
case 'sqlite':
return createSQLiteConn(kwargs);
break;
case 'postgres':
return createPostgreSQLConn(kwargs);
break;
}
}