tabloid

package module
v0.0.0-...-6972059 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Feb 7, 2021 License: GPL-3.0 Imports: 33 Imported by: 0

README

Tabloid

CircleCI Documentation

Presently, Tabloid is in its early stages, making it a bit rough for production use. APIs and DB schemas may break.

Purpose

Tabloid is a simple, minimalistic Hackernews engine written in Go. It is designed toward small private communities, such as a company in its early stages or a group of friends. It makes it easy to set up a private newsboard and to adapt it to your context.

In most scenarios, the base idea stays the same: a group of people exchange and score links, and post comments about them, in a tree form. But often, what drives adoption and/or what makes it valuable for a particular community is tailoring it to that community pre-existing tools and ecosystem.

How it works

To allow for extensibility, Tabloid goes for a "library" model, where you simply create a main.go , import tabloid and write your custom behaviour:

package main

import (
    // ...
    "github.com/jhchabran/tabloid"
)


func main() {
    // load the config
    cfg := cmd.DefaultConfig()
    err := cfg.Load()
    if err != nil {
        log.Fatal().Err(err).Msg("Cannot read configuration")
    }
    logger := cmd.SetupLogger(cfg)

    // setup database
    pg := pgstore.New(cfg.DatabaseURL)

    // setup authentication
    authService := github_auth.New(cfg.ServerSecret, cfg.GithubClientID, cfg.GithubClientSecret, ll)

    // create the server
    s := tabloid.NewServer(&tabloid.ServerConfig{Addr: cfg.Addr, StoriesPerPage: cfg.StoriesPerPage}, logger, pg, authService)

    // 🔥 do something every time a story is submitted
    s.AddStoryHook(func(story *tabloid.Story) error {
        s.Logger.Debug().Msg("Adding a story hook")
        // example: post the story on a #share channel on slack
        return nil
    })

    // 🔥 do something every time a comment is submitted
    s.AddCommentHook(func(story *tabloid.Story, comment *tabloid.Comment) error {
        s.Logger.Debug().Msg("Adding a comment hook")
        // example: post the comment on a #share channel on slack and ping users mentioned in the comments
        return nil
    })

    // Prepare and start the server
    err = s.Prepare()
    if err != nil {
        logger.Fatal().Err(err).Msg("Cannot prepare server")
    }

    err = s.Start()
    if err != nil {
        logger.Fatal().Err(err).Msg("Cannot start server")
    }
}

From there, this file can be versioned and Tabloid updates are just a matter of updating your go modules and your customizations are self-contained.

Deploying it

