feat: add pre-commit hooks & refactor code to pass linting

This commit is contained in:
Jordan Knott
2020-08-20 18:11:24 -05:00
parent abf9e1328a
commit 9dba566660
49 changed files with 297 additions and 462 deletions

View File

@ -9,20 +9,27 @@ import (
var jwtKey = []byte("taskcafe_test_key")
// RestrictedMode is used restrict JWT access to just the install route
type RestrictedMode string
const (
// Unrestricted is the code to allow access to all routes
Unrestricted RestrictedMode = "unrestricted"
InstallOnly = "install_only"
// InstallOnly is the code to restrict access ONLY to install route
InstallOnly = "install_only"
)
// Role is the role code for the user
type Role string
const (
RoleAdmin Role = "admin"
// RoleAdmin is the code for the admin role
RoleAdmin Role = "admin"
// RoleMember is the code for the member role
RoleMember Role = "member"
)
// AccessTokenClaims is the claims the access JWT token contains
type AccessTokenClaims struct {
UserID string `json:"userId"`
Restricted RestrictedMode `json:"restricted"`
@ -30,23 +37,23 @@ type AccessTokenClaims struct {
jwt.StandardClaims
}
type RefreshTokenClaims struct {
UserID string `json:"userId"`
jwt.StandardClaims
}
// ErrExpiredToken is the error returned if the token has expired
type ErrExpiredToken struct{}
// Error returns the error message for ErrExpiredToken
func (r *ErrExpiredToken) Error() string {
return "token is expired"
}
// ErrMalformedToken is the error returned if the token has malformed
type ErrMalformedToken struct{}
// Error returns the error message for ErrMalformedToken
func (r *ErrMalformedToken) Error() string {
return "token is malformed"
}
// NewAccessToken generates a new JWT access token with the correct claims
func NewAccessToken(userID string, restrictedMode RestrictedMode, orgRole string) (string, error) {
role := RoleMember
if orgRole == "admin" {
@ -68,6 +75,7 @@ func NewAccessToken(userID string, restrictedMode RestrictedMode, orgRole string
return accessTokenString, nil
}
// NewAccessTokenCustomExpiration creates an access token with a custom duration
func NewAccessTokenCustomExpiration(userID string, dur time.Duration) (string, error) {
accessExpirationTime := time.Now().Add(dur)
accessClaims := &AccessTokenClaims{
@ -85,6 +93,7 @@ func NewAccessTokenCustomExpiration(userID string, dur time.Duration) (string, e
return accessTokenString, nil
}
// ValidateAccessToken validates a JWT access token and returns the contained claims or an error if it's invalid
func ValidateAccessToken(accessTokenString string) (AccessTokenClaims, error) {
accessClaims := &AccessTokenClaims{}
accessToken, err := jwt.ParseWithClaims(accessTokenString, accessClaims, func(token *jwt.Token) (interface{}, error) {
@ -112,18 +121,3 @@ func ValidateAccessToken(accessTokenString string) (AccessTokenClaims, error) {
}
return AccessTokenClaims{}, err
}
func NewRefreshToken(userID string) (string, time.Time, error) {
refreshExpirationTime := time.Now().Add(24 * time.Hour)
refreshClaims := &RefreshTokenClaims{
UserID: userID,
StandardClaims: jwt.StandardClaims{ExpiresAt: refreshExpirationTime.Unix()},
}
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
refreshTokenString, err := refreshToken.SignedString(jwtKey)
if err != nil {
return "", time.Time{}, err
}
return refreshTokenString, refreshExpirationTime, nil
}

View File

@ -9,10 +9,6 @@ import (
"github.com/spf13/viper"
)
const TaskcafeConfDirEnvName = "TASKCAFE_CONFIG_DIR"
const TaskcafeAppConf = "taskcafe"
const mainDescription = `Taskcafé is an open soure project management
system written in Golang & React.`
@ -26,10 +22,6 @@ var versionTemplate = fmt.Sprintf(`Version: %s
Commit: %s
Built: %s`, version, commit, date+"\n")
var commandError error
var configDir string
var verbose bool
var noColor bool
var cfgFile string
var rootCmd = &cobra.Command{
@ -69,6 +61,7 @@ func initConfig() {
}
}
// Execute the root cobra command
func Execute() {
rootCmd.SetVersionTemplate(versionTemplate)
rootCmd.AddCommand(newWebCmd(), newMigrateCmd(), newTokenCmd())

View File

@ -3,8 +3,6 @@
package commands
import (
"fmt"
"github.com/jordanknott/taskcafe/internal/migrations"
)

View File

@ -8,16 +8,17 @@ import (
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/golang-migrate/migrate/v4/source/httpfs"
"github.com/jmoiron/sqlx"
log "github.com/sirupsen/logrus"
)
// MigrateLog is a logger for go migrate
type MigrateLog struct {
verbose bool
}
// Printf logs to logrus
func (l *MigrateLog) Printf(format string, v ...interface{}) {
log.Printf("%s", v)
}

View File

@ -2,14 +2,14 @@ package commands
import (
"fmt"
"net/http"
"time"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/golang-migrate/migrate/v4/source/httpfs"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"net/http"
"time"
"github.com/jmoiron/sqlx"
"github.com/jordanknott/taskcafe/internal/route"

View File

@ -1,58 +0,0 @@
package config
import (
"github.com/BurntSushi/toml"
"io/ioutil"
)
type Database struct {
Host string
Name string
User string
Password string
}
type General struct {
Host string
}
type EmailNotifications struct {
Enabled bool
DisplayName string `toml:"display_name"`
FromAddress string `toml:"from_address"`
}
type Storage struct {
StorageSystem string `toml:"local_storage"`
UploadDirPath string `toml:"upload_dir_path"`
}
type Smtp struct {
Username string
Password string
Server string
Port int
ConnectionSecurity string `toml:"connection_security"`
}
type AppConfig struct {
General General
Database Database
EmailNotifications EmailNotifications `toml:"email_notifications"`
Storage Storage
Smtp Smtp
}
func LoadConfig(path string) (AppConfig, error) {
dat, err := ioutil.ReadFile("conf/app.toml")
if err != nil {
return AppConfig{}, err
}
var appConfig AppConfig
_, err = toml.Decode(string(dat), &appConfig)
if err != nil {
return AppConfig{}, err
}
return appConfig, nil
}

View File

@ -19,4 +19,3 @@ DELETE FROM task_group WHERE task_group_id = $1;
-- name: UpdateTaskGroupLocation :one
UPDATE task_group SET position = $2 WHERE task_group_id = $1 RETURNING *;

View File

@ -4,6 +4,7 @@ import (
"github.com/jmoiron/sqlx"
)
// Repository contains methods for interacting with a database storage
type Repository struct {
*Queries
db *sqlx.DB

View File

@ -2405,7 +2405,7 @@ type Query {
teams: [Team!]!
labelColors: [LabelColor!]!
taskGroups: [TaskGroup!]!
me: MePayload!
me: MePayload!
}
type Mutation

View File

@ -18,7 +18,6 @@ import (
"github.com/jordanknott/taskcafe/internal/auth"
"github.com/jordanknott/taskcafe/internal/db"
log "github.com/sirupsen/logrus"
"github.com/vektah/gqlparser/v2/gqlerror"
)
// NewHandler returns a new graphql endpoint handler.
@ -107,26 +106,32 @@ func NewPlaygroundHandler(endpoint string) http.Handler {
return playground.Handler("GraphQL Playground", endpoint)
}
// GetUserID retrieves the UserID out of a context
func GetUserID(ctx context.Context) (uuid.UUID, bool) {
userID, ok := ctx.Value("userID").(uuid.UUID)
return userID, ok
}
// GetUserRole retrieves the user role out of a context
func GetUserRole(ctx context.Context) (auth.Role, bool) {
role, ok := ctx.Value("org_role").(auth.Role)
return role, ok
}
// GetUser retrieves both the user id & user role out of a context
func GetUser(ctx context.Context) (uuid.UUID, auth.Role, bool) {
userID, userOK := GetUserID(ctx)
role, roleOK := GetUserRole(ctx)
return userID, role, userOK && roleOK
}
// GetRestrictedMode retrieves the restricted mode code out of a context
func GetRestrictedMode(ctx context.Context) (auth.RestrictedMode, bool) {
restricted, ok := ctx.Value("restricted_mode").(auth.RestrictedMode)
return restricted, ok
}
// GetProjectRoles retrieves the team & project role for the given project ID
func GetProjectRoles(ctx context.Context, r db.Repository, projectID uuid.UUID) (db.GetUserRolesForProjectRow, error) {
userID, ok := GetUserID(ctx)
if !ok {
@ -135,6 +140,7 @@ func GetProjectRoles(ctx context.Context, r db.Repository, projectID uuid.UUID)
return r.GetUserRolesForProject(ctx, db.GetUserRolesForProjectParams{UserID: userID, ProjectID: projectID})
}
// ConvertToRoleCode converts a role code string to a RoleCode type
func ConvertToRoleCode(r string) RoleCode {
if r == RoleCodeAdmin.String() {
return RoleCodeAdmin
@ -144,47 +150,3 @@ func ConvertToRoleCode(r string) RoleCode {
}
return RoleCodeObserver
}
func RequireTeamAdmin(ctx context.Context, r db.Repository, teamID uuid.UUID) error {
userID, role, ok := GetUser(ctx)
if !ok {
return errors.New("internal: user id is not set")
}
teamRole, err := r.GetTeamRoleForUserID(ctx, db.GetTeamRoleForUserIDParams{UserID: userID, TeamID: teamID})
isAdmin := role == auth.RoleAdmin
isTeamAdmin := err == nil && ConvertToRoleCode(teamRole.RoleCode) == RoleCodeAdmin
if !(isAdmin || isTeamAdmin) {
return &gqlerror.Error{
Message: "organization or team admin role required",
Extensions: map[string]interface{}{
"code": "2-400",
},
}
} else if err != nil {
return err
}
return nil
}
func RequireProjectOrTeamAdmin(ctx context.Context, r db.Repository, projectID uuid.UUID) error {
role, ok := GetUserRole(ctx)
if !ok {
return errors.New("user ID is not set")
}
if role == auth.RoleAdmin {
return nil
}
roles, err := GetProjectRoles(ctx, r, projectID)
if err != nil {
return err
}
if !(roles.ProjectRole == "admin" || roles.TeamRole == "admin") {
return &gqlerror.Error{
Message: "You must be a team or project admin",
Extensions: map[string]interface{}{
"code": "4-400",
},
}
}
return nil
}

View File

@ -7,9 +7,12 @@ import (
"github.com/jordanknott/taskcafe/internal/db"
)
// GetOwnedList todo: remove this
func GetOwnedList(ctx context.Context, r db.Repository, user db.UserAccount) (*OwnedList, error) {
return &OwnedList{}, nil
}
// GetMemberList returns a list of projects the user is a member of
func GetMemberList(ctx context.Context, r db.Repository, user db.UserAccount) (*MemberList, error) {
projectMemberIDs, err := r.GetMemberProjectIDsForUserID(ctx, user.UserID)
if err != sql.ErrNoRows && err != nil {

View File

@ -1,5 +1,6 @@
//go:generate sh ../scripts/genSchema.sh
//go:generate go run github.com/99designs/gqlgen
package graph
import (
@ -8,6 +9,7 @@ import (
"github.com/jordanknott/taskcafe/internal/db"
)
// Resolver handles resolving GraphQL queries & mutations
type Resolver struct {
Repository db.Repository
mu sync.Mutex

View File

@ -3,18 +3,21 @@ package graph
import (
"io"
"strconv"
"github.com/99designs/gqlgen/graphql"
"github.com/google/uuid"
"github.com/pkg/errors"
"strconv"
)
// MarshalUUID converts a UUID to JSON string
func MarshalUUID(uuid uuid.UUID) graphql.Marshaler {
return graphql.WriterFunc(func(w io.Writer) {
w.Write([]byte(strconv.Quote(uuid.String())))
})
}
// UnmarshalUUID converts a String to a UUID
func UnmarshalUUID(v interface{}) (uuid.UUID, error) {
if uuidRaw, ok := v.(string); ok {
return uuid.Parse(uuidRaw)

View File

@ -187,7 +187,7 @@ type Query {
teams: [Team!]!
labelColors: [LabelColor!]!
taskGroups: [TaskGroup!]!
me: MePayload!
me: MePayload!
}
type Mutation
@ -663,4 +663,3 @@ type DeleteUserAccountPayload {
ok: Boolean!
userAccount: UserAccount!
}

View File

@ -800,11 +800,11 @@ func (r *queryResolver) Users(ctx context.Context) ([]db.UserAccount, error) {
}
func (r *queryResolver) FindUser(ctx context.Context, input FindUser) (*db.UserAccount, error) {
userId, err := uuid.Parse(input.UserID)
userID, err := uuid.Parse(input.UserID)
if err != nil {
return &db.UserAccount{}, err
}
account, err := r.Repository.GetUserAccountByID(ctx, userId)
account, err := r.Repository.GetUserAccountByID(ctx, userID)
if err == sql.ErrNoRows {
return &db.UserAccount{}, &gqlerror.Error{
Message: "User not found",
@ -1097,9 +1097,9 @@ func (r *taskResolver) Badges(ctx context.Context, obj *db.Task) (*TaskBadges, e
return &TaskBadges{}, err
}
for _, item := range items {
total += 1
total++
if item.Complete {
complete += 1
complete++
}
}
}

View File

@ -29,7 +29,7 @@ type Query {
teams: [Team!]!
labelColors: [LabelColor!]!
taskGroups: [TaskGroup!]!
me: MePayload!
me: MePayload!
}
type Mutation

View File

@ -25,4 +25,3 @@ type DeleteProjectPayload {
ok: Boolean!
project: Project!
}

View File

@ -9,19 +9,17 @@ import (
"github.com/sirupsen/logrus"
)
// StructuredLogger is a simple, but powerful implementation of a custom structured
// logger backed on logrus. I encourage users to copy it, adapt it and make it their
// own. Also take a look at https://github.com/pressly/lg for a dedicated pkg based
// on this work, designed for context-based http routers.
// NewStructuredLogger creates a new logger for chi router
func NewStructuredLogger(logger *logrus.Logger) func(next http.Handler) http.Handler {
return middleware.RequestLogger(&StructuredLogger{logger})
}
// StructuredLogger is a logger for chi router
type StructuredLogger struct {
Logger *logrus.Logger
}
// NewLogEntry creates a new log entry for the given HTTP request
func (l *StructuredLogger) NewLogEntry(r *http.Request) middleware.LogEntry {
entry := &StructuredLoggerEntry{Logger: logrus.NewEntry(l.Logger)}
logFields := logrus.Fields{}
@ -48,10 +46,12 @@ func (l *StructuredLogger) NewLogEntry(r *http.Request) middleware.LogEntry {
return entry
}
// StructuredLoggerEntry is a log entry will all relevant information about a specific http request
type StructuredLoggerEntry struct {
Logger logrus.FieldLogger
}
// Write logs information about http request response body
func (l *StructuredLoggerEntry) Write(status, bytes int, elapsed time.Duration) {
l.Logger = l.Logger.WithFields(logrus.Fields{
"resp_status": status, "resp_bytes_length": bytes,
@ -60,6 +60,7 @@ func (l *StructuredLoggerEntry) Write(status, bytes int, elapsed time.Duration)
l.Logger.Debugln("request complete")
}
// Panic logs if the request panics
func (l *StructuredLoggerEntry) Panic(v interface{}, stack []byte) {
l.Logger = l.Logger.WithFields(logrus.Fields{
"stack": string(stack),
@ -67,24 +68,20 @@ func (l *StructuredLoggerEntry) Panic(v interface{}, stack []byte) {
})
}
// Helper methods used by the application to get the request-scoped
// logger entry and set additional fields between handlers.
//
// This is a useful pattern to use to set state on the entry as it
// passes through the handler chain, which at any point can be logged
// with a call to .Print(), .Info(), etc.
// GetLogEntry helper function for getting log entry for request
func GetLogEntry(r *http.Request) logrus.FieldLogger {
entry := middleware.GetLogEntry(r).(*StructuredLoggerEntry)
return entry.Logger
}
// LogEntrySetField sets a key's value
func LogEntrySetField(r *http.Request, key string, value interface{}) {
if entry, ok := r.Context().Value(middleware.LogEntryCtxKey).(*StructuredLoggerEntry); ok {
entry.Logger = entry.Logger.WithField(key, value)
}
}
// LogEntrySetFields sets the log entry's fields
func LogEntrySetFields(r *http.Request, fields map[string]interface{}) {
if entry, ok := r.Context().Value(middleware.LogEntryCtxKey).(*StructuredLoggerEntry); ok {
entry.Logger = entry.Logger.WithFields(fields)

View File

@ -18,11 +18,13 @@ var jwtKey = []byte("taskcafe_test_key")
type authResource struct{}
// LoginRequestData is the request data when a user logs in
type LoginRequestData struct {
Username string
Password string
}
// NewUserAccount is the request data for a new user
type NewUserAccount struct {
FullName string `json:"fullname"`
Username string
@ -31,30 +33,35 @@ type NewUserAccount struct {
Email string
}
// InstallRequestData is the request data for installing new Taskcafe app
type InstallRequestData struct {
User NewUserAccount
}
// LoginResponseData is the response data for when a user logs in
type LoginResponseData struct {
AccessToken string `json:"accessToken"`
IsInstalled bool `json:"isInstalled"`
}
// LogoutResponseData is the response data for when a user logs out
type LogoutResponseData struct {
Status string `json:"status"`
}
// RefreshTokenResponseData is the response data for when an access token is refreshed
type RefreshTokenResponseData struct {
AccessToken string `json:"accessToken"`
}
// AvatarUploadResponseData is the response data for a user profile is uploaded
type AvatarUploadResponseData struct {
UserID string `json:"userID"`
URL string `json:"url"`
}
// RefreshTokenHandler handles when a user attempts to refresh token
func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
_, err := h.repo.GetSystemOptionByKey(r.Context(), "is_installed")
if err == sql.ErrNoRows {
user, err := h.repo.GetUserAccountByUsername(r.Context(), "system")
@ -131,6 +138,7 @@ func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Req
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString, IsInstalled: true})
}
// LogoutHandler removes all refresh tokens to log out user
func (h *TaskcafeHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("refreshToken")
if err != nil {
@ -150,6 +158,7 @@ func (h *TaskcafeHandler) LogoutHandler(w http.ResponseWriter, r *http.Request)
json.NewEncoder(w).Encode(LogoutResponseData{Status: "success"})
}
// LoginHandler creates a new refresh & access token for the user if given the correct credentials
func (h *TaskcafeHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
var requestData LoginRequestData
err := json.NewDecoder(r.Body).Decode(&requestData)
@ -171,8 +180,8 @@ func (h *TaskcafeHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(requestData.Password))
if err != nil {
log.WithFields(log.Fields{
"username": requestData.Username,
}).Warn("password incorrect for user")
"username": requestData.Username,
}).Warn("password incorrect for user")
w.WriteHeader(http.StatusUnauthorized)
return
}
@ -196,6 +205,7 @@ func (h *TaskcafeHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false})
}
// InstallHandler creates first user on fresh install
func (h *TaskcafeHandler) InstallHandler(w http.ResponseWriter, r *http.Request) {
if restricted, ok := r.Context().Value("restricted_mode").(auth.RestrictedMode); ok {
if restricted != auth.InstallOnly {
@ -256,6 +266,7 @@ func (h *TaskcafeHandler) InstallHandler(w http.ResponseWriter, r *http.Request)
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false})
}
// Routes registers all authentication routes
func (rs authResource) Routes(taskcafeHandler TaskcafeHandler) chi.Router {
r := chi.NewRouter()
r.Post("/login", taskcafeHandler.LoginHandler)

View File

@ -16,6 +16,7 @@ import (
"github.com/jordanknott/taskcafe/internal/frontend"
)
// Frontend serves the index.html file
func (h *TaskcafeHandler) Frontend(w http.ResponseWriter, r *http.Request) {
f, err := frontend.Frontend.Open("index.h")
if os.IsNotExist(err) {
@ -26,6 +27,7 @@ func (h *TaskcafeHandler) Frontend(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, "index.html", time.Now(), f)
}
// ProfileImageUpload handles a user uploading a new avatar profile image
func (h *TaskcafeHandler) ProfileImageUpload(w http.ResponseWriter, r *http.Request) {
log.Info("preparing to upload file")
userID, ok := r.Context().Value("userID").(uuid.UUID)

View File

@ -10,6 +10,19 @@ import (
log "github.com/sirupsen/logrus"
)
// ContextKey represents a context key
type ContextKey string
const (
// UserIDKey is the key for the user id of the authenticated user
UserIDKey ContextKey = "userID"
//RestrictedModeKey is the key for whether the authenticated user only has access to install route
RestrictedModeKey ContextKey = "restricted_mode"
// OrgRoleKey is the key for the organization role code of the authenticated user
OrgRoleKey ContextKey = "org_role"
)
// AuthenticationMiddleware is a middleware that requires a valid JWT token to be passed via the Authorization header
func AuthenticationMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
bearerTokenRaw := r.Header.Get("Authorization")
@ -51,9 +64,9 @@ func AuthenticationMiddleware(next http.Handler) http.Handler {
return
}
}
ctx := context.WithValue(r.Context(), "userID", userID)
ctx = context.WithValue(ctx, "restricted_mode", accessClaims.Restricted)
ctx = context.WithValue(ctx, "org_role", accessClaims.OrgRole)
ctx := context.WithValue(r.Context(), UserIDKey, userID)
ctx = context.WithValue(ctx, RestrictedModeKey, accessClaims.Restricted)
ctx = context.WithValue(ctx, OrgRoleKey, accessClaims.OrgRole)
next.ServeHTTP(w, r.WithContext(ctx))
})

View File

@ -10,23 +10,22 @@ import (
"github.com/jmoiron/sqlx"
log "github.com/sirupsen/logrus"
"os"
"path/filepath"
"github.com/jordanknott/taskcafe/internal/db"
"github.com/jordanknott/taskcafe/internal/frontend"
"github.com/jordanknott/taskcafe/internal/graph"
"github.com/jordanknott/taskcafe/internal/logger"
"os"
"path/filepath"
)
// spaHandler implements the http.Handler interface, so we can use it
// to respond to HTTP requests. The path to the static directory and
// path to the index file within that static directory are used to
// serve the SPA in the given static directory.
// FrontendHandler serves an embed React client through chi
type FrontendHandler struct {
staticPath string
indexPath string
}
// IsDir checks if the given file is a directory
func IsDir(f http.File) bool {
fi, err := f.Stat()
if err != nil {
@ -35,6 +34,7 @@ func IsDir(f http.File) bool {
return fi.IsDir()
}
// ServeHTTP attempts to serve a requested file for the embedded React client
func (h FrontendHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path, err := filepath.Abs(r.URL.Path)
if err != nil {
@ -58,10 +58,12 @@ func (h FrontendHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, path, time.Now(), f)
}
// TaskcafeHandler contains all the route handlers
type TaskcafeHandler struct {
repo db.Repository
}
// NewRouter creates a new router for chi
func NewRouter(dbConnection *sqlx.DB) (chi.Router, error) {
formatter := new(log.TextFormatter)
formatter.TimestampFormat = "02-01-2006 15:04:05"