initial commit: base structure, config, models
This commit is contained in:
commit
c81d7c3264
13 changed files with 1992 additions and 0 deletions
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
|
||||||
|
# ---> Go
|
||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories (remove the comment below to include it)
|
||||||
|
# vendor/
|
||||||
|
|
||||||
|
# ---> VisualStudioCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# Local History for Visual Studio Code
|
||||||
|
.history/
|
13
.vscode/launch.json
vendored
Normal file
13
.vscode/launch.json
vendored
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [{
|
||||||
|
"name": "Launch Package",
|
||||||
|
"type": "go",
|
||||||
|
"request": "launch",
|
||||||
|
"mode": "auto",
|
||||||
|
"program": "${workspaceFolder}/cmd/feedizer"
|
||||||
|
}]
|
||||||
|
}
|
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"cSpell.words": [
|
||||||
|
"adrg",
|
||||||
|
"feedizer"
|
||||||
|
]
|
||||||
|
}
|
3
README.md
Normal file
3
README.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# Feedizer
|
||||||
|
|
||||||
|
A rewrite of the [Feedizer](https://git.zom.bi/fanir/feedizer) project originally written in PHP5.
|
56
cmd/feedizer/config/config.go
Normal file
56
cmd/feedizer/config/config.go
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/adrg/xdg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
Socket string
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Database string
|
||||||
|
Options string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Database DatabaseConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = Config{
|
||||||
|
Database: DatabaseConfig{
|
||||||
|
Host: "localhost",
|
||||||
|
Port: 5432,
|
||||||
|
Database: "feedizer",
|
||||||
|
Options: "sslmode=require",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const appName = "feedizer"
|
||||||
|
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
numXdgDirs := len(xdg.ConfigDirs)
|
||||||
|
paths := make([]string, numXdgDirs+2)
|
||||||
|
for i, path := range xdg.ConfigDirs {
|
||||||
|
paths[numXdgDirs-i-1] = filepath.Join(path, appName, appName+".yaml")
|
||||||
|
}
|
||||||
|
paths[numXdgDirs] = filepath.Join(xdg.ConfigHome, appName, appName+".yaml")
|
||||||
|
paths[numXdgDirs+1] = appName + ".yaml"
|
||||||
|
|
||||||
|
used, err := ReadFiles(paths, &config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.Println("config: search paths are:", paths)
|
||||||
|
log.Println("config: using config files:", used)
|
||||||
|
|
||||||
|
envAvailable, envUsed := ReadEnv(&config, appName)
|
||||||
|
log.Println("config: usable env vars:", envAvailable)
|
||||||
|
log.Println("config: using env vars:", envUsed)
|
||||||
|
|
||||||
|
return &config, nil
|
||||||
|
}
|
68
cmd/feedizer/config/env.go
Normal file
68
cmd/feedizer/config/env.go
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReadEnv(c *Config, prefix string) (namesAvailable, namesUsed []string) {
|
||||||
|
return structFromEnv(reflect.ValueOf(c), reflect.TypeOf(c), prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func structFromEnv(v reflect.Value, t reflect.Type, prefix string) (namesAvailable, namesUsed []string) {
|
||||||
|
|
||||||
|
if v.Kind() == reflect.Ptr {
|
||||||
|
v = v.Elem()
|
||||||
|
t = t.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < v.NumField(); i++ {
|
||||||
|
fv := v.Field(i)
|
||||||
|
ft := t.Field(i)
|
||||||
|
tag := ft.Tag.Get("env")
|
||||||
|
if !ft.IsExported() || tag == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := ft.Name
|
||||||
|
if tag != "" {
|
||||||
|
name = tag
|
||||||
|
}
|
||||||
|
|
||||||
|
if fv.Kind() == reflect.Struct {
|
||||||
|
na, nu := structFromEnv(fv, ft.Type, prefix+"_"+name)
|
||||||
|
namesAvailable = append(namesAvailable, na...)
|
||||||
|
namesUsed = append(namesUsed, nu...)
|
||||||
|
} else if !ft.Anonymous {
|
||||||
|
envName := strings.ToUpper(prefix + "_" + name)
|
||||||
|
namesAvailable = append(namesAvailable, envName)
|
||||||
|
|
||||||
|
val, ok := os.LookupEnv(envName)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !fv.CanSet() {
|
||||||
|
panic("cannot set field for environment variable " + envName)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch fk := fv.Kind(); fk {
|
||||||
|
case reflect.String:
|
||||||
|
fv.SetString(val)
|
||||||
|
case reflect.Int:
|
||||||
|
x, err := strconv.ParseInt(val, 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fv.SetInt(x)
|
||||||
|
default:
|
||||||
|
panic(fmt.Sprintf("cannot set field for environment variable %s: type %s is not supported", envName, fk))
|
||||||
|
}
|
||||||
|
namesUsed = append(namesUsed, envName)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
return namesAvailable, namesUsed
|
||||||
|
}
|
70
cmd/feedizer/config/files.go
Normal file
70
cmd/feedizer/config/files.go
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/adrg/xdg"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrUnsupportedFormat = errors.New("unsupported format")
|
||||||
|
var ErrFileExists = errors.New("file already exists")
|
||||||
|
|
||||||
|
func ReadFiles(files []string, out *Config) (usedFiles []string, err error) {
|
||||||
|
for _, path := range files {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read config file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := filepath.Ext(path)
|
||||||
|
if len(ext) > 0 {
|
||||||
|
ext = ext[1:]
|
||||||
|
}
|
||||||
|
switch ext {
|
||||||
|
case "yaml", "yml":
|
||||||
|
if err = yaml.NewDecoder(f).Decode(out); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot parse yaml config file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot read config file %s: %w", path, ErrUnsupportedFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
usedFiles = append(usedFiles, path)
|
||||||
|
}
|
||||||
|
return usedFiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteFile(path string) (string, error) {
|
||||||
|
if path == "" {
|
||||||
|
var err error
|
||||||
|
if path, err = xdg.ConfigFile(filepath.Join(appName, appName+".yaml")); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println(path)
|
||||||
|
|
||||||
|
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
if err == nil {
|
||||||
|
return "", ErrFileExists
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = yaml.NewEncoder(f).Encode(config); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return path, f.Close()
|
||||||
|
}
|
69
cmd/feedizer/database/database.go
Normal file
69
cmd/feedizer/database/database.go
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.zom.bi/fanir/feedizer/cmd/feedizer/config"
|
||||||
|
"git.zom.bi/fanir/feedizer/models/migrations"
|
||||||
|
"github.com/golang-migrate/migrate/v4"
|
||||||
|
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||||
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||||
|
)
|
||||||
|
|
||||||
|
var LogName string
|
||||||
|
|
||||||
|
func Open(c config.DatabaseConfig) (*sql.DB, error) {
|
||||||
|
var dsn string
|
||||||
|
if c.Socket != "" {
|
||||||
|
dsn = fmt.Sprintf("postgres://%s:%s@%s/%s?%s", c.Username, c.Password, c.Socket, c.Database, c.Options)
|
||||||
|
LogName = fmt.Sprintf("%s/%s", c.Socket, c.Database)
|
||||||
|
} else {
|
||||||
|
dsn = fmt.Sprintf("postgres://%s:%s@%s:%d/%s?%s", c.Username, c.Password, c.Host, c.Port, c.Database, c.Options)
|
||||||
|
LogName = fmt.Sprintf("%s:%d/%s", c.Host, c.Port, c.Database)
|
||||||
|
}
|
||||||
|
return sql.Open("postgres", dsn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Migrate(db *sql.DB) error {
|
||||||
|
source, err := iofs.New(migrations.FS, ".")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
driver, err := postgres.WithInstance(db, &postgres.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := migrate.NewWithInstance("iofs", source, "postgres", driver)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.Up()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purgeable allows purging the entire database by undoing all migrations.
|
||||||
|
// This is deliberately non-straightforward to use to prevent accidental purging.
|
||||||
|
// Call .Purge() on the instance to do the thing.
|
||||||
|
type Purgeable struct{ *sql.DB }
|
||||||
|
|
||||||
|
func (p Purgeable) Purge() error {
|
||||||
|
source, err := iofs.New(migrations.FS, ".")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
driver, err := postgres.WithInstance(p.DB, &postgres.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := migrate.NewWithInstance("iofs", source, "postgres", driver)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.Down()
|
||||||
|
}
|
92
cmd/feedizer/main.go
Normal file
92
cmd/feedizer/main.go
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.zom.bi/fanir/feedizer/cmd/feedizer/config"
|
||||||
|
"git.zom.bi/fanir/feedizer/cmd/feedizer/database"
|
||||||
|
"github.com/golang-migrate/migrate/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
log.Fatalln("do not run feedizer as root")
|
||||||
|
}
|
||||||
|
|
||||||
|
configFile := flag.String("C", "", "custom config file path")
|
||||||
|
autoMigrate := flag.Bool("m", true, "Automatically migrate the database to the highest version.")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
switch flag.Arg(0) {
|
||||||
|
case "", "run":
|
||||||
|
startApp(*autoMigrate)
|
||||||
|
|
||||||
|
case "newconfig":
|
||||||
|
path, err := config.WriteFile(*configFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("cannot write config file %s: %s", path, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("wrote config file", path)
|
||||||
|
|
||||||
|
case "purgedb":
|
||||||
|
purgeDB()
|
||||||
|
|
||||||
|
default:
|
||||||
|
fmt.Printf("invalid action %s\n\n", flag.Arg(0))
|
||||||
|
fmt.Println("valid actions are probably: run (default), newconfig, purgedb")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startApp(automigrate bool) {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := database.Open(cfg.Database)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if automigrate {
|
||||||
|
log.Println("migrating database...")
|
||||||
|
if err = database.Migrate(db); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
log.Println("migration successful")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func purgeDB() {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := database.Open(cfg.Database)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("PURGE database %s? You will loose all data. [yes/no]\n", database.LogName)
|
||||||
|
in, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
if in != "yes\n" {
|
||||||
|
fmt.Println("Aborting. (You must type \"yes\" to continue)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("purging database...")
|
||||||
|
if err = (database.Purgeable{DB: db}).Purge(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
|
fmt.Println("purging successful")
|
||||||
|
}
|
21
go.mod
Normal file
21
go.mod
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
module git.zom.bi/fanir/feedizer
|
||||||
|
|
||||||
|
go 1.17
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/adrg/xdg v0.4.0
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.15.1
|
||||||
|
github.com/google/uuid v1.3.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
|
github.com/lib/pq v1.10.4 // indirect
|
||||||
|
go.uber.org/atomic v1.9.0 // indirect
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect
|
||||||
|
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 // indirect
|
||||||
|
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa // indirect
|
||||||
|
google.golang.org/grpc v1.43.0 // indirect
|
||||||
|
)
|
8
models/migrations/migrations.go
Normal file
8
models/migrations/migrations.go
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed *.sql
|
||||||
|
var FS embed.FS
|
55
models/models.go
Normal file
55
models/models.go
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Feed struct {
|
||||||
|
ID uuid.UUID // PRIMARY KEY
|
||||||
|
Slug string // NOT NULL UNIQUE
|
||||||
|
URI string // NOT NULL
|
||||||
|
AutoRefresh bool // NOT NULL
|
||||||
|
RefreshInterval int //
|
||||||
|
NextRefresh time.Time // NOT NULL
|
||||||
|
Expire bool // NOT NULL
|
||||||
|
ExpireDate time.Time //
|
||||||
|
Password string //
|
||||||
|
CreationIP net.IP // NOT NULL
|
||||||
|
CreationDate time.Time // NOT NULL DEFAULT now()
|
||||||
|
}
|
||||||
|
|
||||||
|
type Feeditem struct {
|
||||||
|
Feed int // NOT NULL REFERENCES feed ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
Timestamp time.Time // NOT NULL DEFAULT now()
|
||||||
|
HTML string // NOT NULL
|
||||||
|
Diff string //
|
||||||
|
//PRIMARY KEY (feed_ time.Time //)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Announcement struct {
|
||||||
|
ID uuid.UUID // PRIMARY KEY
|
||||||
|
Title string // NOT NULL
|
||||||
|
Content string // NOT NULL
|
||||||
|
Abstract string //
|
||||||
|
PublicationDate time.Time // NOT NULL DEFAULT now()
|
||||||
|
ShowUntil time.Time //
|
||||||
|
IsImportant bool // NOT NULL
|
||||||
|
}
|
||||||
|
|
||||||
|
type Feedhistory struct {
|
||||||
|
Feed int // NOT NULL REFERENCES feed ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
Timestamp time.Time // NOT NULL DEFAULT now()
|
||||||
|
IP net.IP // NOT NULL
|
||||||
|
Slug string //
|
||||||
|
URI string //
|
||||||
|
AutoRefresh bool //
|
||||||
|
RefreshInterval int //
|
||||||
|
NextRefresh time.Time //
|
||||||
|
Expire bool // NOT NULL
|
||||||
|
ExpireDate time.Time //
|
||||||
|
Password string //
|
||||||
|
//PRIMARY KEY (feed_ time.Time //)
|
||||||
|
}
|
Loading…
Reference in a new issue