Presently, it's not streamlined at all, as it's still the early stages and no stable releases had been made. The main goal there is to provide an example repository that can be forked, modified and deployed to common cloud providers with a single button (See #51, #8)

Still, for now it can be easily deployed on Heroku with the provided Dockerfile.

Reasoning

Communities using Tabloid may come from different software backgrounds, which is why Tabloid isn't using any kind of framework. Everybody should be able to contribute and frameworks are usually getting in the way when it comes to add that little feature that would makes sense in your context.

Also, not relying on any framework makes the code a bit more resiliant to time. Nobody likes to hack around outdated libs and the feature-set is small enough to deal with it without it. It doesn't have usual abstractions for such an app and yes it could be written in less than n lines with library X but the idea is that the code is almost self-contained.

Not knowing too much about Go shouldn't be an entry barrier. Similarly, the front-end code aims to be as simple as possible. Pure HTML should be enough for this kind of web application.

Contributing

See the issues to report a bug, open a feature request or simply if you want to find something to contribute on. Good first issues are a good way to start.

Running the code
make migrate
go run cmd/server/main.go
open "http://localhost:8080"
Config

See config.example.json.

  • LOG_LEVEL sets log level; defaults to info
  • LOG_FORMAT sets log format; defaults to json
  • PORT sets the port to listen for incoming requests, supersedes ADDR.
  • ADDR sets the address to listen for incoming requests
  • DATABASE_URL sets the database url string, supersedes other database settings below.
  • DATABASE_NAME sets the database name
  • DATABASE_USER sets the database user
  • DATABASE_HOST sets the database host
  • DATABASE_PASSWORD sets the database password
  • GITHUB_CLIENT_ID sets the Github client ID
  • GITHUB_CLIENT_SECRET sets the Github client secret
  • SERVER_SECRET sets the server secret for cookies
  • STORIES_PER_PAGE sets the server number of stories per page; default to 20.
  • FRONT_PAGE_TIME_BASE_IN_HOURS adjusts how front page stories are ranked; it defines the time window that may be considered as "current"; defaults to 24 (Visualisation)
  • FRONT_PAGE_GRAVITY adjusts how front page stories are ranked; it defines how fast the ranking decrease as older a story gets; defaults to 1.8. (Visualisation)

Configuration for the provided example main (cmd/server/main.go), used for dev purpose until we reach a stable release:

  • GH_USERNAMES_JSON sets the url to a json file representing a table of slack handles github handles, used in my custom hook, for pinging users in comments notifications.

Documentation

Index

Constants

This section is empty.

Variables

View Source
var NowFunc func() time.Time = time.Now

Functions

func SetFlash

func SetFlash(w http.ResponseWriter, level string, value string)

SetFlash sets a cookie on the http response. Valid levels are primary, secondary, success, danger, warning , info, light and dark.

Its display is handled client side through JS.

Types

type BadRequestError

type BadRequestError struct {
	// contains filtered or unexported fields
}

BadRequestError responds with bad request status code

func BadRequest

func BadRequest(err error) *BadRequestError

func (*BadRequestError) Error

func (e *BadRequestError) Error() string

func (*BadRequestError) RespondError

func (e *BadRequestError) RespondError(w http.ResponseWriter, r *http.Request) bool

type Comment

type Comment struct {
	ID              string         `db:"id"`
	ParentCommentID sql.NullString `db:"parent_comment_id"`
	StoryID         string         `db:"story_id"`
	Body            string         `db:"body"`
	Score           int64          `db:"score"`
	AuthorID        string         `db:"author_id"`
	Author          string         `db:"author"`
	CreatedAt       time.Time      `db:"created_at"`
}

func NewComment

func NewComment(storyID string, parentCommentID sql.NullString, body string, authorID string) *Comment

func (*Comment) Age

func (c *Comment) Age() time.Time

func (*Comment) GetID

func (c *Comment) GetID() string

func (*Comment) GetParentCommentID

func (c *Comment) GetParentCommentID() sql.NullString

func (*Comment) GetScore

func (c *Comment) GetScore() int64

func (*Comment) Pings

func (c *Comment) Pings() []string

type CommentAccessor

type CommentAccessor interface {
	GetID() string
	GetParentCommentID() sql.NullString
	GetScore() int64
	Age() time.Time
}

TODO we should probably refactor these structs, their name are unclear for now, it'll do the job.

type CommentHookFn

type CommentHookFn func(*Story, *Comment) error

CommentHookFn represents a function suitable for Commet hooks

type CommentNode

type CommentNode struct {
	Comment  CommentAccessor
	Children []*CommentNode
}

CommentTree is a simple tree of comments ordered by score

type CommentPresenter

type CommentPresenter struct {
	ID         string
	StoryID    string
	Path       string
	ParentPath string
	StoryPath  string
	Body       template.HTML
	Score      int64
	Author     string
	CreatedAt  time.Time
	Children   []*CommentPresenter
	Upvoted    bool
	CanEdit    bool
}

TODO move this in a better place

func NewCommentPresenter

func NewCommentPresenter(c *CommentNode) *CommentPresenter

TODO missing fields

func (*CommentPresenter) Age

func (c *CommentPresenter) Age() time.Time

func (*CommentPresenter) GetScore

func (c *CommentPresenter) GetScore() int64

func (*CommentPresenter) SetCanEdit

func (c *CommentPresenter) SetCanEdit(userName string, editWindow time.Duration, at time.Time)

CanEdit returns true if the given user is the author and if the creation date is still within the given edit window.

type CommentPresentersTree

type CommentPresentersTree []*CommentPresenter

func NewCommentPresentersTree

func NewCommentPresentersTree(comments []CommentAccessor) CommentPresentersTree

func (CommentPresentersTree) SetCanEdits

func (t CommentPresentersTree) SetCanEdits(userName string, editWindow time.Duration, at time.Time)

func (CommentPresentersTree) Sort

func (t CommentPresentersTree) Sort(rankFn func(s ranking.Rankable) float64)

type CommentSeenByUser

type CommentSeenByUser struct {
	Comment
	UserID string       `db:"user_id"`
	Up     sql.NullBool `db:"up"`
}

func (*CommentSeenByUser) GetID

func (c *CommentSeenByUser) GetID() string

func (*CommentSeenByUser) GetParentCommentID

func (c *CommentSeenByUser) GetParentCommentID() sql.NullString

type ErrorResponder

type ErrorResponder interface {
	RespondError(w http.ResponseWriter, r *http.Request) bool
}

type HandleE

HandleE is a httprouter.Handle that also returns an error.

type Maybe404Error

type Maybe404Error struct {
	// contains filtered or unexported fields
}

Maybe404Error responds with not found status code, if its supplied error is sql.ErrNoRows.

func Maybe404

func Maybe404(err error) *Maybe404Error

func (*Maybe404Error) Error

func (e *Maybe404Error) Error() string

func (*Maybe404Error) Is404

func (e *Maybe404Error) Is404() bool

func (*Maybe404Error) RespondError

func (e *Maybe404Error) RespondError(w http.ResponseWriter, r *http.Request) bool

type MethodNotAllowedError

type MethodNotAllowedError struct {
	// contains filtered or unexported fields
}

MethodNotAllowedError responds with a method not allowed status code.

func MethodNotAllowed

func MethodNotAllowed(method string, path string) *MethodNotAllowedError

func (*MethodNotAllowedError) Error

func (e *MethodNotAllowedError) Error() string

func (*MethodNotAllowedError) RespondError

func (e *MethodNotAllowedError) RespondError(w http.ResponseWriter, r *http.Request) bool

type Server

type Server struct {
	// Logger is the server logger
	Logger zerolog.Logger
	// contains filtered or unexported fields
}

Server represents the HTTP server component, with all its runtime dependencies.

func NewServer

func NewServer(config *ServerConfig, logger zerolog.Logger, store Store, authService authentication.AuthService) *Server

NewServer returns a server instance, configured with given components and with middlewares installed.

func (*Server) AddCommentHook

func (s *Server) AddCommentHook(fn CommentHookFn)

AddCommentHook registers a given CommentHookFn, that will be called every time a story is submitted. Multiple hooks will be called in the order they were registered. If a hook fails and returns an error, it will interrupt the request but won't prevent the Comment to be created.

func (*Server) AddStoryHook

func (s *Server) AddStoryHook(fn StoryHookFn)

AddStoryHook registers a given StoryHookFn, that will be called every time a story is submitted. Multiple hooks will be called in the order they were registered. If a hook fails and returns an error, it will interrupt the request but won't prevent the Story to be created.

func (*Server) HandleCommentEdit

func (s *Server) HandleCommentEdit() HandleE

func (*Server) HandleCommentUpdateAction

func (s *Server) HandleCommentUpdateAction() HandleE

func (*Server) HandleIndex

func (s *Server) HandleIndex() HandleE

HandleIndex handles requests for the root path, listing sorted paginated stories. If the client isn't authenticated, it serves a template with no upvoting nor commenting capabilities.

func (*Server) HandleOAuthCallback

func (s *Server) HandleOAuthCallback() HandleE

HandleOAuthCallback handles requests of the OAuth provider redirects the user back to Tabloid, after successfully authenticating him on its side.

func (*Server) HandleOAuthDestroy

func (s *Server) HandleOAuthDestroy() HandleE

HandleOAuthDestroy handles requests destroying the current session.

func (*Server) HandleOAuthStart

func (s *Server) HandleOAuthStart() HandleE

HandleOAuthStart handles requests starting the OAauth authentication process.

func (*Server) HandleShow

func (s *Server) HandleShow() HandleE

HandleShow handles requests to access a particular Story, showing all its comments and allowing the user to comment if authenticated.

func (*Server) HandleSubmit

func (s *Server) HandleSubmit() HandleE

HandleSubmit handles requests to get the form for submitting a Story. It redirects to the root path if not authenticated.

func (*Server) HandleSubmitAction

func (s *Server) HandleSubmitAction() HandleE

HandleSubmitAction handles requests for when a user submit a Story form. It redirects the user to the root path if not authenticated. In case someone bypass the client-side form validations with invalid form data, it returns a HTTP error.

func (*Server) HandleSubmitCommentAction

func (s *Server) HandleSubmitCommentAction() HandleE

HandleSubmitCommentAction handles requests for when a user submit a Comment form for a given Story. It redirects the user to the root path if not authenticated. In case someone bypass the client-side form validations with invalid form data, it returns a HTTP error.

func (*Server) HandleVoteCommentAction

func (s *Server) HandleVoteCommentAction() HandleE

HandleVoteCommentAction handles requests to vote on a comment. It redirects back to the Story on which the Comment was posted on. If not authenticated, it redirects to the root path.

func (*Server) HandleVoteStoryAction

func (s *Server) HandleVoteStoryAction() HandleE

HandleVoteStoryAction handles requests to vote on a given Story. If not authenticated, it redirects to the root path.

func (*Server) Prepare

func (s *Server) Prepare() error

Prepare setups all internal components, like connecting to the database, declaring routes and loading templates.

func (*Server) ServeHTTP

func (s *Server) ServeHTTP(res http.ResponseWriter, req *http.Request)

ServeHTTP implements a http.Handler that answers incoming requests.

func (*Server) Start

func (s *Server) Start() error

Start runs the server and will block until stopped.

func (*Server) Stop

func (s *Server) Stop()

Stop gracefully stops a running server.

type ServerConfig

type ServerConfig struct {
	Addr                     string
	StoriesPerPage           int
	EditWindowInMinutes      int
	FrontPageTimeBaseInHours int
	FrontPageGravity         float64
}

ServerConfig represents the settings required for the server to operate.

type Store

type Store interface {
	Connect() error
	FindStory(ID string) (*Story, error)
	FindStoryWithVote(ID string, userID string) (*StorySeenByUser, error)
	ListStories(page int, perPage int) ([]*Story, error)
	ListStoriesWithVotes(userID string, page int, perPage int) ([]*StorySeenByUser, error)
	InsertStory(item *Story) error
	FindComment(commentID string) (*Comment, error)
	ListComments(storyID string) ([]*Comment, error)
	ListCommentsWithVotes(storyID string, userID string) ([]*CommentSeenByUser, error)
	InsertComment(comment *Comment) error
	UpdateComment(comment *Comment) error
	FindUserByLogin(login string) (*User, error)
	CreateOrUpdateUser(login string, email string) (string, error)
	CreateOrUpdateVoteOnStory(storyID string, userID string, up bool) error
	CreateOrUpdateVoteOnComment(storyID string, userID string, up bool) error
	UpdateUser(user *User) error
}

type Story

type Story struct {
	ID            string    `db:"id"`
	Title         string    `db:"title"`
	URL           string    `db:"url"`
	Body          string    `db:"body"`
	Score         int64     `db:"score"`
	Author        string    `db:"author"`
	AuthorID      string    `db:"author_id"`
	CommentsCount int64     `db:"comments_count"`
	CreatedAt     time.Time `db:"created_at"`
}

func NewStory

func NewStory(title string, body string, authorID string, url string) *Story

func (*Story) Age

func (s *Story) Age() time.Time

func (*Story) GetScore

func (s *Story) GetScore() int64

type StoryHookFn

type StoryHookFn func(*Story) error

StoryHookFn represents a function suitable for Story hooks

type StorySeenByUser

type StorySeenByUser struct {
	Story
	UserId string       `db:"user_id"`
	Up     sql.NullBool `db:"up"`
}

type UnauthorizedError

type UnauthorizedError struct {
	// contains filtered or unexported fields
}

UnauthorizedError responds with unauthorized status code.

func Unauthorized

func Unauthorized(path string) *UnauthorizedError

func (*UnauthorizedError) Error

func (e *UnauthorizedError) Error() string

func (*UnauthorizedError) RespondError

func (e *UnauthorizedError) RespondError(w http.ResponseWriter, r *http.Request) bool

type UnprocessableEntityError

type UnprocessableEntityError struct {
	// contains filtered or unexported fields
}

UnprocessableEntityErrorError responds with bad request status code, listing fields that are invalid.

func UnprocessableEntity

func UnprocessableEntity(fieldNames ...string) *UnprocessableEntityError

func UnprocessableEntityWithError

func UnprocessableEntityWithError(err error, fieldNames ...string) *UnprocessableEntityError

func (*UnprocessableEntityError) Error

func (e *UnprocessableEntityError) Error() string

func (*UnprocessableEntityError) RespondError

type User

type User struct {
	ID          string       `db:"id"`
	Name        string       `db:"name"`
	Email       string       `db:"email"`
	CreatedAt   time.Time    `db:"created_at"`
	Settings    UserSettings `db:"settings"`
	LastLoginAt time.Time    `db:"last_login_at"`
}

type UserSettings

type UserSettings struct {
	SendDailyDigest bool `json:"send_daily_digest,omitempty"`
}

func (*UserSettings) Scan

func (us *UserSettings) Scan(value interface{}) error

func (UserSettings) Value

func (us UserSettings) Value() (driver.Value, error)

type Vote

type Vote struct {
	ID        string         `db:"id"`
	CommentID sql.NullString `db:"comment_id"`
	StoryID   sql.NullString `db:"story_id"`
	UserID    string         `db:"user_id"`
	Up        bool           `db:"up"`
	CreatedAt time.Time      `db:"created_at"`
}

Directories

Path Synopsis
cmd
seeds command
server command
This package is a playground for my current experimentations and showcase how Tabloid could be used by communities.
This package is a playground for my current experimentations and showcase how Tabloid could be used by communities.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL