diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..52be0d9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.env +hatecomputers.club +Dockerfile +*.db diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..4493f14 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,20 @@ +--- +kind: pipeline +type: docker +name: build and publish docker image + +steps: +- name: docker + image: plugins/docker + settings: + username: + from_secret: gitea_packpub_username + password: + from_secret: gitea_packpub_password + repo: git.hatecomputers.club/hatecomputers/hatecomputers.club + tags: + - latest + - main +trigger: + branch: + - main \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..af80e67 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +CLOUDFLARE_TOKEN= +CLOUDFLARE_ZONE= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7bbdba --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +hatecomputers.club +*.db diff --git a/Dockerfile b/Dockerfile index 728ff30..d7d12cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,10 +5,10 @@ WORKDIR /app COPY go.mod go.sum ./ RUN go mod download -COPY *.go ./ +COPY . . RUN go build -o /app/hatecomputers EXPOSE 8080 -CMD ["/app/hatecomputers", "--port", "8080", "--template-path", "/app/templates", "--database-path", "/app/db/hatecomputers.db", "--static-path", "/app/static"] +CMD ["/app/hatecomputers", "--server", "--migrate", "--port", "8080", "--template-path", "/app/templates", "--database-path", "/app/db/hatecomputers.db", "--static-path", "/app/static"] diff --git a/api/serve.go b/api/serve.go new file mode 100644 index 0000000..2b95297 --- /dev/null +++ b/api/serve.go @@ -0,0 +1,111 @@ +package api + +import ( + "crypto/rand" + "database/sql" + "fmt" + "log" + "net/http" + "time" + + "git.hatecomputers.club/hatecomputers/hatecomputers.club/args" +) + +type RequestContext struct { + DBConn *sql.DB + Args *args.Arguments + + Id string + Start time.Time +} + +type Continuation func(*RequestContext, *http.Request, http.ResponseWriter) ContinuationChain +type ContinuationChain func(Continuation, Continuation) ContinuationChain + +func randomId() string { + uuid := make([]byte, 16) + _, err := rand.Read(uuid) + if err != nil { + panic(err) + } + + uuid[8] = uuid[8]&^0xc0 | 0x80 + uuid[6] = uuid[6]&^0xf0 | 0x40 + + return fmt.Sprintf("%x-%x-%x-%x-%x", uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:]) +} + +func LogRequestContinuation(context *RequestContext, req *http.Request, resp http.ResponseWriter) ContinuationChain { + return func(success Continuation, _failure Continuation) ContinuationChain { + context.Start = time.Now() + context.Id = randomId() + + log.Println(req.Method, req.URL.Path, req.RemoteAddr, context.Id) + return success(context, req, resp) + } +} + +func LogExecutionTimeContinuation(context *RequestContext, req *http.Request, resp http.ResponseWriter) ContinuationChain { + return func(success Continuation, _failure Continuation) ContinuationChain { + end := time.Now() + + log.Println(context.Id, "took", end.Sub(context.Start)) + + return success(context, req, resp) + } +} + +func HealthCheckContinuation(context *RequestContext, req *http.Request, resp http.ResponseWriter) ContinuationChain { + return func(success Continuation, _failure Continuation) ContinuationChain { + resp.WriteHeader(200) + resp.Write([]byte("healthy")) + return success(context, req, resp) + } +} + +func FailurePassingContinuation(context *RequestContext, req *http.Request, resp http.ResponseWriter) ContinuationChain { + return func(_success Continuation, failure Continuation) ContinuationChain { + return failure(context, req, resp) + } +} + +func IdContinuation(context *RequestContext, req *http.Request, resp http.ResponseWriter) ContinuationChain { + return func(success Continuation, _failure Continuation) ContinuationChain { + return success(context, req, resp) + } +} + +func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server { + mux := http.NewServeMux() + + fileServer := http.FileServer(http.Dir(argv.StaticPath)) + mux.Handle("/static/", http.StripPrefix("/static/", fileServer)) + + makeRequestContext := func() *RequestContext { + return &RequestContext{ + DBConn: dbConn, + Args: argv, + } + } + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + requestContext := makeRequestContext() + LogRequestContinuation(requestContext, r, w)(TemplateContinuation("home.html", nil, true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) { + requestContext := makeRequestContext() + LogRequestContinuation(requestContext, r, w)(HealthCheckContinuation, FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + mux.HandleFunc("GET /{name}", func(w http.ResponseWriter, r *http.Request) { + requestContext := makeRequestContext() + name := r.PathValue("name") + LogRequestContinuation(requestContext, r, w)(TemplateContinuation(name+".html", nil, true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + return &http.Server{ + Addr: ":" + fmt.Sprint(argv.Port), + Handler: mux, + } +} diff --git a/api/template.go b/api/template.go new file mode 100644 index 0000000..c666029 --- /dev/null +++ b/api/template.go @@ -0,0 +1,65 @@ +package api + +import ( + "bytes" + "errors" + "html/template" + "log" + "net/http" + "os" +) + +func renderTemplate(context *RequestContext, templateName string, showBaseHtml bool, data interface{}) (bytes.Buffer, error) { + templatePath := context.Args.TemplatePath + basePath := templatePath + "/base_empty.html" + if showBaseHtml { + basePath = templatePath + "/base.html" + } + + templateLocation := templatePath + "/" + templateName + tmpl, err := template.New("").ParseFiles(templateLocation, basePath) + if err != nil { + return bytes.Buffer{}, err + } + + var buffer bytes.Buffer + err = tmpl.ExecuteTemplate(&buffer, "base", data) + + if err != nil { + return bytes.Buffer{}, err + } + return buffer, nil +} + +func TemplateContinuation(path string, data interface{}, showBase bool) Continuation { + return func(context *RequestContext, req *http.Request, resp http.ResponseWriter) ContinuationChain { + return func(success Continuation, failure Continuation) ContinuationChain { + html, err := renderTemplate(context, path, true, data) + if errors.Is(err, os.ErrNotExist) { + resp.WriteHeader(404) + html, err = renderTemplate(context, "404.html", true, nil) + if err != nil { + log.Println("error rendering 404 template", err) + resp.WriteHeader(500) + return failure(context, req, resp) + } + + resp.Header().Set("Content-Type", "text/html") + resp.Write(html.Bytes()) + return failure(context, req, resp) + } + + if err != nil { + log.Println("error rendering template", err) + resp.WriteHeader(500) + resp.Write([]byte("error rendering template")) + return failure(context, req, resp) + } + + resp.WriteHeader(200) + resp.Header().Set("Content-Type", "text/html") + resp.Write(html.Bytes()) + return success(context, req, resp) + } + } +} diff --git a/args/args.go b/args/args.go new file mode 100644 index 0000000..9176d27 --- /dev/null +++ b/args/args.go @@ -0,0 +1,53 @@ +package args + +import ( + "errors" + "flag" + "os" +) + +type Arguments struct { + DatabasePath string + TemplatePath string + StaticPath string + CloudflareToken string + CloudflareZone string + Port int + Server bool + Migrate bool +} + +func GetArgs() (*Arguments, error) { + port := flag.Int("port", 8080, "Port to listen on") + databasePath := flag.String("database-path", "./hatecomputers.db", "Path to the SQLite database") + templatePath := flag.String("template-path", "./templates", "Path to the template directory") + staticPath := flag.String("static-path", "./static", "Path to the static directory") + + server := flag.Bool("server", false, "Run the server") + migrate := flag.Bool("migrate", false, "Run the migrations") + + flag.Parse() + + cloudflareToken := os.Getenv("CLOUDFLARE_TOKEN") + cloudflareZone := os.Getenv("CLOUDFLARE_ZONE") + + if cloudflareToken == "" { + return nil, errors.New("please set the CLOUDFLARE_TOKEN environment variable") + } + if cloudflareZone == "" { + return nil, errors.New("please set the CLOUDFLARE_ZONE environment variable") + } + + arguments := &Arguments{ + DatabasePath: *databasePath, + TemplatePath: *templatePath, + StaticPath: *staticPath, + CloudflareToken: cloudflareToken, + CloudflareZone: cloudflareZone, + Port: *port, + Server: *server, + Migrate: *migrate, + } + + return arguments, nil +} diff --git a/database/conn.go b/database/conn.go new file mode 100644 index 0000000..be27586 --- /dev/null +++ b/database/conn.go @@ -0,0 +1,17 @@ +package database + +import ( + "database/sql" + _ "github.com/mattn/go-sqlite3" + "log" +) + +func MakeConn(databasePath *string) *sql.DB { + log.Println("opening database at", *databasePath, "with foreign keys enabled") + dbConn, err := sql.Open("sqlite3", *databasePath+"?_foreign_keys=on") + if err != nil { + panic(err) + } + + return dbConn +} diff --git a/database/migrate.go b/database/migrate.go new file mode 100644 index 0000000..f10e03b --- /dev/null +++ b/database/migrate.go @@ -0,0 +1,84 @@ +package database + +import ( + "log" + + "database/sql" + _ "github.com/mattn/go-sqlite3" +) + +type Migrator func(*sql.DB) (*sql.DB, error) + +func MigrateUsers(dbConn *sql.DB) (*sql.DB, error) { + log.Println("migrating users table") + + _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + );`) + if err != nil { + return dbConn, err + } + + log.Println("creating unique index on users table") + _, err = dbConn.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users (username);`) + if err != nil { + return dbConn, err + } + + return dbConn, nil +} + +func MigrateApiKeys(dbConn *sql.DB) (*sql.DB, error) { + log.Println("migrating api_keys table") + + _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS api_keys ( + key TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) + );`) + if err != nil { + return dbConn, err + } + return dbConn, nil +} + +func MigrateDNSRecords(dbConn *sql.DB) (*sql.DB, error) { + log.Println("migrating dns_records table") + + _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS dns_records ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, + content TEXT NOT NULL, + ttl INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) + );`) + if err != nil { + return dbConn, err + } + return dbConn, nil +} + +func Migrate(dbConn *sql.DB) (*sql.DB, error) { + log.Println("migrating database") + + migrations := []Migrator{ + MigrateUsers, + MigrateApiKeys, + MigrateDNSRecords, + } + + for _, migration := range migrations { + dbConn, err := migration(dbConn) + if err != nil { + return dbConn, err + } + } + + return dbConn, nil +} diff --git a/src/database/users.go b/database/users.go similarity index 53% rename from src/database/users.go rename to database/users.go index 06e5808..6fb2601 100644 --- a/src/database/users.go +++ b/database/users.go @@ -1,3 +1,5 @@ +package database + func getUsers() { } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b568e87 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +version: "3" + +services: + api: + restart: always + #image: ghcr.io/utahstate/slack-incidents + build: . + healthcheck: + test: ["CMD", "wget", "--spider", "http://localhost:8080/api/health"] + interval: 5s + timeout: 10s + retries: 5 + env_file: .env + volumes: + - ./db:/app/db + - ./templates:/app/templates + - ./static:/app/static + ports: + - "127.0.0.1:4455:8080" diff --git a/go.mod b/go.mod index a3e9fb8..96a831f 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,8 @@ -module hatecomputers.club/m +module git.hatecomputers.club/hatecomputers/hatecomputers.club go 1.22.1 + +require ( + github.com/joho/godotenv v1.5.1 + github.com/mattn/go-sqlite3 v1.14.22 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c866887 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= diff --git a/main.go b/main.go new file mode 100644 index 0000000..2d7771b --- /dev/null +++ b/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "log" + + "git.hatecomputers.club/hatecomputers/hatecomputers.club/api" + "git.hatecomputers.club/hatecomputers/hatecomputers.club/args" + "git.hatecomputers.club/hatecomputers/hatecomputers.club/database" + "github.com/joho/godotenv" +) + +func main() { + log.SetFlags(log.LstdFlags | log.Lshortfile) + + err := godotenv.Load() + if err != nil { + log.Println("could not load .env file:", err) + } + + argv, err := args.GetArgs() + if err != nil { + log.Fatal(err) + } + + dbConn := database.MakeConn(&argv.DatabasePath) + defer dbConn.Close() + + if argv.Migrate { + _, err = database.Migrate(dbConn) + if err != nil { + log.Fatal(err) + } + log.Println("database migrated successfully") + } + + if argv.Server { + server := api.MakeServer(argv, dbConn) + log.Println("server listening on port", argv.Port) + err = server.ListenAndServe() + if err != nil { + log.Fatal(err) + } + } +} diff --git a/src/main.go b/src/main.go deleted file mode 100644 index f5ba944..0000000 --- a/src/main.go +++ /dev/null @@ -1,13 +0,0 @@ -package server - -import ( - "fmt" - "net/http" - - "github.com/joho/godotenv" - _ "github.com/mattn/go-sqlite3" -) - -func main() { - err := godotenv.Load() -} diff --git a/static/css/blinky.css b/static/css/blinky.css new file mode 100644 index 0000000..8bd636e --- /dev/null +++ b/static/css/blinky.css @@ -0,0 +1,9 @@ +.blinky { + animation: blinker 1s step-start infinite; +} + +@keyframes blinker { + 50% { + opacity: 0; + } +} diff --git a/static/css/colors.css b/static/css/colors.css new file mode 100644 index 0000000..159a142 --- /dev/null +++ b/static/css/colors.css @@ -0,0 +1,13 @@ +:root { + --background-color: #f4e8e9; + --text-color: #333; + --link-color: #d291bc; + --container-bg: #fff7f8; +} + +[data-theme="DARK"] { + --background-color: #333; + --text-color: #f4e8e9; + --link-color: #b86b77; + --container-bg: #424242; +} diff --git a/static/css/styles.css b/static/css/styles.css index 3b2f447..f62b32e 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -1,25 +1,23 @@ -:root { - /* Light theme colors */ - --background-color: #F4E8E9; /* Soft pink background */ - --text-color: #333; /* Dark text for contrast */ - --link-color: #D291BC; /* Retro pink for links */ - --container-bg: #FFF7F8; /* Very light pink for containers */ +@import "/static/css/colors.css"; +@import "/static/css/blinky.css"; + +@font-face { + font-family: "ComicSans"; + src: url("/static/font/comicsans.ttf"); } -.dark-mode { - /* Dark theme colors */ - --background-color: #333; /* Dark background */ - --text-color: #F4E8E9; /* Light text for contrast */ - --link-color: #B86B77; /* Soft pink for links */ - --container-bg: #424242; /* Darker shade for containers */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; + color: var(--text-color); } body { - font-family: 'ComicSans', sans-serif; + font-family: "ComicSans", sans-serif; background-color: var(--background-color); - color: var(--text-color); - padding: 20px; - text-align: center; + background-image: url("/static/img/stars.gif"); + min-height: 100vh; } a { @@ -33,10 +31,16 @@ a:hover { } .container { - max-width: 600px; + max-width: 1600px; margin: auto; + margin-top: 1rem; background-color: var(--container-bg); - padding: 20px; - border-radius: 8px; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + padding: 1rem; +} + +hr { + border: 0; + border-top: 1px solid var(--text-color); + + margin: 20px 0; } diff --git a/static/font/comicsans.ttf b/static/font/comicsans.ttf new file mode 100644 index 0000000..831e3d8 Binary files /dev/null and b/static/font/comicsans.ttf differ diff --git a/static/img/favicon.svg b/static/img/favicon.svg new file mode 100644 index 0000000..8451def --- /dev/null +++ b/static/img/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/img/stars.gif b/static/img/stars.gif new file mode 100644 index 0000000..91f82dd Binary files /dev/null and b/static/img/stars.gif differ diff --git a/static/js/components/themeSwitcher.js b/static/js/components/themeSwitcher.js new file mode 100644 index 0000000..5f6d927 --- /dev/null +++ b/static/js/components/themeSwitcher.js @@ -0,0 +1,27 @@ +const THEMES = { + DARK: "DARK", + LIGHT: "LIGHT", +}; + +const flipFlopTheme = (theme) => + THEMES[theme] === THEMES.DARK ? THEMES.LIGHT : THEMES.DARK; + +const themePickerText = { + DARK: "light mode.", + LIGHT: "dark mode.", +}; + +const themeSwitcher = document.getElementById("theme-switcher"); + +const setTheme = (theme) => { + themeSwitcher.textContent = `${themePickerText[theme]}`; + + document.documentElement.setAttribute("data-theme", theme); + localStorage.setItem("theme", theme); +}; + +themeSwitcher.addEventListener("click", () => + setTheme(flipFlopTheme(document.documentElement.getAttribute("data-theme"))), +); + +setTheme(localStorage.getItem("theme") ?? THEMES.LIGHT); diff --git a/static/js/require.js b/static/js/require.js new file mode 100644 index 0000000..a4203f0 --- /dev/null +++ b/static/js/require.js @@ -0,0 +1,5 @@ +/** vim: et:ts=4:sw=4:sts=4 + * @license RequireJS 2.3.6 Copyright jQuery Foundation and other contributors. + * Released under MIT license, https://github.com/requirejs/requirejs/blob/master/LICENSE + */ +var requirejs,require,define;!function(global,setTimeout){var req,s,head,baseElement,dataMain,src,interactiveScript,currentlyAddingScript,mainScript,subPath,version="2.3.6",commentRegExp=/\/\*[\s\S]*?\*\/|([^:"'=]|^)\/\/.*$/gm,cjsRequireRegExp=/[^.]\s*require\s*\(\s*["']([^'"\s]+)["']\s*\)/g,jsSuffixRegExp=/\.js$/,currDirRegExp=/^\.\//,op=Object.prototype,ostring=op.toString,hasOwn=op.hasOwnProperty,isBrowser=!("undefined"==typeof window||"undefined"==typeof navigator||!window.document),isWebWorker=!isBrowser&&"undefined"!=typeof importScripts,readyRegExp=isBrowser&&"PLAYSTATION 3"===navigator.platform?/^complete$/:/^(complete|loaded)$/,defContextName="_",isOpera="undefined"!=typeof opera&&"[object Opera]"===opera.toString(),contexts={},cfg={},globalDefQueue=[],useInteractive=!1;function commentReplace(e,t){return t||""}function isFunction(e){return"[object Function]"===ostring.call(e)}function isArray(e){return"[object Array]"===ostring.call(e)}function each(e,t){var i;if(e)for(i=0;ipage not found +

but hey, at least you found our witty 404 page. that's something, right?

+ +

go back home

+ +{{ end }} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index d855b51..fcd978e 100644 --- a/templates/base.html +++ b/templates/base.html @@ -6,14 +6,32 @@ hatecomputers.club + + + + + + + + +
- {{ template "content" . }} +
+

hatecomputers.club

+ +
+
+ +
+ {{ template "content" . }} +
+

hi

- + diff --git a/templates/base_empty.html b/templates/base_empty.html new file mode 100644 index 0000000..6191ab9 --- /dev/null +++ b/templates/base_empty.html @@ -0,0 +1,3 @@ +{{ define "base" }} + {{ template "content" . }} +{{ end }} \ No newline at end of file diff --git a/templates/guestbook.html b/templates/guestbook.html new file mode 100644 index 0000000..859daaf --- /dev/null +++ b/templates/guestbook.html @@ -0,0 +1,3 @@ +{{ define "content" }} +

guestbook

+{{ end }} diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..1938a03 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,3 @@ +{{ define "content" }} + +{{ end }} \ No newline at end of file