eternity-web/main.go

603 lines
19 KiB
Go

package main
import (
"bytes"
"errors"
"github.com/go-chi/chi/v5"
"net/url"
"os"
"time"
"crypto/ed25519"
"database/sql"
"encoding/base64"
"encoding/json"
"html/template"
"io/fs"
"net/http"
"path/filepath"
library "git.ailur.dev/ailur/fg-library/v2"
authLibrary "git.ailur.dev/ailur/fg-nucleus-library"
"git.oreonproject.org/oreonproject/eternity/common"
"git.oreonproject.org/oreonproject/eternity/lib"
"github.com/go-git/go-git/v5"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
var ServiceInformation = library.Service{
Name: "eternity",
Permissions: library.Permissions{
Authenticate: true, // This service does require authentication
Database: true, // This service does require database access
BlobStorage: false, // This service does not require blob storage
InterServiceCommunication: true, // This service does require inter-service communication
Resources: true, // This service does require its HTTP templates and static files
},
ServiceID: uuid.MustParse("0f31fa2d-43ca-410f-8148-239b298112b3"),
}
func logFunc(message string, messageType uint64, information library.ServiceInitializationInformation) {
// Log the message to the logger service
information.Outbox <- library.InterServiceMessage{
ServiceID: ServiceInformation.ServiceID,
ForServiceID: uuid.MustParse("00000000-0000-0000-0000-000000000002"), // Logger service
MessageType: messageType,
SentAt: time.Now(),
Message: message,
}
}
func renderTemplate(statusCode int, w http.ResponseWriter, data map[string]interface{}, templatePath string, information library.ServiceInitializationInformation) {
var err error
var requestedTemplate *template.Template
// Output ls of the resource directory
requestedTemplate, err = template.ParseFS(information.ResourceDir, "templates/"+templatePath)
if err != nil {
logFunc(err.Error(), 2, information)
renderString(500, w, "Sorry, something went wrong on our end. Error code: 01. Please report to the administrator.", information)
} else {
w.WriteHeader(statusCode)
err = requestedTemplate.Execute(w, data)
if err != nil {
logFunc(err.Error(), 2, information)
renderString(500, w, "Sorry, something went wrong on our end. Error code: 02. Please report to the administrator.", information)
}
}
}
func renderString(statusCode int, w http.ResponseWriter, data string, information library.ServiceInitializationInformation) {
w.WriteHeader(statusCode)
_, err := w.Write([]byte(data))
if err != nil {
logFunc(err.Error(), 2, information)
}
}
func renderJSON(statusCode int, w http.ResponseWriter, data map[string]interface{}, information library.ServiceInitializationInformation) {
w.WriteHeader(statusCode)
err := json.NewEncoder(w).Encode(data)
if err != nil {
logFunc(err.Error(), 2, information)
}
}
func verifySecret(secret string, conn library.Database) bool {
// Check if the secret is in the secrets table
var secretCheck string
err := conn.DB.QueryRow("SELECT secret FROM secrets WHERE secret = $1", secret).Scan(&secretCheck)
if err != nil || secretCheck != secret {
return false
}
return true
}
func verifyJwt(token string, publicKey ed25519.PublicKey, conn library.Database) (jwt.MapClaims, bool) {
parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) {
return publicKey, nil
})
if err != nil {
return nil, false
}
if !parsedToken.Valid {
return nil, false
}
claims, ok := parsedToken.Claims.(jwt.MapClaims)
if !ok {
return nil, false
}
// Check if the token expired
date, err := claims.GetExpirationTime()
if err != nil || date.Before(time.Now()) || claims["sub"] != nil || claims["isOpenID"] != nil || claims["isOAuth"].(bool) {
return claims, false
}
// Check if the token is in users
var idCheck []byte
err = conn.DB.QueryRow("SELECT id FROM users WHERE id = $1", claims["sub"]).Scan(&idCheck)
if err != nil || claims["sub"] != uuid.Must(uuid.FromBytes(idCheck)).String() {
return claims, false
}
return claims, true
}
func Main(information library.ServiceInitializationInformation) *chi.Mux {
var conn library.Database
gitDir := information.Configuration["gitDir"].(string)
outputDir := information.Configuration["outputDir"].(string)
hostName := information.Configuration["hostName"].(string)
// Initiate a connection to the database
// Call service ID 1 to get the database connection information
information.Outbox <- library.InterServiceMessage{
ServiceID: ServiceInformation.ServiceID,
ForServiceID: uuid.MustParse("00000000-0000-0000-0000-000000000001"), // Service initialization service
MessageType: 1, // Request connection information
SentAt: time.Now(),
Message: nil,
}
// Wait for the response
response := <-information.Inbox
if response.MessageType == 2 {
// This is the connection information
// Set up the database connection
conn = response.Message.(library.Database)
if conn.DBType == library.Sqlite {
// Create the packages table
_, err := conn.DB.Exec("CREATE TABLE IF NOT EXISTS packages (creator BLOB NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL)")
if err != nil {
logFunc(err.Error(), 3, information)
}
// Create the secrets table
_, err = conn.DB.Exec("CREATE TABLE IF NOT EXISTS secrets (secret BLOB NOT NULL)")
if err != nil {
logFunc(err.Error(), 3, information)
}
// Create the users table
_, err = conn.DB.Exec("CREATE TABLE IF NOT EXISTS users (id BLOB NOT NULL)")
if err != nil {
logFunc(err.Error(), 3, information)
}
} else {
// Create the packages table
_, err := conn.DB.Exec("CREATE TABLE IF NOT EXISTS packages (creator BYTEA NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL)")
if err != nil {
logFunc(err.Error(), 3, information)
}
// Create the secrets table
_, err = conn.DB.Exec("CREATE TABLE IF NOT EXISTS secrets (secret BYTEA NOT NULL)")
if err != nil {
logFunc(err.Error(), 3, information)
}
// Create the users table
_, err = conn.DB.Exec("CREATE TABLE IF NOT EXISTS users (id BYTEA NOT NULL)")
if err != nil {
logFunc(err.Error(), 3, information)
}
}
} else {
// This is an error message
// Log the error message to the logger service
logFunc(response.Message.(error).Error(), 3, information)
}
// Ask the authentication service for the public key
information.Outbox <- library.InterServiceMessage{
ServiceID: ServiceInformation.ServiceID,
ForServiceID: uuid.MustParse("00000000-0000-0000-0000-000000000004"), // Authentication service
MessageType: 2, // Request public key
SentAt: time.Now(),
Message: nil,
}
var publicKey ed25519.PublicKey = nil
// 3 second timeout
go func() {
time.Sleep(3 * time.Second)
if publicKey == nil {
logFunc("Timeout while waiting for the public key from the authentication service", 3, information)
}
}()
// Wait for the response
response = <-information.Inbox
if response.MessageType == 2 {
// This is the public key
publicKey = response.Message.(ed25519.PublicKey)
} else {
// This is an error message
// Log the error message to the logger service
logFunc(response.Message.(error).Error(), 3, information)
}
// Ask the authentication service to create a new OAuth2 client
urlPath, err := url.JoinPath(hostName, "/oauth")
if err != nil {
logFunc(err.Error(), 3, information)
}
information.Outbox <- library.InterServiceMessage{
ServiceID: ServiceInformation.ServiceID,
ForServiceID: uuid.MustParse("00000000-0000-0000-0000-000000000004"), // Authentication service
MessageType: 1, // Create OAuth2 client
SentAt: time.Now(),
Message: authLibrary.OAuthInformation{
Name: "Eternity Web",
RedirectUri: urlPath,
KeyShareUri: "",
Scopes: []string{"openid"},
},
}
oauthResponse := authLibrary.OAuthResponse{}
// 3 second timeout
go func() {
time.Sleep(3 * time.Second)
if oauthResponse == (authLibrary.OAuthResponse{}) {
logFunc("Timeout while waiting for the OAuth response from the authentication service", 3, information)
}
}()
// Wait for the response
response = <-information.Inbox
switch response.MessageType {
case 0:
// Success, set the OAuth response
oauthResponse = response.Message.(authLibrary.OAuthResponse)
logFunc("Initialized with App ID: "+oauthResponse.AppID, 0, information)
case 1:
// An error which is their fault
logFunc(response.Message.(error).Error(), 3, information)
case 2:
// An error which is our fault
logFunc(response.Message.(error).Error(), 3, information)
default:
// An unknown error
logFunc("Unknown error", 3, information)
}
// Set up the router
router := chi.NewRouter()
// Set up the static routes
staticDir, err := fs.Sub(information.ResourceDir, "static")
if err != nil {
logFunc(err.Error(), 3, information)
} else {
router.Handle("/static-eternity/*", http.StripPrefix("/static-eternity/", http.FileServerFS(staticDir)))
}
// Set up the API routes
router.Post("/api/packages/list", func(w http.ResponseWriter, r *http.Request) {
// Get the list of packages
rows, err := conn.DB.Query("SELECT name FROM packages")
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "01"}, information)
return
}
var packages []string
for rows.Next() {
var name string
err = rows.Scan(&name)
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "02"}, information)
return
}
packages = append(packages, name)
}
renderJSON(200, w, map[string]interface{}{
"packages": packages,
}, information)
})
router.Post("/api/packages/add", func(w http.ResponseWriter, r *http.Request) {
type packageData struct {
Name string `json:"name"`
RepositoryPath string `json:"repositoryPath"`
JwtToken string `json:"token"`
Secret string `json:"secret"`
}
var data packageData
err := json.NewDecoder(r.Body).Decode(&data)
if err != nil {
renderJSON(400, w, map[string]interface{}{"error": "Invalid JSON"}, information)
return
}
var claims jwt.MapClaims
if data.Secret != "" {
// Verify the secret
if !verifySecret(data.Secret, conn) {
renderJSON(400, w, map[string]interface{}{"error": "Invalid secret"}, information)
return
}
} else {
// Verify the JWT token
var ok bool
claims, ok = verifyJwt(data.JwtToken, publicKey, conn)
if !ok {
renderJSON(400, w, map[string]interface{}{"error": "Invalid JWT token"}, information)
return
}
}
// Fetch the Git repository
_, err = git.PlainClone(filepath.Join(gitDir, data.Name), false, &git.CloneOptions{
Depth: 1,
URL: data.RepositoryPath,
})
if err != nil {
renderJSON(400, w, map[string]interface{}{"error": "Invalid repository path"}, information)
return
}
// Add the package to the database
var userid []byte
if data.Secret != "" {
userid, err = uuid.MustParse("00000000-0000-0000-0000-000000000000").MarshalBinary()
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "03"}, information)
}
} else {
userid, err = uuid.MustParse(claims["sub"].(string)).MarshalBinary()
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "03"}, information)
}
}
_, err = conn.DB.Exec("INSERT INTO packages (creator, name, path) VALUES ($1, $2, $3)", userid, data.Name, data.RepositoryPath)
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "04"}, information)
return
}
renderJSON(200, w, map[string]interface{}{"success": true}, information)
})
router.Post("/api/packages/remove", func(w http.ResponseWriter, r *http.Request) {
type packageData struct {
Name string `json:"name"`
JwtToken string `json:"token"`
Secret string `json:"secret"`
}
var data packageData
err := json.NewDecoder(r.Body).Decode(&data)
if err != nil {
renderJSON(400, w, map[string]interface{}{"error": "Invalid JSON"}, information)
return
}
// Verify the JWT token
var claims jwt.MapClaims
if data.Secret != "" {
// Verify the secret
if !verifySecret(data.Secret, conn) {
renderJSON(400, w, map[string]interface{}{"error": "Invalid secret"}, information)
return
}
} else {
// Verify the JWT token
var ok bool
claims, ok = verifyJwt(data.JwtToken, publicKey, conn)
if !ok {
renderJSON(400, w, map[string]interface{}{"error": "Invalid JWT token"}, information)
return
}
}
// Remove the package from the database
var userid []byte
if data.Secret != "" {
userid, err = uuid.MustParse("00000000-0000-0000-0000-000000000000").MarshalBinary()
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "03"}, information)
}
} else {
userid, err = uuid.MustParse(claims["sub"].(string)).MarshalBinary()
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "03"}, information)
}
}
_, err = conn.DB.Exec("DELETE FROM packages WHERE creator = $1 AND name = $2", userid, data.Name)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
renderJSON(404, w, map[string]interface{}{"error": "Package not found"}, information)
return
} else {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "04"}, information)
return
}
}
// Remove the package from the filesystem
err = os.RemoveAll(filepath.Join(gitDir, data.Name))
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "05"}, information)
return
}
renderJSON(200, w, map[string]interface{}{"success": true}, information)
})
router.Post("/api/packages/get", func(w http.ResponseWriter, r *http.Request) {
type packageData struct {
Name string `json:"name"`
JwtToken string `json:"token"`
Secret string `json:"secret"`
}
var data packageData
err := json.NewDecoder(r.Body).Decode(&data)
if err != nil {
renderJSON(400, w, map[string]interface{}{"error": "Invalid JSON"}, information)
return
}
// Verify the JWT token
if data.Secret != "" {
// Verify the secret
if !verifySecret(data.Secret, conn) {
renderJSON(400, w, map[string]interface{}{"error": "Invalid secret"}, information)
return
}
} else {
// Verify the JWT token
var ok bool
_, ok = verifyJwt(data.JwtToken, publicKey, conn)
if !ok {
renderJSON(400, w, map[string]interface{}{"error": "Invalid JWT token"}, information)
return
}
}
var nameCheck string
err = conn.DB.QueryRow("SELECT name FROM packages WHERE name = $1", data.Name).Scan(&nameCheck)
if err != nil || nameCheck != data.Name {
if err != nil && errors.Is(err, sql.ErrNoRows) {
renderJSON(404, w, map[string]interface{}{"error": "Package not found"}, information)
return
} else {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "04"}, information)
return
}
}
// Get the package from the resource directory
packageEpk, err := os.ReadFile(filepath.Join(outputDir, "packages", data.Name+".epk"))
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "06"}, information)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(200)
_, err = w.Write(packageEpk)
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "07"}, information)
logFunc(err.Error(), 2, information)
}
})
router.Post("/api/packages/compile", func(w http.ResponseWriter, r *http.Request) {
type packageData struct {
Name string `json:"name"`
JwtToken string `json:"token"`
Secret string `json:"secret"`
PrivateKey string `json:"privateKey"`
}
var data packageData
err := json.NewDecoder(r.Body).Decode(&data)
if err != nil {
renderJSON(400, w, map[string]interface{}{"error": "Invalid JSON"}, information)
return
}
// Verify the JWT token
if data.Secret != "" {
// Verify the secret
if !verifySecret(data.Secret, conn) {
renderJSON(400, w, map[string]interface{}{"error": "Invalid secret"}, information)
return
}
} else {
// Verify the JWT token
var ok bool
_, ok = verifyJwt(data.JwtToken, publicKey, conn)
if !ok {
renderJSON(400, w, map[string]interface{}{"error": "Invalid JWT token"}, information)
return
}
}
// Fetch the package from the database
var nameCheck, path string
err = conn.DB.QueryRow("SELECT name, path FROM packages WHERE name = $1", data.Name).Scan(&nameCheck, &path)
if err != nil || nameCheck != data.Name {
if err != nil && errors.Is(err, sql.ErrNoRows) {
renderJSON(404, w, map[string]interface{}{"error": "Package not found"}, information)
return
} else {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "04"}, information)
return
}
}
// Decode the base64 private key
privateKeyBytes, err := base64.StdEncoding.DecodeString(data.PrivateKey)
if err != nil {
renderJSON(400, w, map[string]interface{}{"error": "Invalid private key"}, information)
return
}
// Compile the package
// I'm sure you all know how eternity works, so I'm not going to explain this
// If you don't, https://git.oreonproject.org/oreonproject/eternity, it's really cool!
err = os.Chdir(filepath.Join(gitDir, data.Name))
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "08"}, information)
return
}
var stdout bytes.Buffer
logger := &lib.Logger{
LogFunc: func(log lib.Log) string {
stdout.Write([]byte(log.Content))
if log.Level == "fatal" {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "09", "stdout": stdout.String()}, information)
}
return ""
},
PromptSupported: false,
StdoutSupported: true,
Stdout: &stdout,
}
config, err, vagueErr := lib.ParseConfig("eternity.json", logger)
if err != nil || vagueErr != nil {
common.EternityJsonHandleError(err, vagueErr, logger)
}
size, tempDir, err, vagueErr := lib.BuildEPK("./", false, config.Build, logger)
if err != nil || vagueErr != nil {
common.BuildEPKHandleError(tempDir, err, vagueErr, logger)
}
config.Metadata.DecompressedSize = size
err, vagueErr = lib.PackageEPK(config.Metadata, config.Build, tempDir, filepath.Join(outputDir, "packages", data.Name+".epk"), privateKeyBytes, logger)
if err != nil || vagueErr != nil {
common.PackageEPKHandleError(err, vagueErr, logger)
}
common.BuildEPKRemoveTemporaryDirectory(tempDir, logger)
renderJSON(200, w, map[string]interface{}{"success": true, "stdout": stdout.String()}, information)
})
// Set up the template routes
router.Get("/", func(w http.ResponseWriter, r *http.Request) {
renderTemplate(200, w, map[string]interface{}{}, "index.html", information)
})
router.Get("/packages", func(w http.ResponseWriter, r *http.Request) {
renderTemplate(200, w, map[string]interface{}{}, "packages.html", information)
})
router.Get("/oauth", func(w http.ResponseWriter, r *http.Request) {
renderTemplate(200, w, map[string]interface{}{
"ClientId": oauthResponse.AppID,
}, "oauth.html", information)
})
return router
}