skeleton init
This commit is contained in:
commit
24356421a9
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
# ---> Go
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
priv
|
||||
# ensures readme still available
|
||||
!priv/readme.md
|
||||
|
||||
# ignore locally created testing content
|
||||
content/
|
||||
9
LICENSE
Normal file
9
LICENSE
Normal file
@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Keegan Deppe
|
||||
|
||||
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 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.
|
||||
8
Makefile
Normal file
8
Makefile
Normal file
@ -0,0 +1,8 @@
|
||||
build:
|
||||
docker build -t kb -f docker/Dockerfile .
|
||||
|
||||
up: build
|
||||
docker compose -f docker/docker-compose.yml --env-file priv/env up
|
||||
|
||||
clean:
|
||||
go clean
|
||||
6
config/env
Normal file
6
config/env
Normal file
@ -0,0 +1,6 @@
|
||||
GO_VERSION="1.25.5"
|
||||
|
||||
POSTGRES_USER="kb"
|
||||
POSTGRES_PASSWORD=""
|
||||
POSTGRES_DB="kb"
|
||||
POSTGRES_HOSTNAME="postgres"
|
||||
28
docker/Dockerfile
Normal file
28
docker/Dockerfile
Normal file
@ -0,0 +1,28 @@
|
||||
# build stage
|
||||
FROM golang:1.25-alpine AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Download Go modules
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# this could be simplified a lot by being stricter on what we copy.
|
||||
COPY . .
|
||||
|
||||
ENV GOCACHE=/root/.cache/go-build
|
||||
|
||||
RUN --mount=type=cache,target="/root/.cache/go-build" CGO_ENABLED=0 GOOS=linux go build -o kb .
|
||||
|
||||
# copy only binary and static files to slim image size
|
||||
FROM alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /src/kb .
|
||||
COPY --from=build /src/web ./web
|
||||
|
||||
# application binds to 80 by default
|
||||
EXPOSE 80
|
||||
|
||||
ENTRYPOINT ["/app/kb"]
|
||||
10
docker/docker-compose.yml
Normal file
10
docker/docker-compose.yml
Normal file
@ -0,0 +1,10 @@
|
||||
services:
|
||||
kb:
|
||||
image: kb:latest
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
- MARIADB_PASSWORD=${MARIADB_PASSWORD}
|
||||
- MARIADB_USER=${MARIADB_USER}
|
||||
- MARIADB_DB=${MARIADB_DB}
|
||||
- MARIADB_HOSTNAME=${MARIADB_HOSTNAME}
|
||||
17
go.mod
Normal file
17
go.mod
Normal file
@ -0,0 +1,17 @@
|
||||
module git.keegandeppe.com/kdeppe/kb
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.7.2
|
||||
github.com/yuin/goldmark v1.7.13
|
||||
golang.org/x/crypto v0.31.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
)
|
||||
30
go.sum
Normal file
30
go.sum
Normal file
@ -0,0 +1,30 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
||||
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
|
||||
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
8
internal/auth/auth.go
Normal file
8
internal/auth/auth.go
Normal file
@ -0,0 +1,8 @@
|
||||
// Package auth provides implements the basic login/signup functionality typical of modern websites as well as integrated TOTP.
|
||||
package auth
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
fmt.Println("vim-go")
|
||||
}
|
||||
23
internal/crypto/crypto.go
Normal file
23
internal/crypto/crypto.go
Normal file
@ -0,0 +1,23 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func Test() {
|
||||
|
||||
hash("test")
|
||||
hash("test")
|
||||
hash("test1")
|
||||
}
|
||||
|
||||
func hash(pwd string) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Password hash: %x\n", hash)
|
||||
}
|
||||
47
internal/db/db.go
Normal file
47
internal/db/db.go
Normal file
@ -0,0 +1,47 @@
|
||||
// package db serves as a database wrapper for adonis
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Database struct {
|
||||
conn *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewDatabase(username, password, hostname, database string) *Database {
|
||||
|
||||
// pool ensures thread safety
|
||||
url := url(username, password, hostname, database)
|
||||
dbpool, err := pgxpool.New(context.Background(), url)
|
||||
|
||||
for err != nil {
|
||||
dbpool, err = pgxpool.New(context.Background(), url)
|
||||
fmt.Fprintf(os.Stderr, "Unable to create connection pool: %v\nRetrying...\n", err)
|
||||
//os.Exit(1)
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
var greeting string
|
||||
err = dbpool.QueryRow(context.Background(), "select 'Hello, world!'").Scan(&greeting)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "QueryRow failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println(greeting)
|
||||
|
||||
return &Database{conn: dbpool}
|
||||
}
|
||||
|
||||
// this function assumes default port because i cant be bothered
|
||||
func url(username, password, hostname, database string) string {
|
||||
// generate url based on passed params
|
||||
// safe to disable SSL as comms occur over docker network
|
||||
return fmt.Sprintf("postgresql://%s:%s@%s/%s?sslmode=disable",
|
||||
username, password, hostname, database)
|
||||
}
|
||||
10
internal/routes/routes.go
Normal file
10
internal/routes/routes.go
Normal file
@ -0,0 +1,10 @@
|
||||
// Package routes is probably dog ass and a bad way to do things but i am a lemming
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
305
main.go
Normal file
305
main.go
Normal file
@ -0,0 +1,305 @@
|
||||
// main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"fmt"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
)
|
||||
|
||||
const (
|
||||
contentDir = "./content"
|
||||
port = "80"
|
||||
)
|
||||
|
||||
var (
|
||||
md = goldmark.New(
|
||||
goldmark.WithExtensions(
|
||||
extension.GFM,
|
||||
extension.Table,
|
||||
extension.Strikethrough,
|
||||
extension.TaskList,
|
||||
),
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithAutoHeadingID(),
|
||||
),
|
||||
goldmark.WithRendererOptions(
|
||||
html.WithHardWraps(),
|
||||
html.WithXHTML(),
|
||||
),
|
||||
)
|
||||
|
||||
templates = template.Must(template.ParseGlob("web/templates/*.html"))
|
||||
)
|
||||
|
||||
|
||||
|
||||
type Page struct {
|
||||
Title string
|
||||
Content template.HTML
|
||||
RawText string
|
||||
Path string
|
||||
IsEdit bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Ensure content directory exists
|
||||
if err := os.MkdirAll(contentDir, 0755); err != nil {
|
||||
log.Fatalf("Failed to create content directory: %v", err)
|
||||
}
|
||||
|
||||
// Test write permissions
|
||||
testFile := filepath.Join(contentDir, ".write-test")
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
||||
log.Fatalf("Content directory not writable: %v", err)
|
||||
}
|
||||
os.Remove(testFile)
|
||||
|
||||
http.HandleFunc("/", handleIndex)
|
||||
http.HandleFunc("/page/", handlePage)
|
||||
http.HandleFunc("/edit/", handleEdit)
|
||||
http.HandleFunc("/save", handleSave)
|
||||
|
||||
log.Printf("Starting knowledge base server on %s\n", port)
|
||||
log.Printf("Content directory: %s\n", contentDir)
|
||||
|
||||
if err := http.ListenAndServe(fmt.Sprintf("0.0.0.0:%s",port), nil); err != nil {
|
||||
log.Fatalf("Server failed to start: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
pages, err := listPages()
|
||||
if err != nil {
|
||||
log.Printf("Error listing pages: %v", err)
|
||||
http.Error(w, "Error loading page list", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := templates.ExecuteTemplate(w, "index.html", map[string]interface{}{
|
||||
"Pages": pages,
|
||||
}); err != nil {
|
||||
log.Printf("Error rendering index: %v", err)
|
||||
// Headers already sent, can't send error response
|
||||
}
|
||||
}
|
||||
|
||||
func handlePage(w http.ResponseWriter, r *http.Request) {
|
||||
pagePath := strings.TrimPrefix(r.URL.Path, "/page/")
|
||||
if pagePath == "" {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
content, err := readPage(pagePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Page doesn't exist, redirect to create it
|
||||
http.Redirect(w, r, "/edit/"+pagePath, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// Other error (permissions, I/O error, etc.)
|
||||
log.Printf("Error reading page %s: %v", pagePath, err)
|
||||
http.Error(w, "Error loading page", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var htmlContent strings.Builder
|
||||
if err := md.Convert([]byte(content), &htmlContent); err != nil {
|
||||
log.Printf("Error converting markdown for %s: %v", pagePath, err)
|
||||
http.Error(w, "Error rendering page", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
page := Page{
|
||||
Title: formatTitle(pagePath),
|
||||
Content: template.HTML(htmlContent.String()),
|
||||
Path: pagePath,
|
||||
IsEdit: false,
|
||||
}
|
||||
|
||||
if err := templates.ExecuteTemplate(w, "page.html", page); err != nil {
|
||||
log.Printf("Error executing template for %s: %v", pagePath, err)
|
||||
// Can't use http.Error here - headers already sent
|
||||
// Just log it; user will see partial page
|
||||
}
|
||||
}
|
||||
|
||||
func handleEdit(w http.ResponseWriter, r *http.Request) {
|
||||
pagePath := strings.TrimPrefix(r.URL.Path, "/edit/")
|
||||
if pagePath == "" {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
content, err := readPage(pagePath)
|
||||
if err != nil {
|
||||
// New page
|
||||
content = "# " + formatTitle(pagePath) + "\n\nStart writing your content here..."
|
||||
}
|
||||
|
||||
page := Page{
|
||||
Title: formatTitle(pagePath),
|
||||
RawText: content,
|
||||
Path: pagePath,
|
||||
IsEdit: true,
|
||||
}
|
||||
|
||||
templates.ExecuteTemplate(w, "edit.html", page)
|
||||
}
|
||||
|
||||
func handleSave(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse form data
|
||||
if err := r.ParseForm(); err != nil {
|
||||
log.Printf("Error parsing form: %v", err)
|
||||
http.Error(w, "Invalid form data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pagePath := r.FormValue("path")
|
||||
content := r.FormValue("content")
|
||||
|
||||
if pagePath == "" {
|
||||
http.Error(w, "Path is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate/sanitize path to prevent directory traversal
|
||||
if strings.Contains(pagePath, "..") {
|
||||
log.Printf("Attempted directory traversal: %s", pagePath)
|
||||
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := savePage(pagePath, content); err != nil {
|
||||
log.Printf("Error saving page %s: %v", pagePath, err)
|
||||
|
||||
if os.IsPermission(err) {
|
||||
http.Error(w, "Permission denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "Error saving page", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/page/"+pagePath, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
func listPages() ([]string, error) {
|
||||
var pages []string
|
||||
|
||||
err := filepath.Walk(contentDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
// Log but continue walking - don't fail entire listing for one bad file
|
||||
log.Printf("Error accessing %s: %v", path, err)
|
||||
return nil // Return nil to continue walking
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.HasSuffix(path, ".md") {
|
||||
relPath, err := filepath.Rel(contentDir, path)
|
||||
if err != nil {
|
||||
log.Printf("Error getting relative path for %s: %v", path, err)
|
||||
return nil // Continue despite error
|
||||
}
|
||||
pagePath := strings.TrimSuffix(relPath, ".md")
|
||||
pages = append(pages, pagePath)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("walking content directory: %w", err)
|
||||
}
|
||||
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
func formatTitle(pagePath string) string {
|
||||
parts := strings.Split(pagePath, "/")
|
||||
title := parts[len(parts)-1]
|
||||
title = strings.ReplaceAll(title, "-", " ")
|
||||
title = strings.ReplaceAll(title, "_", " ")
|
||||
return strings.Title(title)
|
||||
}
|
||||
func readPage(pagePath string) (string, error) {
|
||||
filePath := filepath.Join(contentDir, pagePath+".md")
|
||||
|
||||
// Security: ensure the path is within contentDir
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
absContentDir, err := filepath.Abs(contentDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid content dir: %w", err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(absPath, absContentDir) {
|
||||
return "", fmt.Errorf("path outside content directory")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", err // Let caller distinguish NotExist vs other errors
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func savePage(pagePath, content string) error {
|
||||
filePath := filepath.Join(contentDir, pagePath+".md")
|
||||
|
||||
// Security check (same as readPage)
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
absContentDir, err := filepath.Abs(contentDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid content dir: %w", err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(absPath, absContentDir) {
|
||||
return fmt.Errorf("path outside content directory")
|
||||
}
|
||||
|
||||
// Create subdirectories if needed
|
||||
dir := filepath.Dir(filePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("creating directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("writing file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
9
readme.md
Normal file
9
readme.md
Normal file
@ -0,0 +1,9 @@
|
||||
# kb
|
||||
|
||||
## Goals
|
||||
|
||||
basic knowledgebase to be usable solely in the browser
|
||||
|
||||
## Setup
|
||||
|
||||
`make up` runs `docker compose -f docker/docker-compose.yml --env-file priv/env up`
|
||||
176
web/css/style.css
Normal file
176
web/css/style.css
Normal file
@ -0,0 +1,176 @@
|
||||
/* static/style.css */
|
||||
:root {
|
||||
--primary: #0066cc;
|
||||
--primary-hover: #0052a3;
|
||||
--bg: #ffffff;
|
||||
--text: #333333;
|
||||
--border: #dddddd;
|
||||
--code-bg: #f5f5f5;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
background-color: var(--bg);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
border-bottom: 2px solid var(--border);
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.content h1, .content h2, .content h3 {
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.content code {
|
||||
background-color: var(--code-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.content pre {
|
||||
background-color: var(--code-bg);
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-list li {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.page-list a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.page-list a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.edit-link {
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #28a745;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#editor {
|
||||
width: 100%;
|
||||
min-height: 500px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.CodeMirror {
|
||||
height: 600px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
Create a `go.mod` file:
|
||||
```
|
||||
module knowledgebase
|
||||
|
||||
go 1.21
|
||||
|
||||
require github.com/yuin/goldmark v1.6.0
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
knowledgebase/
|
||||
├── main.go
|
||||
├── go.mod
|
||||
├── content/ (created automatically)
|
||||
├── static/
|
||||
│ └── style.css
|
||||
└── templates/
|
||||
├── index.html
|
||||
├── page.html
|
||||
└── edit.html
|
||||
1
web/js/htmx.min.js
vendored
Normal file
1
web/js/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
59
web/templates/edit.html
Normal file
59
web/templates/edit.html
Normal file
@ -0,0 +1,59 @@
|
||||
<!-- templates/edit.html -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Edit: {{.Title}} - Knowledge Base</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/theme/base16-dark.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/mode/markdown/markdown.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/page/{{.Path}}">← Cancel</a>
|
||||
<h1>Edit: {{.Title}}</h1>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<form method="POST" action="/save" id="editForm">
|
||||
<input type="hidden" name="path" value="{{.Path}}">
|
||||
<textarea id="editor" name="content">{{.RawText}}</textarea>
|
||||
<div class="button-group">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<a href="/page/{{.Path}}" class="btn">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var editor = CodeMirror.fromTextArea(document.getElementById('editor'), {
|
||||
mode: 'markdown',
|
||||
theme: 'base16-dark',
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
autoCloseBrackets: true,
|
||||
matchBrackets: true,
|
||||
indentUnit: 4,
|
||||
tabSize: 4,
|
||||
indentWithTabs: false
|
||||
});
|
||||
|
||||
// Keyboard shortcut for save (Ctrl+S or Cmd+S)
|
||||
editor.setOption("extraKeys", {
|
||||
"Ctrl-S": function(cm) {
|
||||
document.getElementById('editForm').submit();
|
||||
},
|
||||
"Cmd-S": function(cm) {
|
||||
document.getElementById('editForm').submit();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
33
web/templates/index.html
Normal file
33
web/templates/index.html
Normal file
@ -0,0 +1,33 @@
|
||||
<!-- templates/index.html -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Knowledge Base</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>📚 Knowledge Base</h1>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<h2>All Pages</h2>
|
||||
{{if .Pages}}
|
||||
<ul class="page-list">
|
||||
{{range .Pages}}
|
||||
<li>
|
||||
<a href="/page/{{.}}">{{.}}</a>
|
||||
<a href="/edit/{{.}}" class="edit-link">edit</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p>No pages yet. <a href="/edit/home">Create your first page</a></p>
|
||||
{{end}}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
25
web/templates/page.html
Normal file
25
web/templates/page.html
Normal file
@ -0,0 +1,25 @@
|
||||
<!-- templates/page.html -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} - Knowledge Base</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/">← Home</a>
|
||||
<h1>{{.Title}}</h1>
|
||||
<a href="/edit/{{.Path}}" class="btn">Edit</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
{{.Content}}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user