initial commit
This commit is contained in:
commit
c21f569144
37 changed files with 3956 additions and 0 deletions
32
.air.toml
Normal file
32
.air.toml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
root = "."
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
bin = "./tmp/main"
|
||||||
|
cmd = "templ generate && tailwindcss -i views/static/css/input.css -o views/static/css/output.css && go build -v -o ./tmp/main ./server/server.go"
|
||||||
|
delay = 500
|
||||||
|
exclude_dir = ["assets", "tmp", "vendor", "data"]
|
||||||
|
exclude_file = []
|
||||||
|
exclude_regex = [".*_templ.go", "output.css"]
|
||||||
|
exclude_unchanged = false
|
||||||
|
follow_symlink = false
|
||||||
|
full_bin = ""
|
||||||
|
include_dir = []
|
||||||
|
include_ext = ["go", "tpl", "tmpl", "templ", "html", "css"]
|
||||||
|
# kill_delay = "0s"
|
||||||
|
log = "build-errors.log"
|
||||||
|
send_interrupt = false
|
||||||
|
stop_on_error = true
|
||||||
|
|
||||||
|
[color]
|
||||||
|
app = ""
|
||||||
|
build = "yellow"
|
||||||
|
main = "magenta"
|
||||||
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = false
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
.env
|
||||||
|
views/static/css/output.css
|
||||||
|
*_templ.go
|
||||||
|
tmp/*
|
||||||
|
routes/tmp/*
|
||||||
|
views/static/box/*
|
||||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
FROM golang:1.23
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY views views
|
||||||
|
COPY server server
|
||||||
|
COPY database database
|
||||||
|
COPY routes routes
|
||||||
|
|
||||||
|
COPY tailwind.config.js ./
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
COPY Makefile ./
|
||||||
|
|
||||||
|
# não é legal
|
||||||
|
COPY .env ./
|
||||||
|
|
||||||
|
RUN wget https://github.com/tailwindlabs/tailwindcss/releases/download/v4.0.3/tailwindcss-linux-x64 \
|
||||||
|
--quiet \
|
||||||
|
-O tailwindcss
|
||||||
|
RUN chmod a+x tailwindcss
|
||||||
|
|
||||||
|
RUN go install github.com/a-h/templ/cmd/templ@latest
|
||||||
|
|
||||||
|
RUN templ generate .
|
||||||
|
|
||||||
|
# make não consegue encontrar o tailwind sem que ajustemos o PATH
|
||||||
|
RUN PATH=$PATH:/app make build
|
||||||
|
|
||||||
|
EXPOSE 8888
|
||||||
|
|
||||||
|
CMD ["/app/base"]
|
||||||
26
Makefile
Normal file
26
Makefile
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
.PHONY: dev build build-docker tw templ db_teste goose
|
||||||
|
|
||||||
|
include .env
|
||||||
|
|
||||||
|
dev:
|
||||||
|
sudo docker compose up db adminer prometheus -d
|
||||||
|
air
|
||||||
|
|
||||||
|
build: tw templ
|
||||||
|
go build -v -o base ./server
|
||||||
|
|
||||||
|
build-docker:
|
||||||
|
sudo docker compose up --force-recreate --build -d
|
||||||
|
|
||||||
|
tw:
|
||||||
|
tailwindcss -i views/static/css/input.css -o views/static/css/output.css
|
||||||
|
|
||||||
|
templ:
|
||||||
|
templ generate
|
||||||
|
|
||||||
|
|
||||||
|
db-teste: goose
|
||||||
|
psql -h localhost -U ${PG_USER} ${PG_DB} < database/testdata/teste.sql
|
||||||
|
|
||||||
|
goose:
|
||||||
|
goose postgres "postgres://${PG_USER}:${PG_PASS}@${PG_ADDR}/${PG_DB}" up -dir database/migrations
|
||||||
6
README.md
Normal file
6
README.md
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
# **This box URL belongs to me!**
|
||||||
|
|
||||||
|
teste
|
||||||
|
|
||||||
|
<script>alert("if this is executed it means i screwed up")</script>
|
||||||
75
database/box.go
Normal file
75
database/box.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"foobar/model"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SelectBox(boxURL model.BoxURL) (model.Box, error) {
|
||||||
|
row := instance.QueryRow(
|
||||||
|
`SELECT id, edit_code, header, private, moderation, created_at, last_updated_at
|
||||||
|
FROM box WHERE url = $1;`,
|
||||||
|
boxURL,
|
||||||
|
)
|
||||||
|
if row.Err() != nil {
|
||||||
|
return model.Box{}, row.Err()
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
id uuid.UUID
|
||||||
|
edit_code string
|
||||||
|
header *model.Markdown
|
||||||
|
private bool
|
||||||
|
moderation bool
|
||||||
|
createdAt time.Time
|
||||||
|
lastUpdatedAt time.Time
|
||||||
|
)
|
||||||
|
err := row.Scan(&id, &edit_code, &header, &private, &moderation, &createdAt, &lastUpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return model.Box{}, err
|
||||||
|
}
|
||||||
|
if header == nil {
|
||||||
|
header = new(model.Markdown)
|
||||||
|
*header = ""
|
||||||
|
}
|
||||||
|
return model.NewBox(id, boxURL, edit_code, *header, private, moderation, createdAt, lastUpdatedAt), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateHeader(tx *sql.Tx, boxId uuid.UUID, text model.Markdown) error {
|
||||||
|
result, err := tx.Exec(`UPDATE box SET header = $1 where id = $2`, text, boxId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
return errNoRowsAffected
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func InsertBox(tx *sql.Tx, box model.Box, editCode string, header model.Markdown) error {
|
||||||
|
result, err := tx.Exec(
|
||||||
|
`INSERT INTO box (id, url, edit_code, header, private, moderation, created_at, last_updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
box.ID(), box.Url(), editCode, header, box.Private(), box.Moderation(),
|
||||||
|
box.CreatedAt(), box.LastUpdatedAt(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
return errNoRowsAffected
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
86
database/database.go
Normal file
86
database/database.go
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"github.com/pressly/goose/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
instance *sql.DB
|
||||||
|
|
||||||
|
errNoRowsAffected = fmt.Errorf("no rows affected")
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
embedMigrations embed.FS
|
||||||
|
)
|
||||||
|
|
||||||
|
func New() *sql.DB {
|
||||||
|
|
||||||
|
// Evita criar mais de um banco
|
||||||
|
if instance != nil {
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carrega as variáveis de ambiente definadas no arquivo .env na raiz do projeto.
|
||||||
|
err := godotenv.Load()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
// Para rodar os testes desse pacote, É necessário que haja um symlink de um .env nesta
|
||||||
|
// pasta (database/) apontando para o .env no topo do projeto, já que 'go test' usa o
|
||||||
|
// diretório raiz do pacote (./database) para executar os testes enquanto 'go run' usa o diretório
|
||||||
|
// raiz do projeto.
|
||||||
|
|
||||||
|
// variáveis ambientais de .env são carregadas para o ambiente do nosso processo, basta
|
||||||
|
// usar a forma padrão para pegar os valores dessas variáveis.
|
||||||
|
var (
|
||||||
|
user = os.Getenv("PG_USER")
|
||||||
|
pass = os.Getenv("PG_PASS")
|
||||||
|
dbName = os.Getenv("PG_DB")
|
||||||
|
hostAddr = os.Getenv("PG_ADDR")
|
||||||
|
)
|
||||||
|
|
||||||
|
connectionString := fmt.Sprintf("postgresql://%s:%s@%s:5432/%s", user, pass, hostAddr, dbName)
|
||||||
|
db, err := sql.Open("pgx", connectionString)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica se a conexão foi um sucesso
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
goose.SetBaseFS(embedMigrations)
|
||||||
|
if err := goose.SetDialect("postgres"); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
// Aplica todas as migrações disponíveis
|
||||||
|
if err := goose.Up(db, "migrations"); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
instance = db
|
||||||
|
log.Println("Nova instância do banco de dados foi criada.")
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
|
func MustBeginTx() *sql.Tx {
|
||||||
|
if instance == nil {
|
||||||
|
panic(fmt.Errorf("Banco de dados não foi inicializado. Não foi possível criar nova transação."))
|
||||||
|
}
|
||||||
|
tx, err := instance.BeginTx(context.Background(), nil)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return tx
|
||||||
|
}
|
||||||
82
database/database_test.go
Normal file
82
database/database_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"foobar/model"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNew(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_ = New()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBox(t *testing.T) {
|
||||||
|
_ = New()
|
||||||
|
|
||||||
|
boxURL, err := model.CheckBoxURL("1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := SelectBox(boxURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.ID().String() == "" {
|
||||||
|
t.Fatalf("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInsertBox(t *testing.T) {
|
||||||
|
|
||||||
|
_ = New()
|
||||||
|
tx := MustBeginTx()
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
url, err := model.CheckBoxURL("foobar")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
box := model.NewBox(uuid.New(), url, "foobar", "", false, false, time.Now(), time.Now())
|
||||||
|
|
||||||
|
err = InsertBox(tx, box, "foobar", "foobar")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterFile(t *testing.T) {
|
||||||
|
_ = New()
|
||||||
|
tx := MustBeginTx()
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
url, err := model.CheckBoxURL("foobar")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
box := model.NewBox(uuid.New(), url, "foobar", "", false, false, time.Now(), time.Now())
|
||||||
|
|
||||||
|
err = InsertBox(tx, box, "foobar", "foobar")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var file = model.NewFile(
|
||||||
|
uuid.New(), "foobar", 6, time.Now(), model.FileMime("plain/text"), md5.Sum([]byte("foobar")),
|
||||||
|
)
|
||||||
|
|
||||||
|
err = InsertFile(tx, box.ID(), file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
65
database/file.go
Normal file
65
database/file.go
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"foobar/model"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func InsertFile(tx *sql.Tx, boxId uuid.UUID, file model.File) error {
|
||||||
|
|
||||||
|
row, err := tx.Exec(
|
||||||
|
`INSERT INTO file (id, id_box, name, created_at, size, mime, md5)
|
||||||
|
VALUES
|
||||||
|
($1, $2, $3, $4, $5, $6, $7);`,
|
||||||
|
file.ID(), boxId, file.Name(), file.CreatedAt(),
|
||||||
|
file.Size(), file.Mime(), file.Checksum(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := row.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected != 1 {
|
||||||
|
return errNoRowsAffected
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SelectBoxFiles(boxID uuid.UUID) (f []model.File, e error) {
|
||||||
|
// TODO: teste
|
||||||
|
row, err := instance.Query(
|
||||||
|
`SELECT id, name, size, created_at, mime, md5
|
||||||
|
FROM file WHERE id_box = $1 ORDER BY created_at DESC`,
|
||||||
|
boxID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for row.Next() {
|
||||||
|
var (
|
||||||
|
id uuid.UUID
|
||||||
|
name model.Filename
|
||||||
|
size int64
|
||||||
|
createdAt time.Time
|
||||||
|
mime model.FileMime
|
||||||
|
encodedMd5 string
|
||||||
|
)
|
||||||
|
err = row.Scan(&id, &name, &size, &createdAt, &mime, &encodedMd5)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
decodedMD5, err := hex.DecodeString(encodedMd5)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
f = append(f, model.NewFile(id, name, size, createdAt, mime, model.MD5Checksum(decodedMD5)))
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
36
database/migrations/20250202024620_init_usuario.sql
Normal file
36
database/migrations/20250202024620_init_usuario.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- Diff code generated with pgModeler (PostgreSQL Database Modeler)
|
||||||
|
-- pgModeler version: 1.2.0-alpha1
|
||||||
|
-- Diff date: 2025-02-01 23:56:43
|
||||||
|
-- Source model: dev1
|
||||||
|
-- Database: dev1
|
||||||
|
-- PostgreSQL version: 16.0
|
||||||
|
|
||||||
|
-- [ Diff summary ]
|
||||||
|
-- Dropped objects: 0
|
||||||
|
-- Created objects: 1
|
||||||
|
-- Changed objects: 0
|
||||||
|
|
||||||
|
SET search_path=public,pg_catalog;
|
||||||
|
-- ddl-end --
|
||||||
|
|
||||||
|
|
||||||
|
-- [ Created objects ] --
|
||||||
|
-- object: public.usuario | type: TABLE --
|
||||||
|
-- DROP TABLE IF EXISTS public.usuario CASCADE;
|
||||||
|
CREATE TABLE public.usuario (
|
||||||
|
id integer NOT NULL GENERATED ALWAYS AS IDENTITY ,
|
||||||
|
email text NOT NULL,
|
||||||
|
senha_hash text NOT NULL,
|
||||||
|
nome text NOT NULL,
|
||||||
|
ctime timestamp NOT NULL DEFAULT current_timestamp,
|
||||||
|
CONSTRAINT usuario_pk PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
-- ddl-end --
|
||||||
|
ALTER TABLE public.usuario OWNER TO dev;
|
||||||
|
-- ddl-end --
|
||||||
|
|
||||||
|
|
||||||
36
database/migrations/20250412193502_file.sql
Normal file
36
database/migrations/20250412193502_file.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
-- +goose Up
|
||||||
|
-- Diff code generated with pgModeler (PostgreSQL Database Modeler)
|
||||||
|
-- pgModeler version: 1.2.0-alpha1
|
||||||
|
-- Diff date: 2025-04-12 16:39:19
|
||||||
|
-- Source model: dev1
|
||||||
|
-- Database: dev1
|
||||||
|
-- PostgreSQL version: 17.0
|
||||||
|
|
||||||
|
-- [ Diff summary ]
|
||||||
|
-- Dropped objects: 1
|
||||||
|
-- Created objects: 1
|
||||||
|
-- Changed objects: 0
|
||||||
|
|
||||||
|
SET search_path=public,pg_catalog;
|
||||||
|
-- ddl-end --
|
||||||
|
|
||||||
|
|
||||||
|
-- [ Dropped objects ] --
|
||||||
|
DROP TABLE IF EXISTS public.usuario CASCADE;
|
||||||
|
-- ddl-end --
|
||||||
|
|
||||||
|
|
||||||
|
-- [ Created objects ] --
|
||||||
|
-- object: public.file | type: TABLE --
|
||||||
|
-- DROP TABLE IF EXISTS public.file CASCADE;
|
||||||
|
CREATE TABLE public.file (
|
||||||
|
id uuid NOT NULL,
|
||||||
|
name varchar(256) NOT NULL,
|
||||||
|
size integer NOT NULL,
|
||||||
|
created_at timestamp NOT NULL,
|
||||||
|
CONSTRAINT file_pk PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
-- ddl-end --
|
||||||
|
ALTER TABLE public.file OWNER TO dev;
|
||||||
|
-- ddl-end --
|
||||||
|
|
||||||
32
database/migrations/20250414041807_file_mime.sql
Normal file
32
database/migrations/20250414041807_file_mime.sql
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
-- +goose Up
|
||||||
|
-- Diff code generated with pgModeler (PostgreSQL Database Modeler)
|
||||||
|
-- pgModeler version: 1.2.0-alpha1
|
||||||
|
-- Diff date: 2025-04-14 01:18:24
|
||||||
|
-- Source model: dev1
|
||||||
|
-- Database: dev1
|
||||||
|
-- PostgreSQL version: 17.0
|
||||||
|
|
||||||
|
-- [ Diff summary ]
|
||||||
|
-- Dropped objects: 0
|
||||||
|
-- Created objects: 2
|
||||||
|
-- Changed objects: 0
|
||||||
|
|
||||||
|
SET search_path=public,pg_catalog;
|
||||||
|
-- ddl-end --
|
||||||
|
|
||||||
|
|
||||||
|
-- [ Created objects ] --
|
||||||
|
-- object: mime | type: COLUMN --
|
||||||
|
ALTER TABLE public.file ADD COLUMN mime text;
|
||||||
|
-- ddl-end --
|
||||||
|
|
||||||
|
UPDATE public.file SET mime = '';
|
||||||
|
|
||||||
|
ALTER TABLE public.file ALTER COLUMN mime SET NOT NULL;
|
||||||
|
|
||||||
|
|
||||||
|
-- object: message | type: COLUMN --
|
||||||
|
ALTER TABLE public.file ADD COLUMN message varchar(2048);
|
||||||
|
-- ddl-end --
|
||||||
|
|
||||||
|
|
||||||
71
database/migrations/20250415031815_box_table.sql
Normal file
71
database/migrations/20250415031815_box_table.sql
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
-- +goose Up
|
||||||
|
-- Diff code generated with pgModeler (PostgreSQL Database Modeler)
|
||||||
|
-- pgModeler version: 1.2.0-alpha1
|
||||||
|
-- Diff date: 2025-04-15 00:18:26
|
||||||
|
-- Source model: dev1
|
||||||
|
-- Database: dev1
|
||||||
|
-- PostgreSQL version: 17.0
|
||||||
|
|
||||||
|
-- [ Diff summary ]
|
||||||
|
-- Dropped objects: 0
|
||||||
|
-- Created objects: 6
|
||||||
|
-- Changed objects: 0
|
||||||
|
|
||||||
|
SET search_path=public,pg_catalog;
|
||||||
|
-- ddl-end --
|
||||||
|
|
||||||
|
|
||||||
|
-- [ Created objects ] --
|
||||||
|
-- object: width | type: COLUMN --
|
||||||
|
ALTER TABLE public.file ADD COLUMN width smallint;
|
||||||
|
-- ddl-end --
|
||||||
|
|
||||||
|
|
||||||
|
-- object: height | type: COLUMN --
|
||||||
|
ALTER TABLE public.file ADD COLUMN height smallint;
|
||||||
|
-- ddl-end --
|
||||||
|
|
||||||
|
|
||||||
|
-- object: public.box | type: TABLE --
|
||||||
|
-- DROP TABLE IF EXISTS public.box CASCADE;
|
||||||
|
CREATE TABLE public.box (
|
||||||
|
id uuid NOT NULL,
|
||||||
|
url varchar(256) NOT NULL,
|
||||||
|
edit_code varchar(64) NOT NULL,
|
||||||
|
header varchar(4096),
|
||||||
|
private boolean NOT NULL DEFAULT false,
|
||||||
|
moderation boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamp NOT NULL DEFAULT current_timestamp,
|
||||||
|
last_updated_at timestamp NOT NULL DEFAULT current_timestamp,
|
||||||
|
CONSTRAINT url_uq UNIQUE (url),
|
||||||
|
CONSTRAINT box_pk PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
-- ddl-end --
|
||||||
|
ALTER TABLE public.box OWNER TO dev;
|
||||||
|
-- ddl-end --
|
||||||
|
|
||||||
|
-- object: id_box | type: COLUMN --
|
||||||
|
ALTER TABLE public.file ADD COLUMN id_box uuid;
|
||||||
|
-- ddl-end --
|
||||||
|
INSERT INTO public.box (id, url, edit_code) VALUES ('ce407978-b599-4cd6-9ddf-e9249280d321', '1', 'foobar');
|
||||||
|
UPDATE public.file SET id_box = 'ce407978-b599-4cd6-9ddf-e9249280d321';
|
||||||
|
ALTER TABLE public.file ALTER COLUMN id_box SET NOT NULL;
|
||||||
|
|
||||||
|
|
||||||
|
-- object: md5 | type: COLUMN --
|
||||||
|
ALTER TABLE public.file ADD COLUMN md5 text;
|
||||||
|
-- ddl-end --
|
||||||
|
UPDATE public.file SET md5 = '';
|
||||||
|
ALTER TABLE public.file ALTER COLUMN md5 SET NOT NULL;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- [ Created foreign keys ] --
|
||||||
|
-- object: box_fk | type: CONSTRAINT --
|
||||||
|
-- ALTER TABLE public.file DROP CONSTRAINT IF EXISTS box_fk CASCADE;
|
||||||
|
ALTER TABLE public.file ADD CONSTRAINT box_fk FOREIGN KEY (id_box)
|
||||||
|
REFERENCES public.box (id) MATCH FULL
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
-- ddl-end --
|
||||||
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
-- +goose Up
|
||||||
|
-- Diff code generated with pgModeler (PostgreSQL Database Modeler)
|
||||||
|
-- pgModeler version: 1.2.0-alpha1
|
||||||
|
-- Diff date: 2025-04-19 17:49:54
|
||||||
|
-- Source model: dev1
|
||||||
|
-- Database: dev1
|
||||||
|
-- PostgreSQL version: 17.0
|
||||||
|
|
||||||
|
-- [ Diff summary ]
|
||||||
|
-- Dropped objects: 0
|
||||||
|
-- Created objects: 0
|
||||||
|
-- Changed objects: 2
|
||||||
|
|
||||||
|
SET search_path=public,pg_catalog;
|
||||||
|
-- ddl-end --
|
||||||
|
|
||||||
|
|
||||||
|
-- [ Changed objects ] --
|
||||||
|
ALTER TABLE public.file ALTER COLUMN message TYPE varchar(8192);
|
||||||
|
-- ddl-end --
|
||||||
|
ALTER TABLE public.box ALTER COLUMN header TYPE varchar(16384);
|
||||||
|
-- ddl-end --
|
||||||
20
database/migrations/20250421015240_box_header_to_text.sql
Normal file
20
database/migrations/20250421015240_box_header_to_text.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
-- +goose Up
|
||||||
|
-- Diff code generated with pgModeler (PostgreSQL Database Modeler)
|
||||||
|
-- pgModeler version: 1.2.0-alpha1
|
||||||
|
-- Diff date: 2025-04-20 22:52:53
|
||||||
|
-- Source model: dev1
|
||||||
|
-- Database: dev1
|
||||||
|
-- PostgreSQL version: 17.0
|
||||||
|
|
||||||
|
-- [ Diff summary ]
|
||||||
|
-- Dropped objects: 0
|
||||||
|
-- Created objects: 0
|
||||||
|
-- Changed objects: 1
|
||||||
|
|
||||||
|
SET search_path=public,pg_catalog;
|
||||||
|
-- ddl-end --
|
||||||
|
|
||||||
|
|
||||||
|
-- [ Changed objects ] --
|
||||||
|
ALTER TABLE public.box ALTER COLUMN header TYPE text;
|
||||||
|
-- ddl-end --
|
||||||
47
database/model.go
Normal file
47
database/model.go
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Model interface {
|
||||||
|
Id() (int, error)
|
||||||
|
SetId(int)
|
||||||
|
Saved() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type identity struct {
|
||||||
|
id int
|
||||||
|
saved bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotSaved = fmt.Errorf("primary key id não foi salva")
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p *identity) Id() (int, error) {
|
||||||
|
if !p.saved {
|
||||||
|
return 0, ErrNotSaved
|
||||||
|
}
|
||||||
|
return p.id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *identity) SetId(i int) {
|
||||||
|
p.id = i
|
||||||
|
p.saved = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *identity) Saved() bool {
|
||||||
|
return p.saved
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkPasswordHash(password, hash string) error {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
|
||||||
|
return string(bytes), err
|
||||||
|
}
|
||||||
153
database/pgmodeler/model.dbm
Normal file
153
database/pgmodeler/model.dbm
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
CAUTION: Do not modify this file unless you know what you are doing.
|
||||||
|
Unexpected results may occur if the code is changed deliberately.
|
||||||
|
-->
|
||||||
|
<dbmodel pgmodeler-ver="1.2.0-alpha1" use-changelog="false" max-obj-count="4"
|
||||||
|
last-position="0,29" last-zoom="1"
|
||||||
|
default-schema="public" default-owner="dev"
|
||||||
|
layers="Default layer"
|
||||||
|
active-layers="0"
|
||||||
|
layer-name-colors="#000000"
|
||||||
|
layer-rect-colors="#b4b4b4"
|
||||||
|
show-layer-names="false" show-layer-rects="false">
|
||||||
|
<role name="dev"
|
||||||
|
superuser="true"
|
||||||
|
createdb="true"
|
||||||
|
replication="true"
|
||||||
|
createrole="true"
|
||||||
|
inherit="true"
|
||||||
|
login="true"
|
||||||
|
bypassrls="true"
|
||||||
|
password="********"
|
||||||
|
sql-disabled="true">
|
||||||
|
</role>
|
||||||
|
|
||||||
|
<database name="dev1" encoding="UTF8" lc-collate="en_US.utf8" lc-ctype="en_US.utf8" is-template="false" allow-conns="true">
|
||||||
|
<role name="dev"/>
|
||||||
|
<tablespace name="pg_default"/>
|
||||||
|
</database>
|
||||||
|
|
||||||
|
<schema name="public" layers="0" rect-visible="true" fill-color="#e1e1e1" name-color="#000000" sql-disabled="true">
|
||||||
|
</schema>
|
||||||
|
|
||||||
|
<table name="goose_db_version" layers="0" collapse-mode="2" max-obj-count="4" z-value="0">
|
||||||
|
<schema name="public"/>
|
||||||
|
<role name="dev"/>
|
||||||
|
<position x="440" y="300"/>
|
||||||
|
<column name="id" not-null="true"
|
||||||
|
identity-type="BY DEFAULT" start="1" increment="1" min-value="1" max-value="2147483647" cache="1">
|
||||||
|
<type name="integer" length="0"/>
|
||||||
|
</column>
|
||||||
|
<column name="version_id" not-null="true">
|
||||||
|
<type name="bigint" length="0"/>
|
||||||
|
</column>
|
||||||
|
<column name="is_applied" not-null="true">
|
||||||
|
<type name="boolean" length="0"/>
|
||||||
|
</column>
|
||||||
|
<column name="tstamp" not-null="true" default-value="now()">
|
||||||
|
<type name="timestamp" length="0"/>
|
||||||
|
</column>
|
||||||
|
<constraint name="goose_db_version_pkey" type="pk-constr" table="public.goose_db_version">
|
||||||
|
<columns names="id" ref-type="src-columns"/>
|
||||||
|
</constraint>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<sequence name="goose_db_version_id_seq" cycle="false" start="1" increment="1" min-value="1" max-value="2147483647" cache="1" sql-disabled="true">
|
||||||
|
<schema name="public"/>
|
||||||
|
<role name="dev"/>
|
||||||
|
</sequence>
|
||||||
|
|
||||||
|
<sequence name="usuario_id_seq" cycle="false" start="1" increment="1" min-value="1" max-value="2147483647" cache="1" sql-disabled="true">
|
||||||
|
<schema name="public"/>
|
||||||
|
<role name="dev"/>
|
||||||
|
</sequence>
|
||||||
|
|
||||||
|
<table name="file" layers="0" collapse-mode="2" max-obj-count="12" z-value="0">
|
||||||
|
<schema name="public"/>
|
||||||
|
<role name="dev"/>
|
||||||
|
<position x="1160" y="520"/>
|
||||||
|
<column name="id" not-null="true">
|
||||||
|
<type name="uuid" length="0"/>
|
||||||
|
</column>
|
||||||
|
<column name="name" not-null="true">
|
||||||
|
<type name="varchar" length="256"/>
|
||||||
|
</column>
|
||||||
|
<column name="size" not-null="true">
|
||||||
|
<type name="integer" length="0"/>
|
||||||
|
</column>
|
||||||
|
<column name="created_at" not-null="true">
|
||||||
|
<type name="timestamp" length="0"/>
|
||||||
|
</column>
|
||||||
|
<column name="mime" not-null="true">
|
||||||
|
<type name="text" length="0"/>
|
||||||
|
</column>
|
||||||
|
<column name="message">
|
||||||
|
<type name="varchar" length="8192"/>
|
||||||
|
</column>
|
||||||
|
<column name="width">
|
||||||
|
<type name="smallint" length="0"/>
|
||||||
|
</column>
|
||||||
|
<column name="height">
|
||||||
|
<type name="smallint" length="0"/>
|
||||||
|
</column>
|
||||||
|
<column name="md5" not-null="true">
|
||||||
|
<type name="text" length="0"/>
|
||||||
|
</column>
|
||||||
|
<constraint name="file_pk" type="pk-constr" table="public.file">
|
||||||
|
<columns names="id" ref-type="src-columns"/>
|
||||||
|
</constraint>
|
||||||
|
|
||||||
|
<customidxs object-type="column">
|
||||||
|
<object name="id_box" index="8"/>
|
||||||
|
</customidxs>
|
||||||
|
<customidxs object-type="constraint">
|
||||||
|
<object name="box_fk" index="1"/>
|
||||||
|
</customidxs></table>
|
||||||
|
|
||||||
|
<table name="box" layers="0" collapse-mode="2" max-obj-count="9" z-value="0">
|
||||||
|
<schema name="public"/>
|
||||||
|
<role name="dev"/>
|
||||||
|
<position x="560" y="560"/>
|
||||||
|
<column name="id" not-null="true">
|
||||||
|
<type name="uuid" length="0"/>
|
||||||
|
</column>
|
||||||
|
<column name="url" not-null="true">
|
||||||
|
<type name="varchar" length="256"/>
|
||||||
|
</column>
|
||||||
|
<column name="edit_code" not-null="true">
|
||||||
|
<type name="varchar" length="64"/>
|
||||||
|
</column>
|
||||||
|
<column name="header">
|
||||||
|
<type name="text" length="16384"/>
|
||||||
|
</column>
|
||||||
|
<column name="private" not-null="true" default-value="false">
|
||||||
|
<type name="boolean" length="0"/>
|
||||||
|
</column>
|
||||||
|
<column name="moderation" not-null="true" default-value="false">
|
||||||
|
<type name="boolean" length="0"/>
|
||||||
|
</column>
|
||||||
|
<column name="created_at" not-null="true" default-value="current_timestamp">
|
||||||
|
<type name="timestamp" length="0"/>
|
||||||
|
</column>
|
||||||
|
<column name="last_updated_at" not-null="true" default-value="current_timestamp">
|
||||||
|
<type name="timestamp" length="0"/>
|
||||||
|
</column>
|
||||||
|
<constraint name="url_uq" type="uq-constr" table="public.box">
|
||||||
|
<columns names="url" ref-type="src-columns"/>
|
||||||
|
</constraint>
|
||||||
|
<constraint name="box_pk" type="pk-constr" table="public.box">
|
||||||
|
<columns names="id" ref-type="src-columns"/>
|
||||||
|
</constraint>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<relationship name="box_has_many_file" type="rel1n" layers="0"
|
||||||
|
src-col-pattern="{sc}_{st}"
|
||||||
|
pk-pattern="{dt}_pk" uq-pattern="{dt}_uq"
|
||||||
|
src-fk-pattern="{st}_fk"
|
||||||
|
custom-color="#ccd394"
|
||||||
|
src-table="public.box"
|
||||||
|
dst-table="public.file"
|
||||||
|
src-required="true" dst-required="false"/>
|
||||||
|
|
||||||
|
</dbmodel>
|
||||||
69
database/usuario.go
Normal file
69
database/usuario.go
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Usuario struct {
|
||||||
|
identity
|
||||||
|
Email string
|
||||||
|
Nome string
|
||||||
|
// senhaHash string `json:"-"`
|
||||||
|
Ctime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func VerifyUser(tx *sql.Tx, email, senha string) (*Usuario, error) {
|
||||||
|
row := tx.QueryRow(
|
||||||
|
`SELECT id, nome, ctime, senha_hash FROM usuario WHERE email = $1 LIMIT 1`,
|
||||||
|
email,
|
||||||
|
)
|
||||||
|
if row.Err() != nil {
|
||||||
|
return nil, row.Err()
|
||||||
|
}
|
||||||
|
usuario := &Usuario{
|
||||||
|
Email: email,
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int
|
||||||
|
var senhaHash string
|
||||||
|
err := row.Scan(
|
||||||
|
&id,
|
||||||
|
&usuario.Nome,
|
||||||
|
&usuario.Ctime,
|
||||||
|
&senhaHash,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
usuario.SetId(id)
|
||||||
|
|
||||||
|
if err = checkPasswordHash(senha, senhaHash); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return usuario, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindUsuarioByID(tx *sql.Tx, id int) (*Usuario, error) {
|
||||||
|
row := tx.QueryRow(
|
||||||
|
`SELECT id, nome, email, ctime FROM usuario WHERE id = $1 LIMIT 1`,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
if row.Err() != nil {
|
||||||
|
return nil, row.Err()
|
||||||
|
}
|
||||||
|
usuario := &Usuario{}
|
||||||
|
err := row.Scan(
|
||||||
|
&id,
|
||||||
|
&usuario.Nome,
|
||||||
|
&usuario.Email,
|
||||||
|
&usuario.Ctime,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
usuario.SetId(id)
|
||||||
|
return usuario, nil
|
||||||
|
}
|
||||||
73
docker-compose.yml
Normal file
73
docker-compose.yml
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
|
||||||
|
networks:
|
||||||
|
metrics:
|
||||||
|
driver: bridge
|
||||||
|
transactional:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres
|
||||||
|
healthcheck:
|
||||||
|
# Serve de condicional para que o docker compose saiba que o banco
|
||||||
|
# foi criado com sucesso antes de inicializar a aplicação.
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${PG_USER} -d ${PG_DB}"]
|
||||||
|
interval: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
timeout: 10s
|
||||||
|
volumes:
|
||||||
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${PG_USER}
|
||||||
|
POSTGRES_PASSWORD: ${PG_PASS}
|
||||||
|
POSTGRES_DB: ${PG_DB}
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
networks:
|
||||||
|
- transactional
|
||||||
|
|
||||||
|
# Interface web para monitoramento e consultas no banco.
|
||||||
|
adminer:
|
||||||
|
image: adminer
|
||||||
|
ports:
|
||||||
|
- 8090:8080
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
networks:
|
||||||
|
- transactional
|
||||||
|
|
||||||
|
# Observabilidade e coleta de dados
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus
|
||||||
|
expose:
|
||||||
|
- '9090'
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
volumes:
|
||||||
|
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
|
networks:
|
||||||
|
- metrics
|
||||||
|
# Necessário para que o serviço consiga encontrar a aplicação quando
|
||||||
|
# estivermos desenvolvendo localmente, fora de um contêiner.
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
|
# Servidor. rodar apenas quando estivermos em produção.
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
environment:
|
||||||
|
# Aplicação não se conecta com o banco sem isso.
|
||||||
|
# Apenas funciona pois o docker compose registra o nome de cada serviço como um
|
||||||
|
# endereço de rede, e esse é o endereço do banco.
|
||||||
|
PG_ADDR: db
|
||||||
|
depends_on:
|
||||||
|
db: # Não incializa até que o banco esteja recebendo requisições
|
||||||
|
condition: service_healthy
|
||||||
|
restart: true
|
||||||
|
ports:
|
||||||
|
- 8888:8888
|
||||||
|
networks:
|
||||||
|
- metrics
|
||||||
|
- transactional
|
||||||
53
go.mod
Normal file
53
go.mod
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
module foobar
|
||||||
|
|
||||||
|
go 1.23.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgx/v5 v5.7.2
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/PuerkitoBio/goquery v1.10.1 // indirect
|
||||||
|
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
|
||||||
|
github.com/a-h/templ v0.3.833 // indirect
|
||||||
|
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||||
|
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||||
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/cli/browser v1.3.0 // indirect
|
||||||
|
github.com/fatih/color v1.16.0 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
|
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
|
github.com/gorilla/sessions v1.4.0 // indirect
|
||||||
|
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
|
||||||
|
github.com/klauspost/compress v1.17.11 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/natefinch/atomic v1.0.1 // indirect
|
||||||
|
github.com/pressly/goose/v3 v3.24.1 // indirect
|
||||||
|
github.com/prometheus/client_golang v1.21.1 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
|
github.com/prometheus/common v0.62.0 // indirect
|
||||||
|
github.com/prometheus/procfs v0.15.1 // indirect
|
||||||
|
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
golang.org/x/crypto v0.31.0 // indirect
|
||||||
|
golang.org/x/mod v0.20.0 // indirect
|
||||||
|
golang.org/x/net v0.33.0 // indirect
|
||||||
|
golang.org/x/sync v0.10.0 // indirect
|
||||||
|
golang.org/x/sys v0.28.0 // indirect
|
||||||
|
golang.org/x/text v0.21.0 // indirect
|
||||||
|
golang.org/x/tools v0.24.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.1 // indirect
|
||||||
|
)
|
||||||
166
go.sum
Normal file
166
go.sum
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
|
||||||
|
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
|
||||||
|
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
|
||||||
|
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
|
||||||
|
github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
|
||||||
|
github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
|
||||||
|
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||||
|
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||||
|
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||||
|
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||||
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
|
||||||
|
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
|
||||||
|
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/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||||
|
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
|
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
|
||||||
|
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
|
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||||
|
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||||
|
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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||||
|
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||||
|
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
|
||||||
|
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
|
||||||
|
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/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4LY=
|
||||||
|
github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug=
|
||||||
|
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
|
||||||
|
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
|
||||||
|
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||||
|
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||||
|
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
||||||
|
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||||
|
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||||
|
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||||
|
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||||
|
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||||
|
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/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
|
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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
|
||||||
|
golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||||
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||||
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
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/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||||
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
|
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
|
||||||
|
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
|
||||||
|
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||||
|
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=
|
||||||
46
metrics/main.go
Normal file
46
metrics/main.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
responseLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Name: "response_latency",
|
||||||
|
Help: "latency of the response",
|
||||||
|
},
|
||||||
|
// favor adicionar itens à essa lista com cautela.
|
||||||
|
// uma alta cardinalidade em vetores de uma métrica no prometheus
|
||||||
|
// pode causar impactos na performance do servidor remoto.
|
||||||
|
[]string{"status", "method", "path", "panic"},
|
||||||
|
)
|
||||||
|
|
||||||
|
TransactionLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Name: "transaction_db_latency",
|
||||||
|
Help: "latency of the response",
|
||||||
|
}, []string{})
|
||||||
|
)
|
||||||
|
|
||||||
|
func ResponseLatencyObserve(latency time.Duration, statusCode int, panicked bool, r *http.Request) {
|
||||||
|
var (
|
||||||
|
method = r.Method
|
||||||
|
path = r.URL.Path
|
||||||
|
)
|
||||||
|
observer, err := responseLatency.GetMetricWithLabelValues(
|
||||||
|
strconv.Itoa(statusCode),
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
fmt.Sprint(panicked),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
// não temos como tratar esse erro
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
observer.Observe(latency.Seconds())
|
||||||
|
}
|
||||||
239
model/main.go
Normal file
239
model/main.go
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
// Algumas dessas classes sao absurdas mas quero ver até onde isso vai.
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/textproto"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"crypto/md5"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Filename string
|
||||||
|
|
||||||
|
type BoxURL struct {
|
||||||
|
url string
|
||||||
|
}
|
||||||
|
|
||||||
|
var boxURLCheck = regexp.MustCompile(`^[a-zA-Z0-9-]+$`)
|
||||||
|
|
||||||
|
func CheckBoxURL(url string) (BoxURL, error) {
|
||||||
|
if url == "" || !boxURLCheck.MatchString(url) {
|
||||||
|
return BoxURL{}, fmt.Errorf(`invalid url: "%s"`, url)
|
||||||
|
}
|
||||||
|
return BoxURL{url}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b BoxURL) String() string {
|
||||||
|
return b.url
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileMime string
|
||||||
|
|
||||||
|
type Markdown string
|
||||||
|
|
||||||
|
type MD5Checksum [md5.Size]byte
|
||||||
|
|
||||||
|
func (m MD5Checksum) String() string {
|
||||||
|
return fmt.Sprintf("%x", m[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
type File struct {
|
||||||
|
id uuid.UUID
|
||||||
|
name string
|
||||||
|
size int64
|
||||||
|
createdAt time.Time
|
||||||
|
mime FileMime
|
||||||
|
checksum MD5Checksum
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFile(
|
||||||
|
id uuid.UUID,
|
||||||
|
name Filename,
|
||||||
|
size int64,
|
||||||
|
createdat time.Time,
|
||||||
|
mime FileMime,
|
||||||
|
checksum MD5Checksum,
|
||||||
|
) File {
|
||||||
|
return File{
|
||||||
|
id: id,
|
||||||
|
name: string(name),
|
||||||
|
size: size,
|
||||||
|
createdAt: createdat,
|
||||||
|
mime: mime,
|
||||||
|
checksum: checksum,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) ID() uuid.UUID {
|
||||||
|
return f.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) Name() string {
|
||||||
|
return f.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) Size() int64 {
|
||||||
|
return f.size
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) CreatedAt() time.Time {
|
||||||
|
return f.createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) Mime() FileMime {
|
||||||
|
return f.mime
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) Checksum() MD5Checksum {
|
||||||
|
return f.checksum
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileUploadContext struct {
|
||||||
|
id uuid.UUID
|
||||||
|
uploadedDate time.Time
|
||||||
|
size int64
|
||||||
|
filename Filename
|
||||||
|
mime FileMime
|
||||||
|
fileHeader *multipart.FileHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileUploadContext(
|
||||||
|
id uuid.UUID,
|
||||||
|
uploadedDate time.Time,
|
||||||
|
size int64,
|
||||||
|
filename Filename,
|
||||||
|
mime FileMime,
|
||||||
|
fileHeader *multipart.FileHeader,
|
||||||
|
) FileUploadContext {
|
||||||
|
|
||||||
|
return FileUploadContext{
|
||||||
|
id: id,
|
||||||
|
uploadedDate: uploadedDate,
|
||||||
|
size: size,
|
||||||
|
filename: filename,
|
||||||
|
mime: mime,
|
||||||
|
fileHeader: fileHeader,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FileUploadContext) ID() uuid.UUID {
|
||||||
|
return f.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FileUploadContext) UploadedDate() time.Time {
|
||||||
|
return f.uploadedDate
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FileUploadContext) Size() int64 {
|
||||||
|
return f.size
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FileUploadContext) Filename() Filename {
|
||||||
|
return f.filename
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FileUploadContext) Mime() FileMime {
|
||||||
|
return f.mime
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FileUploadContext) FileHeader() *multipart.FileHeader {
|
||||||
|
return f.fileHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
type Box struct {
|
||||||
|
id uuid.UUID
|
||||||
|
url BoxURL
|
||||||
|
header Markdown
|
||||||
|
private bool
|
||||||
|
moderation bool
|
||||||
|
createdAt time.Time
|
||||||
|
lastUpdatedAt time.Time
|
||||||
|
|
||||||
|
// FIXME: should be encoded in bcrypt
|
||||||
|
editCode string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBox(
|
||||||
|
id uuid.UUID,
|
||||||
|
url BoxURL,
|
||||||
|
editCode string,
|
||||||
|
header Markdown,
|
||||||
|
private, moderation bool,
|
||||||
|
createdAt, lastUpdatedAt time.Time,
|
||||||
|
) Box {
|
||||||
|
return Box{
|
||||||
|
id: id,
|
||||||
|
url: url,
|
||||||
|
header: header,
|
||||||
|
editCode: editCode,
|
||||||
|
private: private,
|
||||||
|
moderation: moderation,
|
||||||
|
createdAt: createdAt,
|
||||||
|
lastUpdatedAt: lastUpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Box) ID() uuid.UUID {
|
||||||
|
return b.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Box) Url() BoxURL {
|
||||||
|
return b.url
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Box) Header() Markdown {
|
||||||
|
return b.header
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Box) EditCode() string {
|
||||||
|
return b.editCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Box) Private() bool {
|
||||||
|
return b.private
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Box) Moderation() bool {
|
||||||
|
return b.moderation
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Box) CreatedAt() time.Time {
|
||||||
|
return b.createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Box) LastUpdatedAt() time.Time {
|
||||||
|
return b.lastUpdatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m FileMime) IsImage() bool {
|
||||||
|
return strings.Contains(string(m), "image")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m FileMime) IsVideo() bool {
|
||||||
|
return strings.Contains(string(m), "video")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m FileMime) PrettyType() string {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(string(m), "gif"):
|
||||||
|
return "gif"
|
||||||
|
case m.IsImage():
|
||||||
|
return "image"
|
||||||
|
case m.IsVideo():
|
||||||
|
return "video"
|
||||||
|
case strings.Contains(string(m), "pdf"):
|
||||||
|
return "pdf"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func MimeFromHeader(header textproto.MIMEHeader) FileMime {
|
||||||
|
return FileMime(header.Get("Content-Type"))
|
||||||
|
}
|
||||||
21
model/main_test.go
Normal file
21
model/main_test.go
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pp(result, expected any) error {
|
||||||
|
return fmt.Errorf("result: %v, expected: %v", result, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckBoxURL(t *testing.T) {
|
||||||
|
boxURL, err := CheckBoxURL("123123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result, expected := fmt.Sprintf("%s", boxURL), "123123"; result != expected {
|
||||||
|
t.Fatal(pp(result, expected))
|
||||||
|
}
|
||||||
|
}
|
||||||
23
prometheus/prometheus.yml
Normal file
23
prometheus/prometheus.yml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
global:
|
||||||
|
scrape_interval: 15s # By default, scrape targets every 15 seconds.
|
||||||
|
|
||||||
|
# Attach these labels to any time series or alerts when communicating with
|
||||||
|
# external systems (federation, remote storage, Alertmanager).
|
||||||
|
external_labels:
|
||||||
|
monitor: 'codelab-monitor'
|
||||||
|
|
||||||
|
# A scrape configuration containing exactly one endpoint to scrape:
|
||||||
|
# Here it's Prometheus itself.
|
||||||
|
scrape_configs:
|
||||||
|
# The job name is added as a label `job=<job_name>` to any timeseries scraped from this config.
|
||||||
|
- job_name: 'prometheus'
|
||||||
|
scrape_interval: 5s
|
||||||
|
static_configs:
|
||||||
|
- targets: ['localhost:9090']
|
||||||
|
|
||||||
|
- job_name: 'node'
|
||||||
|
scrape_interval: 5s
|
||||||
|
static_configs:
|
||||||
|
- targets: ['host.docker.internal:8888']
|
||||||
|
labels:
|
||||||
|
group: 'production'
|
||||||
69
routes/auth.go
Normal file
69
routes/auth.go
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"foobar/database"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
|
"github.com/joho/godotenv" // Carrega .env
|
||||||
|
)
|
||||||
|
|
||||||
|
var store *sessions.CookieStore
|
||||||
|
|
||||||
|
const sessionIdCookie = "session_id"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
|
||||||
|
err := godotenv.Load()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := os.Getenv("SESSION_STORE_KEY")
|
||||||
|
|
||||||
|
if key == "" {
|
||||||
|
panic(fmt.Errorf("chave da sessão de cookies não foi definida."))
|
||||||
|
}
|
||||||
|
|
||||||
|
store = sessions.NewCookieStore([]byte(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentUser(r *http.Request) (*database.Usuario, error) {
|
||||||
|
session, err := store.Get(r, sessionIdCookie)
|
||||||
|
if err != nil {
|
||||||
|
logError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
id, ok := session.Values["userId"].(int)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("not ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := database.MustBeginTx()
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
usuario, err := database.FindUsuarioByID(tx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return usuario, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirectUnauthorized(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session, err := store.Get(r, sessionIdCookie)
|
||||||
|
if err != nil {
|
||||||
|
logInternalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.AddFlash("Autenticação necessária.")
|
||||||
|
err = session.Save(r, w)
|
||||||
|
if err != nil {
|
||||||
|
logInternalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/index.html", http.StatusFound)
|
||||||
|
}
|
||||||
69
routes/box.go
Normal file
69
routes/box.go
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"foobar/database"
|
||||||
|
"foobar/model"
|
||||||
|
"foobar/views"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
"github.com/gomarkdown/markdown"
|
||||||
|
"github.com/gomarkdown/markdown/html"
|
||||||
|
"github.com/gomarkdown/markdown/parser"
|
||||||
|
"github.com/microcosm-cc/bluemonday"
|
||||||
|
)
|
||||||
|
|
||||||
|
func boxUpload(w http.ResponseWriter, r *http.Request) (templ.Component, error) {
|
||||||
|
boxURL, err := GetBoxURL(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
box, err := database.SelectBox(boxURL)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return views.NewBox(boxURL, readmeTemplate), nil
|
||||||
|
} else {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return views.BoxUpload(box), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkBox(w http.ResponseWriter, r *http.Request) (templ.Component, error) {
|
||||||
|
boxURL, err := GetBoxURL(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
box, err := database.SelectBox(boxURL)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return views.Box(boxURL, []model.File{}), nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
boxFiles, err := database.SelectBoxFiles(box.ID())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return views.Box(boxURL, boxFiles), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mdToHTML(md []byte) model.Markdown {
|
||||||
|
// create markdown parser with extensions
|
||||||
|
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
|
||||||
|
p := parser.NewWithExtensions(extensions)
|
||||||
|
doc := p.Parse(md)
|
||||||
|
|
||||||
|
// create HTML renderer with extensions
|
||||||
|
htmlFlags := html.CommonFlags | html.HrefTargetBlank
|
||||||
|
opts := html.RendererOptions{Flags: htmlFlags}
|
||||||
|
renderer := html.NewRenderer(opts)
|
||||||
|
|
||||||
|
sanitizer := bluemonday.UGCPolicy()
|
||||||
|
return model.Markdown(sanitizer.SanitizeBytes(markdown.Render(doc, renderer)))
|
||||||
|
}
|
||||||
204
routes/file.go
Normal file
204
routes/file.go
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/md5"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"foobar/database"
|
||||||
|
"foobar/model"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
var maxFileSize int64 = 32 * 1000 * 1000
|
||||||
|
|
||||||
|
func fileUpload(w http.ResponseWriter, r *http.Request) (redirectURL, error) {
|
||||||
|
|
||||||
|
boxURL, err := GetBoxURL(r)
|
||||||
|
if err != nil {
|
||||||
|
return noRedirect, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := database.MustBeginTx()
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
box, err := database.SelectBox(boxURL)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) { // this is a new URL the user wants to claim for themselves
|
||||||
|
editCode := r.FormValue("edit_code")
|
||||||
|
if editCode == "" {
|
||||||
|
return noRedirect, newUserError(
|
||||||
|
"Missing edit code. Box can't be created without it",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
id = uuid.New()
|
||||||
|
createdAt = time.Now()
|
||||||
|
lastUpdatedAt = createdAt
|
||||||
|
header = readmeTemplate
|
||||||
|
)
|
||||||
|
newBox := model.NewBox(
|
||||||
|
id, boxURL, editCode, header, false, false, createdAt, lastUpdatedAt,
|
||||||
|
)
|
||||||
|
err = database.InsertBox(tx, newBox, editCode, header)
|
||||||
|
if err != nil {
|
||||||
|
return noRedirect, err
|
||||||
|
}
|
||||||
|
box = newBox
|
||||||
|
} else {
|
||||||
|
return noRedirect, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filesCtx, err := GetFilesFromUploadRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
return noRedirect, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []model.File
|
||||||
|
|
||||||
|
for _, file := range filesCtx {
|
||||||
|
|
||||||
|
bytes, err := readFileBytes(file.FileHeader())
|
||||||
|
if err != nil {
|
||||||
|
return noRedirect, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mime := model.FileMime(http.DetectContentType(bytes))
|
||||||
|
|
||||||
|
if file.Filename() == "README.md" {
|
||||||
|
|
||||||
|
editCode := r.FormValue("edit_code")
|
||||||
|
if editCode == "" {
|
||||||
|
return noRedirect, newUserError(
|
||||||
|
"Missing edit code. README can't be updated. " +
|
||||||
|
"If you're not attempting to update the header, we'd " +
|
||||||
|
"suggest changing \"README.md\" to a new filename.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if editCode != box.EditCode() {
|
||||||
|
return noRedirect, newUserError(
|
||||||
|
"Wrong edit code.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
md := mdToHTML(bytes)
|
||||||
|
|
||||||
|
err := database.UpdateHeader(tx, box.ID(), md)
|
||||||
|
if err != nil {
|
||||||
|
return noRedirect, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return noRedirect, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: we're ignoring all other uploaded files
|
||||||
|
return redirectURL(fmt.Sprintf("/box/%s", box.Url())), nil
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
err = copyBytesToDisk(file.ID(), bytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
checksum := calculateMD5(bytes)
|
||||||
|
|
||||||
|
newFile := model.NewFile(
|
||||||
|
file.ID(), file.Filename(), file.Size(),
|
||||||
|
file.UploadedDate(), mime, checksum,
|
||||||
|
)
|
||||||
|
|
||||||
|
files = append(files, newFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
err = database.InsertFile(tx, box.ID(), file)
|
||||||
|
if err != nil {
|
||||||
|
return noRedirect, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
return noRedirect, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirectURL(fmt.Sprintf("/box/%s/inside", box.Url())), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateMD5(fileBytes []byte) model.MD5Checksum {
|
||||||
|
return md5.Sum(fileBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFileBytes(fileHeader *multipart.FileHeader) ([]byte, error) {
|
||||||
|
fileReader, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer fileReader.Close()
|
||||||
|
|
||||||
|
fileBytes, err := io.ReadAll(fileReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return fileBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyBytesToDisk(fileId uuid.UUID, fileBytes []byte) error {
|
||||||
|
fileWriter, err := os.Create(filepath.Join(
|
||||||
|
staticDir, "box", fmt.Sprintf(fileId.String())),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer fileWriter.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(fileWriter, bytes.NewReader(fileBytes))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetFilesFromUploadRequest(r *http.Request) ([]model.FileUploadContext, error) {
|
||||||
|
|
||||||
|
err := r.ParseMultipartForm(int64(maxFileSize))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
headers, ok := r.MultipartForm.File["file"]
|
||||||
|
if !ok {
|
||||||
|
return nil, newUserError("no file")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
files []model.FileUploadContext
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, header := range headers {
|
||||||
|
var (
|
||||||
|
id = uuid.New()
|
||||||
|
uploadedDate = time.Now()
|
||||||
|
size = header.Size
|
||||||
|
filename = model.Filename(header.Filename)
|
||||||
|
mime = model.MimeFromHeader(header.Header)
|
||||||
|
)
|
||||||
|
|
||||||
|
fileUpload := model.NewFileUploadContext(id, uploadedDate, size, filename, mime, header)
|
||||||
|
files = append(files, fileUpload)
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
|
||||||
|
}
|
||||||
97
routes/logging.go
Normal file
97
routes/logging.go
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"foobar/metrics"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"runtime/debug"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type wrappedWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *wrappedWriter) WriteHeader(statusCode int) {
|
||||||
|
w.ResponseWriter.WriteHeader(statusCode)
|
||||||
|
w.statusCode = statusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func logging(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
wrapped := &wrappedWriter{
|
||||||
|
ResponseWriter: w,
|
||||||
|
statusCode: http.StatusOK,
|
||||||
|
}
|
||||||
|
|
||||||
|
var panicked bool
|
||||||
|
err := recoverServeHTTP(next, wrapped, r)
|
||||||
|
if err != nil {
|
||||||
|
wrapped.Write([]byte("500 error"))
|
||||||
|
log.Println(err)
|
||||||
|
panicked = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
latency = time.Since(start)
|
||||||
|
path = r.URL.Path
|
||||||
|
statusCode = wrapped.statusCode
|
||||||
|
)
|
||||||
|
if path == "/metrics" && statusCode == 200 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("%v %v %v %v", wrapped.statusCode, r.Method, r.URL.Path, latency)
|
||||||
|
|
||||||
|
metrics.ResponseLatencyObserve(latency, statusCode, panicked, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func recoverServeHTTP(next http.Handler, w http.ResponseWriter, r *http.Request) (e error) {
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
var err error
|
||||||
|
switch v := rec.(type) {
|
||||||
|
case error:
|
||||||
|
err = v
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("%v", v)
|
||||||
|
}
|
||||||
|
stack := debug.Stack()
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
e = errors.Join(err, fmt.Errorf(string(stack)))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func logError(err error) {
|
||||||
|
if err == nil {
|
||||||
|
log.Printf("LOG: Erro Nulo!?\n")
|
||||||
|
} else {
|
||||||
|
log.Printf("LOG: %s\n", err.Error())
|
||||||
|
}
|
||||||
|
log.Println(string(debug.Stack()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func logInternalError(w http.ResponseWriter, err error) {
|
||||||
|
logError(err)
|
||||||
|
http.Error(w, `500`, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpErrorf(w http.ResponseWriter, status int, formatString string, format ...interface{}) {
|
||||||
|
http.Error(w, fmt.Sprintf(formatString, format...), status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formValueMissing(field string, r *http.Request) error {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"campo '%s' em formulário não pôde ser encontrado. Content-Type: %s",
|
||||||
|
field,
|
||||||
|
r.Header.Get("Content-Type"),
|
||||||
|
)
|
||||||
|
}
|
||||||
97
routes/middleware.go
Normal file
97
routes/middleware.go
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"foobar/views"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
)
|
||||||
|
|
||||||
|
type userError struct {
|
||||||
|
error
|
||||||
|
user string
|
||||||
|
}
|
||||||
|
|
||||||
|
type redirectURL string
|
||||||
|
|
||||||
|
type postRouteRedirectFunc func(http.ResponseWriter, *http.Request) (redirectURL, error)
|
||||||
|
|
||||||
|
type routeGetFunc func(http.ResponseWriter, *http.Request) (templ.Component, error)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errUser = errors.New("user error")
|
||||||
|
)
|
||||||
|
|
||||||
|
func newUserError(msg string) *userError {
|
||||||
|
return &userError{
|
||||||
|
user: msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *userError) Error() string {
|
||||||
|
return u.user
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u redirectURL) Valid() bool {
|
||||||
|
return len(u) > 0 && u[0] == '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRouteMiddleware(fun routeGetFunc) http.HandlerFunc {
|
||||||
|
// TODO: handle boosted HTMX requests gracefully
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
component, err := fun(w, r)
|
||||||
|
if err != nil {
|
||||||
|
logInternalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = component.Render(context.Background(), w)
|
||||||
|
if err != nil {
|
||||||
|
logInternalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirectHtmxFormMiddleware(fun postRouteRedirectFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
hxRequest := r.Header.Get("HX-Request") == "true"
|
||||||
|
|
||||||
|
redirectURL, err := fun(w, r)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Add("HX-Retarget", "#error-target")
|
||||||
|
w.Header().Add("HX-Reswap", "innerHTML")
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
if u, ok := err.(*userError); ok {
|
||||||
|
if hxRequest {
|
||||||
|
err = views.ErrorBox(u.user).Render(context.Background(), w)
|
||||||
|
if err != nil {
|
||||||
|
logError(fmt.Errorf("%w: %s", err, r.URL.Path))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_, _ = w.Write([]byte(u.user))
|
||||||
|
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
msg := "Internal error... See the server logs for more information"
|
||||||
|
_ = views.ErrorBox(msg).Render(context.Background(), w)
|
||||||
|
logError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !redirectURL.Valid() {
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
if hxRequest {
|
||||||
|
w.Header().Add("HX-Redirect", string(redirectURL))
|
||||||
|
} else {
|
||||||
|
http.Redirect(w, r, string(redirectURL), http.StatusFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
178
routes/routes.go
Normal file
178
routes/routes.go
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"foobar/database"
|
||||||
|
"foobar/model"
|
||||||
|
"foobar/views"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const noRedirect redirectURL = ""
|
||||||
|
|
||||||
|
var (
|
||||||
|
mux *http.ServeMux = nil
|
||||||
|
errBoxNotFound error = fmt.Errorf("box id not found")
|
||||||
|
staticDir string = filepath.Join("views", "static")
|
||||||
|
)
|
||||||
|
|
||||||
|
var readmeTemplate model.Markdown
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
mux = http.NewServeMux()
|
||||||
|
if err := os.MkdirAll(staticDir, 777); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(os.DirFS(staticDir))))
|
||||||
|
|
||||||
|
mux.HandleFunc("/", index)
|
||||||
|
mux.HandleFunc("POST /box/{box}", redirectHtmxFormMiddleware(fileUpload))
|
||||||
|
mux.HandleFunc("GET /box/{box}", getRouteMiddleware(boxUpload))
|
||||||
|
mux.HandleFunc("GET /box/{box}/inside", getRouteMiddleware(checkBox))
|
||||||
|
|
||||||
|
mux.HandleFunc("/panic", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
panic("não implementado")
|
||||||
|
})
|
||||||
|
mux.Handle("/metrics", promhttp.Handler())
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := template.Must(template.ParseFiles(filepath.Join(staticDir, "default", "README.md"))).
|
||||||
|
Execute(&buf, nil)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
readmeTemplate = mdToHTML(buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func Mux() http.Handler {
|
||||||
|
return logging(mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
func notFound(w http.ResponseWriter) {
|
||||||
|
w.WriteHeader(404)
|
||||||
|
if err := views.FourOfour().Render(context.Background(), w); err != nil {
|
||||||
|
logInternalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func index(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/index.html" && r.URL.Path != "/" || r.Method != "GET" {
|
||||||
|
notFound(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := views.Index().Render(context.Background(), w); err != nil {
|
||||||
|
logInternalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetBoxURL(r *http.Request) (model.BoxURL, error) {
|
||||||
|
return model.CheckBoxURL(r.PathValue("box"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// func sobre(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// if err := views.Sobre().Render(context.Background(), w); err != nil {
|
||||||
|
// logInternalError(w, err)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// func logIn(w http.ResponseWriter, r *http.Request) {
|
||||||
|
//
|
||||||
|
// _, err := currentUser(r)
|
||||||
|
//
|
||||||
|
// userLogged := err == nil
|
||||||
|
//
|
||||||
|
// if userLogged {
|
||||||
|
// http.Redirect(w, r, "/logged", http.StatusFound)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if err := views.Login().Render(context.Background(), w); err != nil {
|
||||||
|
// logInternalError(w, err)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
func logInPOST(w http.ResponseWriter, r *http.Request) (redirectURL, error) {
|
||||||
|
|
||||||
|
email := r.FormValue("email")
|
||||||
|
if email == "" {
|
||||||
|
return "", formValueMissing("email", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
senha := r.FormValue("senha")
|
||||||
|
if senha == "" {
|
||||||
|
return "", formValueMissing("senha", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := database.MustBeginTx()
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
usuario, err := database.VerifyUser(tx, email, senha)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||||
|
return "", newUserError("senha ou email não conferem")
|
||||||
|
} else {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := store.Get(r, sessionIdCookie)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
userId, err := usuario.Id()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Values["userId"] = userId
|
||||||
|
session.Options.SameSite = http.SameSiteDefaultMode
|
||||||
|
|
||||||
|
if err := session.Save(r, w); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/logged", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func logged(w http.ResponseWriter, r *http.Request) {
|
||||||
|
usuario, err := currentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
logError(err)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
w.Write([]byte("Não Autorizado."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = views.Logged(usuario).Render(context.Background(), w); err != nil {
|
||||||
|
logInternalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session, err := store.Get(r, sessionIdCookie)
|
||||||
|
if err != nil {
|
||||||
|
logInternalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.Options.MaxAge = -1
|
||||||
|
if err = session.Save(r, w); err != nil {
|
||||||
|
logInternalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
}
|
||||||
79
server/server.go
Normal file
79
server/server.go
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"foobar/database"
|
||||||
|
"foobar/routes"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv" // Carrega .env
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:generate templ generate
|
||||||
|
|
||||||
|
var (
|
||||||
|
Port = "8888"
|
||||||
|
Addr = "localhost"
|
||||||
|
s *http.Server = nil
|
||||||
|
)
|
||||||
|
|
||||||
|
func MustStartServer() {
|
||||||
|
if s != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ready := make(chan bool, 1)
|
||||||
|
go mustStartServer(ready)
|
||||||
|
<-ready
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustStartServer(ready chan<- bool) {
|
||||||
|
|
||||||
|
if s != nil {
|
||||||
|
ready <- true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := godotenv.Load()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if port := os.Getenv("SERVER_PORT"); port != "" {
|
||||||
|
Port = port
|
||||||
|
}
|
||||||
|
if addr := os.Getenv("SERVER_ADDR"); addr != "" {
|
||||||
|
Addr = addr
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = database.New()
|
||||||
|
routes := routes.Mux()
|
||||||
|
|
||||||
|
s = &http.Server{
|
||||||
|
Addr: fmt.Sprintf("%s:%s", Addr, Port),
|
||||||
|
Handler: routes,
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Second,
|
||||||
|
MaxHeaderBytes: 1 << 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abre uma conexão de socket mas não bloqueia o fluxo do programa.
|
||||||
|
// Isso significa que o servidor já está recebendo requests.
|
||||||
|
// Isso é necessário para rodar os testes.
|
||||||
|
ln, err := net.Listen("tcp", s.Addr)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
ready <- true
|
||||||
|
fmt.Printf("Estamos ao vivo! teste em http://%s:%s\n", Addr, Port)
|
||||||
|
|
||||||
|
log.Fatal(s.Serve(ln))
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
mustStartServer(make(chan bool, 1))
|
||||||
|
}
|
||||||
11
tailwind.config.js
Normal file
11
tailwind.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
// "./internal/**/*.{go,js,templ,html}",
|
||||||
|
"./views/html/*.html"
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
1228
views/static/css/github-markdown.css
Normal file
1228
views/static/css/github-markdown.css
Normal file
File diff suppressed because it is too large
Load diff
78
views/static/css/input.css
Normal file
78
views/static/css/input.css
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* não deve ser o jeito certo de fazer isso. */
|
||||||
|
|
||||||
|
.primary_button {
|
||||||
|
@apply rounded p-2 bg-indigo-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-input {
|
||||||
|
@apply w-full bg-transparent placeholder:text-slate-400 text-slate-700 text-sm border border-slate-200 rounded-md px-3 py-2 transition duration-300 focus:outline-none focus:border-slate-400 hover:border-slate-300 shadow-sm focus:shadow;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-label {
|
||||||
|
@apply block mb-1 text-sm text-slate-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.however {
|
||||||
|
@apply flex items-center mt-2 text-xs text-slate-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-my-input {
|
||||||
|
@apply w-full min-w-[200px] mb-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
@apply w-full rounded-md bg-slate-800 py-2 px-4 my-4 border border-transparent text-center text-sm text-white transition-all shadow-md hover:shadow-lg focus:bg-slate-700 focus:shadow-none active:bg-slate-700 hover:bg-slate-700 active:shadow-none disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
@apply text-5xl font-bold mb-5
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
@apply text-4xl font-bold mb-5
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-7px);
|
||||||
|
timing-function: ease-in;
|
||||||
|
}
|
||||||
|
37% {
|
||||||
|
transform: translateX(7px);
|
||||||
|
timing-function: ease-out;
|
||||||
|
}
|
||||||
|
55% {
|
||||||
|
transform: translateX(-7px);
|
||||||
|
timing-function: ease-in;
|
||||||
|
}
|
||||||
|
73% {
|
||||||
|
transform: translateX(5px);
|
||||||
|
timing-function: ease-out;
|
||||||
|
}
|
||||||
|
82% {
|
||||||
|
transform: translateX(-5px);
|
||||||
|
timing-function: ease-in;
|
||||||
|
}
|
||||||
|
91% {
|
||||||
|
transform: translateX(2px);
|
||||||
|
timing-function: ease-out;
|
||||||
|
}
|
||||||
|
96% {
|
||||||
|
transform: translateX(-2px);
|
||||||
|
timing-function: ease-in;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(0px);
|
||||||
|
timing-function: ease-in;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bounce {
|
||||||
|
animation-name: bounce;
|
||||||
|
animation-duration: .5s;
|
||||||
|
}
|
||||||
74
views/static/default/README.md
Normal file
74
views/static/default/README.md
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
|
||||||
|
# **This box URL belongs to no one.**
|
||||||
|
|
||||||
|
**Drop a file** with the `Edit code` field of your choice **to claim this URL as yours**.
|
||||||
|
|
||||||
|
So as long as you **keep your edit code**, you will be able to make permissive moditifications to this box,
|
||||||
|
including visualizing files, moderation and exclusion of files.
|
||||||
|
|
||||||
|
**To make modifications to this readme section, drop a file called `README.md`** that will override the
|
||||||
|
currently set header. This is how you're supposed to make edits to this section.
|
||||||
|
|
||||||
|
New boxes have been set to have a **maximum lifetime of two years,** after the lifetime period is over,
|
||||||
|
the box files are going to be deleted and the URL is going to be reclaimed. You'll no longer own this URL.
|
||||||
|
|
||||||
|
Once you've claimed this box, you'll be able to **drop a `config.json5`** with valid [`json5`](https://json5.org/) options
|
||||||
|
**to replace the default box configuration.** [The default configuration file can be found
|
||||||
|
here](/box-configurations#) along with all customizeble options.
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
### Can I set a maximum upload size?
|
||||||
|
|
||||||
|
Works is in progress. You won't be able to set it above the maximum size set be this instance administrator
|
||||||
|
|
||||||
|
### Is there a views counter?
|
||||||
|
|
||||||
|
Works is in progress. There will be one.
|
||||||
|
|
||||||
|
### Can I block others from viewing the box contents?
|
||||||
|
|
||||||
|
Works is in progress. You will be able to.
|
||||||
|
|
||||||
|
### What filetypes are allowed? Can I black/white list filetypes?
|
||||||
|
|
||||||
|
Per the default configuration, all filetypes are allowed. but works are in progress. You will be able to
|
||||||
|
white or blacklist filetypes.
|
||||||
|
|
||||||
|
### How can I see the IP addresses of the file uploaders
|
||||||
|
|
||||||
|
You cannot and there is no configuration you or the instance administrator can set that will allow you
|
||||||
|
to see address of the uploaders because the servers don't collect them in the first place.
|
||||||
|
Let it be known that the instance administrator can still set the server to run behind a reverse proxy
|
||||||
|
which would allow him or her to collect data from the client accesses, but even still,
|
||||||
|
this is not something the instance has access to and therefore you won't either.
|
||||||
|
|
||||||
|
### Is the instance adminstrator able to view the contents of this box
|
||||||
|
|
||||||
|
Yes they are. Whether you trust the instance adminsitrator with these files is up to you.
|
||||||
|
|
||||||
|
### What can't be uploaded to these boxes?
|
||||||
|
|
||||||
|
This is up to the instance administrator to define. See [Uploading guidelines](/uploading-guidelines#).
|
||||||
|
|
||||||
|
### What happens if the instance administrator changes the max lifetime of a box or the maximum upload size?
|
||||||
|
|
||||||
|
If the max lifetime is changed your newly created box won't be affected and your lifetime will still be the same from when the
|
||||||
|
box was created. If the instance administrator changes te maximum upload size, your configurations will be capped to
|
||||||
|
conform to those changes. Make sure your configurations are up to date by downloading the `config.json5` from this box at
|
||||||
|
every periodic change.
|
||||||
|
|
||||||
|
### Making changes to these files is hard on a mobile UwU, why can't I edit the configurations with a form?
|
||||||
|
|
||||||
|
Not our problem. Get a computer.
|
||||||
|
|
||||||
|
### Why json5?
|
||||||
|
|
||||||
|
YAML sucks. TOML sucks. JSON sucks. JSON5 sucks less. [Here's what it looks like](https://github.com/chromium/chromium/blob/feb3c9f670515edf9a88f185301cbd7794ee3e52/third_party/blink/renderer/platform/runtime_enabled_features.json5).
|
||||||
|
Lua is an option and it actually rocks but that's a bit overkill for this project.
|
||||||
|
|
||||||
|
### What happens if I lose my edit code?
|
||||||
|
|
||||||
|
It's totally up to you to not do that.
|
||||||
|
|
||||||
|
<script>alert("if this is executed it means we screwed up")</script>
|
||||||
256
views/template.templ
Normal file
256
views/template.templ
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
package views
|
||||||
|
|
||||||
|
import (
|
||||||
|
"foobar/database"
|
||||||
|
"foobar/model"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ indexLayout() {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<title>Mude meu título!</title>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<meta name="htmx-config" content='{ "responseHandling":[{"code":"422", "swap": true}, {"code":"200", "swap": true}] }' />
|
||||||
|
<!-- tailwind -->
|
||||||
|
<link href="/static/css/output.css" rel="stylesheet"/>
|
||||||
|
<!-- markdown styling -->
|
||||||
|
<link href="/static/css/github-markdown.css" rel="stylesheet"/>
|
||||||
|
<!-- daisyUI -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
||||||
|
<!-- daisyUI Themes -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
|
||||||
|
// <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||||
|
<!-- HTMX -->
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
|
<!-- Hyperscript -->
|
||||||
|
<script src="https://unpkg.com/hyperscript.org@0.9.14"></script>
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="flex-wrap flex content-center justify-between flex-col bg-base-100 text-sm"
|
||||||
|
hx-boost="true" hx-push-url="true" hx-target="this">
|
||||||
|
{ children... }
|
||||||
|
<footer></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ mainCenterBody() {
|
||||||
|
@indexLayout() {
|
||||||
|
<main class="w-full" >
|
||||||
|
{ children... }
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleButtonInMeUntilLoad =
|
||||||
|
`on every htmx:beforeSend
|
||||||
|
tell <button/> in me
|
||||||
|
toggle [@disabled='true'] until htmx:afterOnLoad
|
||||||
|
end
|
||||||
|
end`
|
||||||
|
|
||||||
|
func safeURL(s string) string {
|
||||||
|
return fmt.Sprint(templ.SafeURL(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Index() {
|
||||||
|
@mainCenterBody() {
|
||||||
|
<div class="hero-content">
|
||||||
|
<div class="max-w-md text-center">
|
||||||
|
<h1>hakobox</h1>
|
||||||
|
<p>nothing here...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
templ boxUploadSidebar(boxURL model.BoxURL) {
|
||||||
|
@mainCenterBody() {
|
||||||
|
<div class="">
|
||||||
|
<section class="static md:fixed top-0 left-0 z-10 w-full md:w-3/9 p-10 bg-base-200 h-full ">
|
||||||
|
<header>
|
||||||
|
<h1>hakobox</h1>
|
||||||
|
<p>It's temporary file sharing but everyone gets to contribute! </p>
|
||||||
|
<p class="leading-tight font-medium text-black">Drop in a file to be added to this box </p>
|
||||||
|
</header>
|
||||||
|
<form id="add-file"
|
||||||
|
hx-trigger="submit"
|
||||||
|
hx-post={safeURL(fmt.Sprintf("/box/%s", boxURL))}
|
||||||
|
hx-encoding="multipart/form-data">
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<legend class="fieldset-legend">Pick a file</legend>
|
||||||
|
<input name="file" type="file" class="file-input" style="width: 100%;" multiple required/>
|
||||||
|
<label class="fieldset-label">Max size 2MB</label>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<legend class="fieldset-legend">Edit code</legend>
|
||||||
|
<input type="text" class="input" name="edit_code" placeholder="Type here" style="width: 100%;"/>
|
||||||
|
<p class="label">Optional</p>
|
||||||
|
</fieldset>
|
||||||
|
@errorTarget()
|
||||||
|
<button type="submit" class="btn btn-primary mb-5">Add to the box!</button>
|
||||||
|
<div class="text-center">
|
||||||
|
<a class="link link-primary block mb-0"
|
||||||
|
href={templ.SafeURL(fmt.Sprintf("/box/%s/inside", boxURL))}>
|
||||||
|
See what's inside
|
||||||
|
</a>
|
||||||
|
<a class="link link-primary block mb-0"
|
||||||
|
href={templ.SafeURL(fmt.Sprintf("/box/%s", boxURL))}>
|
||||||
|
Front page
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="md:px-20 p-4 md:my-40 m-0 md:ml-auto md:max-w-6/9">
|
||||||
|
{ children... }
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ BoxUpload(box model.Box) {
|
||||||
|
@boxUploadSidebar(box.Url()) {
|
||||||
|
<div class="markdown-body">
|
||||||
|
@templ.Raw(box.Header())
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ NewBox(boxURL model.BoxURL, templateReadme model.Markdown) {
|
||||||
|
@boxUploadSidebar(boxURL) {
|
||||||
|
<div class="markdown-body">
|
||||||
|
@templ.Raw(templateReadme)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
templ Box(boxURL model.BoxURL, files []model.File) {
|
||||||
|
@boxUploadSidebar(boxURL) {
|
||||||
|
<main class="inline-flex flex-wrap gap-0 md:gap-5 mt-20 w-full h-full justify-start md:justify-center">
|
||||||
|
if len(files) == 0 {
|
||||||
|
<h2 class="m-auto">Empty box.</h2>
|
||||||
|
} else {
|
||||||
|
for i := range files {
|
||||||
|
@fileCard(files[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileDisplay(file model.File) templ.Component {
|
||||||
|
switch {
|
||||||
|
case file.Mime().IsImage():
|
||||||
|
return fileImageFigure(file)
|
||||||
|
case file.Mime().IsVideo():
|
||||||
|
return fileVideoFigure(file)
|
||||||
|
default:
|
||||||
|
return unknownFileFigure()
|
||||||
|
// panic(fmt.Errorf("unrecognized mime: %s", file.Mime))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ fileCard(file model.File) {
|
||||||
|
<a class="card w-1/2 md:w-2xs aspect-square mb-10" href={templ.SafeURL(fmt.Sprintf("/static/box/%s", file.ID()))} target="_blank">
|
||||||
|
<figure class="flex flex-col p-1 m-auto mb-auto md:mb-0 text-center">
|
||||||
|
@fileDisplay(file)
|
||||||
|
</figure>
|
||||||
|
<div class="hidden md:inline font-light text-sm" style="text-wrap: nowrap;">
|
||||||
|
<p title={file.Name()}>{shorten(file.Name())}</p>
|
||||||
|
<p title={file.Name()}>{file.CreatedAt().Format(time.DateOnly)}</p>
|
||||||
|
<p>{formatSize(file.Size())} {file.Mime().PrettyType()}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ fileImageFigure(file model.File) {
|
||||||
|
<img
|
||||||
|
title={file.Name()}
|
||||||
|
loading="lazy"
|
||||||
|
src={fmt.Sprintf("/static/box/%s", file.ID())}
|
||||||
|
alt={file.Name()} />
|
||||||
|
}
|
||||||
|
|
||||||
|
templ fileVideoFigure(file model.File) {
|
||||||
|
<video title={file.Name()} loading="lazy" alt={file.Name()} >
|
||||||
|
<source src={fmt.Sprintf("/static/box/%s", file.ID())}/>
|
||||||
|
</video>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
templ unknownFileFigure() {
|
||||||
|
<div>unknown format!</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func shorten(text string) string {
|
||||||
|
if len(text) > 41 {
|
||||||
|
return fmt.Sprintf("%s...", text[:41-3])
|
||||||
|
} else {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSize(size int64) string {
|
||||||
|
units := []string{"b", "Kb", "Mb", "Gb"}
|
||||||
|
i := 0
|
||||||
|
fSize := float64(size)
|
||||||
|
for fSize >= 1000 && i < len(units)-1 {
|
||||||
|
fSize /= 1000
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.2f %s", fSize, units[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
templ FourOfour() {
|
||||||
|
@indexLayout() {
|
||||||
|
<div>404</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Logged(usuario *database.Usuario) {
|
||||||
|
@indexLayout() {
|
||||||
|
<p>Logado como: { usuario.Nome }</p>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<p>
|
||||||
|
<a href="/">index</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="/logout">logout</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ errorTarget() {
|
||||||
|
<div id="error-target">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ ErrorBox(msg string) {
|
||||||
|
<div role="alert" class="bounce mt-3 relative flex flex-col w-full p-3 text-sm text-white bg-red-600 rounded-md">
|
||||||
|
<p class="flex text-base">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="h-5 w-5 mr-2 mt-0.5"><path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"></path></svg>
|
||||||
|
Error
|
||||||
|
</p>
|
||||||
|
<p class="ml-4 p-3">
|
||||||
|
{ msg }
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button class="flex items-center justify-center transition-all w-8 h-8 rounded-md text-white hover:bg-white/10 active:bg-white/10 absolute top-1.5 right-1.5" type="button"
|
||||||
|
_='on click tell closest <div[role="alert"]/> transition opacity to 0 then remove yourself end'
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" class="h-5 w-5" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue