DDD (Domain Design) partially implemented, added comments everywhere.
This commit is contained in:
parent
3456ecd312
commit
242fb3d361
@ -5,6 +5,7 @@ import (
|
|||||||
"github.com/labstack/echo"
|
"github.com/labstack/echo"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Logs Echo requests.
|
||||||
func echoReqLog(ec echo.Context, next echo.HandlerFunc) error {
|
func echoReqLog(ec echo.Context, next echo.HandlerFunc) error {
|
||||||
c.Logger.Info().
|
c.Logger.Info().
|
||||||
Str("IP", ec.RealIP()).
|
Str("IP", ec.RealIP()).
|
||||||
@ -18,6 +19,7 @@ func echoReqLog(ec echo.Context, next echo.HandlerFunc) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wrapper around previous function.
|
||||||
func echoReqLogger() echo.MiddlewareFunc {
|
func echoReqLogger() echo.MiddlewareFunc {
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
|
@ -16,10 +16,12 @@ var (
|
|||||||
e *echo.Echo
|
e *echo.Echo
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// New initializes variables for api package.
|
||||||
func New(cc *context.Context) {
|
func New(cc *context.Context) {
|
||||||
c = cc
|
c = cc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitializeAPI initializes HTTP API and starts web server.
|
||||||
func InitializeAPI() {
|
func InitializeAPI() {
|
||||||
c.Logger.Info().Msg("Initializing HTTP server...")
|
c.Logger.Info().Msg("Initializing HTTP server...")
|
||||||
|
|
||||||
|
@ -13,6 +13,8 @@ var (
|
|||||||
c *context.Context
|
c *context.Context
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// New initializes basic HTTP API, which shows only index page and serves
|
||||||
|
// static files.
|
||||||
func New(cc *context.Context) {
|
func New(cc *context.Context) {
|
||||||
c = cc
|
c = cc
|
||||||
c.Logger.Info().Msg("Initializing HTTP API...")
|
c.Logger.Info().Msg("Initializing HTTP API...")
|
||||||
@ -22,14 +24,4 @@ func New(cc *context.Context) {
|
|||||||
|
|
||||||
// Index.
|
// Index.
|
||||||
c.Echo.GET("/", indexGet)
|
c.Echo.GET("/", indexGet)
|
||||||
|
|
||||||
// New paste.
|
|
||||||
c.Echo.POST("/paste/", pastePOST)
|
|
||||||
|
|
||||||
// Show paste.
|
|
||||||
c.Echo.GET("/paste/:id", pasteGET)
|
|
||||||
|
|
||||||
// Pastes list.
|
|
||||||
c.Echo.GET("/pastes/", pastesGET)
|
|
||||||
c.Echo.GET("/pastes/:page", pastesGET)
|
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/labstack/echo"
|
"github.com/labstack/echo"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Index of this site.
|
||||||
func indexGet(ec echo.Context) error {
|
func indexGet(ec echo.Context) error {
|
||||||
html, err := static.ReadFile("index.html")
|
html, err := static.ReadFile("index.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -9,6 +9,7 @@ var (
|
|||||||
c *context.Context
|
c *context.Context
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// New initializes basic JSON API.
|
||||||
func New(cc *context.Context) {
|
func New(cc *context.Context) {
|
||||||
c = cc
|
c = cc
|
||||||
c.Logger.Info().Msg("Initializing JSON API...")
|
c.Logger.Info().Msg("Initializing JSON API...")
|
||||||
|
@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/pztrn/fastpastebin/context"
|
"github.com/pztrn/fastpastebin/context"
|
||||||
"github.com/pztrn/fastpastebin/database"
|
"github.com/pztrn/fastpastebin/database"
|
||||||
"github.com/pztrn/fastpastebin/database/migrations"
|
"github.com/pztrn/fastpastebin/database/migrations"
|
||||||
|
"github.com/pztrn/fastpastebin/pastes"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -34,6 +35,8 @@ func main() {
|
|||||||
api.New(c)
|
api.New(c)
|
||||||
api.InitializeAPI()
|
api.InitializeAPI()
|
||||||
|
|
||||||
|
pastes.New(c)
|
||||||
|
|
||||||
// CTRL+C handler.
|
// CTRL+C handler.
|
||||||
signalHandler := make(chan os.Signal, 1)
|
signalHandler := make(chan os.Signal, 1)
|
||||||
shutdownDone := make(chan bool, 1)
|
shutdownDone := make(chan bool, 1)
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
|
// ConfigDatabase describes database configuration.
|
||||||
type ConfigDatabase struct {
|
type ConfigDatabase struct {
|
||||||
Address string `yaml:"address"`
|
Address string `yaml:"address"`
|
||||||
Port string `yaml:"port"`
|
Port string `yaml:"port"`
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
|
// ConfigHTTP describes HTTP server configuration.
|
||||||
type ConfigHTTP struct {
|
type ConfigHTTP struct {
|
||||||
Address string `yaml:"address"`
|
Address string `yaml:"address"`
|
||||||
Port string `yaml:"port"`
|
Port string `yaml:"port"`
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
|
// ConfigLogging describes logger configuration.
|
||||||
type ConfigLogging struct {
|
type ConfigLogging struct {
|
||||||
LogToFile bool `yaml:"log_to_file"`
|
LogToFile bool `yaml:"log_to_file"`
|
||||||
FileName string `yaml:"filename"`
|
FileName string `yaml:"filename"`
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
|
// ConfigStruct describes whole configuration.
|
||||||
type ConfigStruct struct {
|
type ConfigStruct struct {
|
||||||
Database ConfigDatabase `yaml:"database"`
|
Database ConfigDatabase `yaml:"database"`
|
||||||
Logging ConfigLogging `yaml:"logging"`
|
Logging ConfigLogging `yaml:"logging"`
|
||||||
|
@ -17,6 +17,10 @@ import (
|
|||||||
"gopkg.in/yaml.v2"
|
"gopkg.in/yaml.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Context is a some sort of singleton. Basically it's a structure that
|
||||||
|
// initialized once and then passed to all parts of application. It
|
||||||
|
// contains everything every part of application need, like configuration
|
||||||
|
// access, logger, etc.
|
||||||
type Context struct {
|
type Context struct {
|
||||||
Config *config.ConfigStruct
|
Config *config.ConfigStruct
|
||||||
Database databaseinterface.Interface
|
Database databaseinterface.Interface
|
||||||
@ -25,6 +29,7 @@ type Context struct {
|
|||||||
Logger zerolog.Logger
|
Logger zerolog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize initializes context.
|
||||||
func (c *Context) Initialize() {
|
func (c *Context) Initialize() {
|
||||||
c.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout}).With().Timestamp().Caller().Logger()
|
c.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout}).With().Timestamp().Caller().Logger()
|
||||||
|
|
||||||
@ -39,11 +44,17 @@ func (c *Context) Initialize() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadConfiguration loads configuration and executes right after Flagger
|
||||||
|
// have parsed CLI flags, because it depends on "-config" defined in
|
||||||
|
// Initialize().
|
||||||
func (c *Context) LoadConfiguration() {
|
func (c *Context) LoadConfiguration() {
|
||||||
c.Logger.Info().Msg("Loading configuration...")
|
c.Logger.Info().Msg("Loading configuration...")
|
||||||
|
|
||||||
var configPath = ""
|
var configPath = ""
|
||||||
|
|
||||||
|
// We're accepting configuration path from "-config" CLI parameter
|
||||||
|
// and FASTPASTEBIN_CONFIG environment variable. Later have higher
|
||||||
|
// weight and can override "-config" value.
|
||||||
configPathFromCLI, err := c.Flagger.GetStringValue("config")
|
configPathFromCLI, err := c.Flagger.GetStringValue("config")
|
||||||
configPathFromEnv, configPathFromEnvFound := os.LookupEnv("FASTPASTEBIN_CONFIG")
|
configPathFromEnv, configPathFromEnvFound := os.LookupEnv("FASTPASTEBIN_CONFIG")
|
||||||
|
|
||||||
@ -65,27 +76,33 @@ func (c *Context) LoadConfiguration() {
|
|||||||
|
|
||||||
c.Config = &config.ConfigStruct{}
|
c.Config = &config.ConfigStruct{}
|
||||||
|
|
||||||
|
// Read configuration file.
|
||||||
fileData, err2 := ioutil.ReadFile(normalizedConfigPath)
|
fileData, err2 := ioutil.ReadFile(normalizedConfigPath)
|
||||||
if err2 != nil {
|
if err2 != nil {
|
||||||
c.Logger.Panic().Msgf("Failed to read configuration file: %s", err2.Error())
|
c.Logger.Panic().Msgf("Failed to read configuration file: %s", err2.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse it into structure.
|
||||||
err3 := yaml.Unmarshal(fileData, c.Config)
|
err3 := yaml.Unmarshal(fileData, c.Config)
|
||||||
if err3 != nil {
|
if err3 != nil {
|
||||||
c.Logger.Panic().Msgf("Failed to parse configuration file: %s", err3.Error())
|
c.Logger.Panic().Msgf("Failed to parse configuration file: %s", err3.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Yay! See what it gets!
|
||||||
c.Logger.Debug().Msgf("Parsed configuration: %+v", c.Config)
|
c.Logger.Debug().Msgf("Parsed configuration: %+v", c.Config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterDatabaseInterface registers database interface for later use.
|
||||||
func (c *Context) RegisterDatabaseInterface(di databaseinterface.Interface) {
|
func (c *Context) RegisterDatabaseInterface(di databaseinterface.Interface) {
|
||||||
c.Database = di
|
c.Database = di
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterEcho registers Echo instance for later usage.
|
||||||
func (c *Context) RegisterEcho(e *echo.Echo) {
|
func (c *Context) RegisterEcho(e *echo.Echo) {
|
||||||
c.Echo = e
|
c.Echo = e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shutdown shutdowns entire application.
|
||||||
func (c *Context) Shutdown() {
|
func (c *Context) Shutdown() {
|
||||||
c.Logger.Info().Msg("Shutting down Fast Pastebin...")
|
c.Logger.Info().Msg("Shutting down Fast Pastebin...")
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package context
|
package context
|
||||||
|
|
||||||
|
// New creates new context.
|
||||||
func New() *Context {
|
func New() *Context {
|
||||||
return &Context{}
|
return &Context{}
|
||||||
}
|
}
|
||||||
|
@ -9,17 +9,22 @@ import (
|
|||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Database represents control structure for database connection.
|
||||||
type Database struct {
|
type Database struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDatabaseConnection returns current database connection.
|
||||||
func (db *Database) GetDatabaseConnection() *sqlx.DB {
|
func (db *Database) GetDatabaseConnection() *sqlx.DB {
|
||||||
return db.db
|
return db.db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize initializes connection to database.
|
||||||
func (db *Database) Initialize() {
|
func (db *Database) Initialize() {
|
||||||
c.Logger.Info().Msg("Initializing database connection...")
|
c.Logger.Info().Msg("Initializing database connection...")
|
||||||
|
|
||||||
|
// There might be only user, without password. MySQL/MariaDB driver
|
||||||
|
// in DSN wants "user" or "user:password", "user:" is invalid.
|
||||||
var userpass = ""
|
var userpass = ""
|
||||||
if c.Config.Database.Password == "" {
|
if c.Config.Database.Password == "" {
|
||||||
userpass = c.Config.Database.Username
|
userpass = c.Config.Database.Username
|
||||||
|
@ -11,6 +11,7 @@ var (
|
|||||||
d *Database
|
d *Database
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// New initializes database structure.
|
||||||
func New(cc *context.Context) {
|
func New(cc *context.Context) {
|
||||||
c = cc
|
c = cc
|
||||||
d = &Database{}
|
d = &Database{}
|
||||||
|
@ -5,12 +5,16 @@ import (
|
|||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Handler is an interfaceable structure that proxifies calls from anyone
|
||||||
|
// to Database structure.
|
||||||
type Handler struct{}
|
type Handler struct{}
|
||||||
|
|
||||||
|
// GetDatabaseConnection returns current database connection.
|
||||||
func (dbh Handler) GetDatabaseConnection() *sqlx.DB {
|
func (dbh Handler) GetDatabaseConnection() *sqlx.DB {
|
||||||
return d.GetDatabaseConnection()
|
return d.GetDatabaseConnection()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize initializes connection to database.
|
||||||
func (dbh Handler) Initialize() {
|
func (dbh Handler) Initialize() {
|
||||||
d.Initialize()
|
d.Initialize()
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,8 @@ import (
|
|||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Interface represents database interface which is available to all
|
||||||
|
// parts of application and registers with context.Context.
|
||||||
type Interface interface {
|
type Interface interface {
|
||||||
GetDatabaseConnection() *sqlx.DB
|
GetDatabaseConnection() *sqlx.DB
|
||||||
Initialize()
|
Initialize()
|
||||||
|
@ -13,15 +13,18 @@ var (
|
|||||||
c *context.Context
|
c *context.Context
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// New initializes migrations.
|
||||||
func New(cc *context.Context) {
|
func New(cc *context.Context) {
|
||||||
c = cc
|
c = cc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migrate launching migrations.
|
||||||
func Migrate() {
|
func Migrate() {
|
||||||
c.Logger.Info().Msg("Migrating database...")
|
c.Logger.Info().Msg("Migrating database...")
|
||||||
|
|
||||||
goose.SetDialect("mysql")
|
goose.SetDialect("mysql")
|
||||||
goose.AddNamedMigration("1_initial.go", InitialUp, nil)
|
goose.AddNamedMigration("1_initial.go", InitialUp, nil)
|
||||||
|
// Add new migrations BEFORE this message.
|
||||||
|
|
||||||
dbConn := c.Database.GetDatabaseConnection()
|
dbConn := c.Database.GetDatabaseConnection()
|
||||||
err := goose.Up(dbConn.DB, ".")
|
err := goose.Up(dbConn.DB, ".")
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
package http
|
package pastes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
// stdlib
|
// stdlib
|
||||||
@ -10,7 +10,6 @@ import (
|
|||||||
|
|
||||||
// local
|
// local
|
||||||
"github.com/pztrn/fastpastebin/api/http/static"
|
"github.com/pztrn/fastpastebin/api/http/static"
|
||||||
"github.com/pztrn/fastpastebin/models"
|
|
||||||
|
|
||||||
// other
|
// other
|
||||||
"github.com/labstack/echo"
|
"github.com/labstack/echo"
|
||||||
@ -20,6 +19,7 @@ var (
|
|||||||
regexInts = regexp.MustCompile("[0-9]+")
|
regexInts = regexp.MustCompile("[0-9]+")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GET for "/paste/PASTE_ID".
|
||||||
func pasteGET(ec echo.Context) error {
|
func pasteGET(ec echo.Context) error {
|
||||||
errhtml, err := static.ReadFile("error.html")
|
errhtml, err := static.ReadFile("error.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -33,7 +33,7 @@ func pasteGET(ec echo.Context) error {
|
|||||||
c.Logger.Debug().Msgf("Requesting paste #%+v", pasteID)
|
c.Logger.Debug().Msgf("Requesting paste #%+v", pasteID)
|
||||||
|
|
||||||
// Get paste.
|
// Get paste.
|
||||||
paste := &models.Paste{ID: pasteID}
|
paste := &Paste{ID: pasteID}
|
||||||
err1 := paste.GetByID(c.Database.GetDatabaseConnection())
|
err1 := paste.GetByID(c.Database.GetDatabaseConnection())
|
||||||
if err1 != nil {
|
if err1 != nil {
|
||||||
c.Logger.Error().Msgf("Failed to get paste #%d from database: %s", pasteID, err1.Error())
|
c.Logger.Error().Msgf("Failed to get paste #%d from database: %s", pasteID, err1.Error())
|
||||||
@ -50,6 +50,8 @@ func pasteGET(ec echo.Context) error {
|
|||||||
return ec.HTML(http.StatusOK, string(pasteHTMLAsString))
|
return ec.HTML(http.StatusOK, string(pasteHTMLAsString))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST for "/paste/" which will create new paste and redirect to
|
||||||
|
// "/pastes/CREATED_PASTE_ID".
|
||||||
func pastePOST(ec echo.Context) error {
|
func pastePOST(ec echo.Context) error {
|
||||||
errhtml, err := static.ReadFile("error.html")
|
errhtml, err := static.ReadFile("error.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -68,7 +70,7 @@ func pastePOST(ec echo.Context) error {
|
|||||||
return ec.HTML(http.StatusBadRequest, string(errhtml))
|
return ec.HTML(http.StatusBadRequest, string(errhtml))
|
||||||
}
|
}
|
||||||
|
|
||||||
paste := &models.Paste{
|
paste := &Paste{
|
||||||
Title: params["paste-title"][0],
|
Title: params["paste-title"][0],
|
||||||
Data: params["paste-contents"][0],
|
Data: params["paste-contents"][0],
|
||||||
}
|
}
|
||||||
@ -91,7 +93,7 @@ func pastePOST(ec echo.Context) error {
|
|||||||
paste.KeepFor = keepFor
|
paste.KeepFor = keepFor
|
||||||
|
|
||||||
keepForUnitRaw := keepForUnitRegex.FindAllString(params["paste-keep-for"][0], 1)[0]
|
keepForUnitRaw := keepForUnitRegex.FindAllString(params["paste-keep-for"][0], 1)[0]
|
||||||
keepForUnit := models.PASTE_KEEPS_CORELLATION[keepForUnitRaw]
|
keepForUnit := PASTE_KEEPS_CORELLATION[keepForUnitRaw]
|
||||||
paste.KeepForUnitType = keepForUnit
|
paste.KeepForUnitType = keepForUnit
|
||||||
|
|
||||||
id, err2 := paste.Save(c.Database.GetDatabaseConnection())
|
id, err2 := paste.Save(c.Database.GetDatabaseConnection())
|
||||||
@ -105,6 +107,7 @@ func pastePOST(ec echo.Context) error {
|
|||||||
return ec.Redirect(http.StatusFound, "/paste/"+newPasteIDAsString)
|
return ec.Redirect(http.StatusFound, "/paste/"+newPasteIDAsString)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET for "/pastes/", a list of publicly available pastes.
|
||||||
func pastesGET(ec echo.Context) error {
|
func pastesGET(ec echo.Context) error {
|
||||||
pasteListHTML, err1 := static.ReadFile("pastelist_list.html")
|
pasteListHTML, err1 := static.ReadFile("pastelist_list.html")
|
||||||
if err1 != nil {
|
if err1 != nil {
|
||||||
@ -125,7 +128,7 @@ func pastesGET(ec echo.Context) error {
|
|||||||
|
|
||||||
c.Logger.Debug().Msgf("Requested page #%d", page)
|
c.Logger.Debug().Msgf("Requested page #%d", page)
|
||||||
|
|
||||||
p := &models.Paste{}
|
p := &Paste{}
|
||||||
// Get pastes IDs.
|
// Get pastes IDs.
|
||||||
pastes, err3 := p.GetPagedPastes(c.Database.GetDatabaseConnection(), page)
|
pastes, err3 := p.GetPagedPastes(c.Database.GetDatabaseConnection(), page)
|
||||||
c.Logger.Debug().Msgf("Got %d pastes", len(pastes))
|
c.Logger.Debug().Msgf("Got %d pastes", len(pastes))
|
26
pastes/exported.go
Normal file
26
pastes/exported.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package pastes
|
||||||
|
|
||||||
|
import (
|
||||||
|
// local
|
||||||
|
"github.com/pztrn/fastpastebin/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
c *context.Context
|
||||||
|
)
|
||||||
|
|
||||||
|
// New initializes pastes package and adds neccessary HTTP and API
|
||||||
|
// endpoints.
|
||||||
|
func New(cc *context.Context) {
|
||||||
|
c = cc
|
||||||
|
|
||||||
|
// New paste.
|
||||||
|
c.Echo.POST("/paste/", pastePOST)
|
||||||
|
|
||||||
|
// Show paste.
|
||||||
|
c.Echo.GET("/paste/:id", pasteGET)
|
||||||
|
|
||||||
|
// Pastes list.
|
||||||
|
c.Echo.GET("/pastes/", pastesGET)
|
||||||
|
c.Echo.GET("/pastes/:page", pastesGET)
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package models
|
package pastes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
// stdlib
|
// stdlib
|
||||||
@ -24,6 +24,7 @@ var (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Paste represents paste itself.
|
||||||
type Paste struct {
|
type Paste struct {
|
||||||
ID int `db:"id"`
|
ID int `db:"id"`
|
||||||
Title string `db:"title"`
|
Title string `db:"title"`
|
Loading…
Reference in New Issue
Block a user