initial commit

This commit is contained in:
silva guimaraes 2025-04-21 17:59:57 -03:00
commit c21f569144
37 changed files with 3956 additions and 0 deletions

75
database/box.go Normal file
View 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
View 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
View 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
View 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
}

View 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 --

View 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 --

View 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 --

View 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 --

View file

@ -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 --

View 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
View 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
}

View 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
View 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
}