├── .do ├── app.yaml └── deploy.template.yaml ├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ ├── docker.yml │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .goreleaser.yaml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── _config.yml ├── api ├── api.go └── v1 │ ├── feeds │ ├── create.go │ ├── feeds.go │ ├── list.go │ └── show.go │ ├── helpers.go │ ├── tokens │ ├── create.go │ └── tokens.go │ ├── users │ ├── create.go │ ├── list.go │ ├── show.go │ ├── update.go │ └── users.go │ └── v1.go ├── app.json ├── crawler ├── crawler.go └── feed.go ├── docs ├── docs.go ├── swagger.json └── swagger.yaml ├── ent ├── client.go ├── ent.go ├── enttest │ └── enttest.go ├── feed.go ├── feed │ ├── feed.go │ └── where.go ├── feed_create.go ├── feed_delete.go ├── feed_query.go ├── feed_update.go ├── generate.go ├── hook │ └── hook.go ├── item.go ├── item │ ├── item.go │ └── where.go ├── item_create.go ├── item_delete.go ├── item_query.go ├── item_update.go ├── migrate │ ├── migrate.go │ └── schema.go ├── mutation.go ├── predicate │ └── predicate.go ├── read.go ├── read │ ├── read.go │ └── where.go ├── read_create.go ├── read_delete.go ├── read_query.go ├── read_update.go ├── runtime.go ├── runtime │ └── runtime.go ├── schema │ ├── feed.go │ ├── item.go │ ├── read.go │ ├── subscription.go │ ├── token.go │ └── user.go ├── subscription.go ├── subscription │ ├── subscription.go │ └── where.go ├── subscription_create.go ├── subscription_delete.go ├── subscription_query.go ├── subscription_update.go ├── token.go ├── token │ ├── token.go │ └── where.go ├── token_create.go ├── token_delete.go ├── token_query.go ├── token_update.go ├── tx.go ├── user.go ├── user │ ├── user.go │ └── where.go ├── user_create.go ├── user_delete.go ├── user_query.go └── user_update.go ├── examples └── etc │ ├── journalist.toml │ └── rc.d │ └── journalist ├── favicon.ico ├── favicon.png ├── gcf.go ├── go.mod ├── go.sum ├── helpers └── helpers.go ├── journalist.go ├── journalist.png ├── journalistd └── journalistd.go ├── lib ├── config.go └── lib.go ├── middlewares └── fiberzap │ ├── fiberzap.go │ └── types.go ├── redacteur ├── render.yaml ├── rss └── rss.go ├── test.sh ├── views ├── actions.html └── subscriptions.list.html └── web ├── actions ├── actions.go ├── read.go ├── read_all.go ├── read_newer.go └── read_older.go ├── engine.go ├── subscriptions ├── list.go └── subscriptions.go └── web.go /.do/app.yaml: -------------------------------------------------------------------------------- 1 | name: journalist 2 | region: nyc 3 | services: 4 | - name: journalist 5 | envs: 6 | - key: JOURNALIST_SERVER_BINDIP 7 | value: 0.0.0.0 8 | - key: DATABASE_URL 9 | scope: RUN_TIME 10 | value: "${journalistdb.DATABASE_URL}" 11 | image: 12 | registry_type: "DOCKER_HUB" 13 | registry: "mrusme" 14 | repository: "journalist" 15 | tag: "latest" 16 | http_port: 8000 17 | health_check: 18 | initial_delay_seconds: 10 19 | period_seconds: 60 20 | timeout_seconds: 30 21 | http_path: /health 22 | port: 8000 23 | instance_count: 1 24 | instance_size_slug: basic-xxs 25 | routes: 26 | - path: /health 27 | preserve_path_prefix: true 28 | - path: /web 29 | preserve_path_prefix: true 30 | - path: /api 31 | preserve_path_prefix: true 32 | cors: 33 | allow_origins: 34 | - regex: "*" 35 | allow_methods: 36 | - GET 37 | - POST 38 | - PUT 39 | - DELETE 40 | databases: 41 | - name: journalistdb 42 | engine: PG 43 | production: true 44 | db_user: journalist 45 | db_name: journalist 46 | 47 | -------------------------------------------------------------------------------- /.do/deploy.template.yaml: -------------------------------------------------------------------------------- 1 | spec: 2 | name: journalist 3 | services: 4 | - name: journalist 5 | envs: 6 | - key: JOURNALIST_SERVER_BINDIP 7 | value: 0.0.0.0 8 | - key: DATABASE_URL 9 | scope: RUN_TIME 10 | value: "${journalistdb.DATABASE_URL}" 11 | image: 12 | registry_type: "DOCKER_HUB" 13 | registry: "mrusme" 14 | repository: "journalist" 15 | tag: "latest" 16 | http_port: 8000 17 | health_check: 18 | initial_delay_seconds: 10 19 | period_seconds: 60 20 | timeout_seconds: 30 21 | http_path: /health 22 | port: 8000 23 | instance_count: 1 24 | instance_size_slug: basic-xxs 25 | routes: 26 | - path: /health 27 | preserve_path_prefix: true 28 | - path: /web 29 | preserve_path_prefix: true 30 | - path: /api 31 | preserve_path_prefix: true 32 | cors: 33 | allow_origins: 34 | - regex: "*" 35 | allow_methods: 36 | - GET 37 | - POST 38 | - PUT 39 | - DELETE 40 | databases: 41 | - name: journalistdb 42 | engine: PG 43 | production: true 44 | db_user: journalist 45 | db_name: journalist 46 | 47 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | indent_style = space 8 | indent_size = 2 9 | insert_final_newline = true 10 | max_line = 80 11 | 12 | [*.{md,markdown}] 13 | trim_trailing_whitespace = false 14 | 15 | [*.go] 16 | indent_style = tab 17 | indent_size = 2 18 | 19 | [{Makefile,Makefile.*}] 20 | indent_style = tab 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ["https://github.com/mrusme#support"] 2 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | tags: 8 | - '*' 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: checkout code 15 | uses: actions/checkout@v4 16 | - name: login docker 17 | run: | 18 | echo "${{ secrets.DOCKER_PASSWORD }}" \ 19 | | docker login \ 20 | -u "${{ secrets.DOCKER_USERNAME }}" \ 21 | --password-stdin 22 | - name: setup qemu 23 | uses: docker/setup-qemu-action@v3 24 | - name: setup buildx 25 | id: buildx 26 | uses: docker/setup-buildx-action@v3 27 | - name: build image 28 | run: | 29 | docker buildx build \ 30 | --push \ 31 | --tag "mrusme/journalist:latest" \ 32 | --tag "mrusme/journalist:${{ github.ref_name }}" \ 33 | --platform \ 34 | "linux/i386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64" \ 35 | . 36 | 37 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | 10 | release: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Install dependencies 16 | run: | 17 | sudo apt update 18 | sudo apt install -y gcc-multilib 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: 1.22.4 24 | 25 | - name: Run GoReleaser 26 | uses: goreleaser/goreleaser-action@v6 27 | with: 28 | distribution: goreleaser 29 | version: '~> v2' 30 | args: release --clean --timeout 80m 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | tests: 7 | defaults: 8 | run: 9 | shell: bash 10 | 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: 1.22.5 20 | 21 | - name: Install dependencies 22 | run: make install-deps 23 | 24 | - name: Build 25 | run: make 26 | 27 | - name: Go test 28 | run: go test -v ./ 29 | 30 | - name: Run Journalist in background 31 | run: | 32 | ./journalist & 33 | 34 | - name: Run integration tests 35 | env: 36 | JOURNALIST_API_URL: http://127.0.0.1:8000/api/v1 37 | run: | 38 | ./test.sh true 39 | 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | /journalist 18 | .DS_Store 19 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # .goreleaser.yaml 2 | version: 2 3 | builds: 4 | - 5 | goos: 6 | - darwin 7 | - dragonfly 8 | - freebsd 9 | - linux 10 | - netbsd 11 | - openbsd 12 | # - plan9 13 | - windows 14 | goarch: 15 | - 386 16 | - amd64 17 | - arm 18 | - arm64 19 | - ppc64 20 | - ppc64le 21 | - riscv64 22 | goarm: 23 | - 5 24 | - 6 25 | - 7 26 | ignore: 27 | - goos: darwin 28 | goarch: 386 29 | - goos: darwin 30 | goarch: arm 31 | - goos: darwin 32 | goarch: ppc64 33 | - goos: darwin 34 | goarch: ppc64le 35 | - goos: darwin 36 | goarch: riscv64 37 | 38 | - goos: dragonfly 39 | goarch: 386 40 | - goos: dragonfly 41 | goarch: arm 42 | - goos: dragonfly 43 | goarch: arm64 44 | - goos: dragonfly 45 | goarch: ppc64 46 | - goos: dragonfly 47 | goarch: ppc64le 48 | - goos: dragonfly 49 | goarch: riscv64 50 | 51 | - goos: freebsd 52 | goarm: arm64 53 | - goos: freebsd 54 | goarm: ppc64 55 | - goos: freebsd 56 | goarm: ppc64le 57 | - goos: freebsd 58 | goarm: riscv64 59 | 60 | - goos: netbsd 61 | goarch: arm64 62 | - goos: netbsd 63 | goarch: ppc64 64 | - goos: netbsd 65 | goarch: ppc64le 66 | - goos: netbsd 67 | goarch: riscv64 68 | 69 | #- goos: plan9 70 | # goarm: arm64 71 | #- goos: plan9 72 | # goarm: ppc64 73 | #- goos: plan9 74 | # goarm: ppc64le 75 | #- goos: plan9 76 | # goarm: riscv64 77 | 78 | - goos: windows 79 | goarm: ppc64 80 | - goos: windows 81 | goarm: ppc64le 82 | - goos: windows 83 | goarm: riscv64 84 | 85 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG ARCH= 2 | FROM ${ARCH}golang:alpine AS builder 3 | 4 | WORKDIR /go/src/app 5 | COPY . . 6 | 7 | RUN apk add --update-cache build-base \ 8 | && go build 9 | 10 | FROM ${ARCH}alpine:latest AS container 11 | 12 | COPY --from=builder /go/src/app/journalist /usr/bin/journalist 13 | 14 | CMD ["journalist"] 15 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: ent swagger build install-deps install-dep-ent install-dep-swag 2 | VERSION := $(shell git describe --tags) 3 | 4 | all: ent swagger build 5 | 6 | ent: 7 | go generate ./ent 8 | 9 | swagger: 10 | swag init -g api/api.go 11 | 12 | build: 13 | go build -ldflags "-X github.com/mrusme/journalist/journalistd.VERSION=$(VERSION)" 14 | 15 | install-deps: install-dep-ent install-dep-swag 16 | 17 | install-dep-ent: 18 | go install entgo.io/ent/cmd/ent@latest 19 | 20 | install-dep-swag: 21 | go install github.com/swaggo/swag/cmd/swag@latest 22 | 23 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal 2 | -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/base64" 5 | "strings" 6 | 7 | "context" 8 | 9 | "github.com/gofiber/fiber/v2" 10 | "github.com/gofiber/fiber/v2/middleware/cors" 11 | "github.com/gofiber/fiber/v2/utils" 12 | v1 "github.com/mrusme/journalist/api/v1" 13 | "github.com/mrusme/journalist/ent" 14 | "github.com/mrusme/journalist/ent/user" 15 | "github.com/mrusme/journalist/lib" 16 | ) 17 | 18 | // @title Journalist API 19 | // @version 1.0 20 | // @description The Journalist REST API v1 21 | 22 | // @contact.name Marius 23 | // @contact.url https://xn--gckvb8fzb.com 24 | // @contact.email marius@xn--gckvb8fzb.com 25 | 26 | // @license.name GPL-3.0 27 | // @license.url https://github.com/mrusme/journalist/blob/master/LICENSE 28 | 29 | // @host localhost:8000 30 | // @BasePath /api/v1 31 | // @accept json 32 | // @produce json 33 | // @schemes http 34 | // @securityDefinitions.basic BasicAuth 35 | func Register( 36 | jctx *lib.JournalistContext, 37 | fiberApp *fiber.App, 38 | ) { 39 | api := fiberApp.Group("/api") 40 | api.Use(cors.New()) 41 | api.Use(authorizer(jctx.EntClient)) 42 | 43 | v1.Register( 44 | jctx, 45 | &api, 46 | ) 47 | } 48 | 49 | // TODO: Move to `middlewares` 50 | func authorizer(entClient *ent.Client) fiber.Handler { 51 | return func(ctx *fiber.Ctx) error { 52 | auth := ctx.Get(fiber.HeaderAuthorization) 53 | 54 | if len(auth) <= 6 || strings.ToLower(auth[:5]) != "basic" { 55 | return ctx.SendStatus(fiber.StatusUnauthorized) 56 | } 57 | 58 | raw, err := base64.StdEncoding.DecodeString(auth[6:]) 59 | if err != nil { 60 | return ctx.SendStatus(fiber.StatusUnauthorized) 61 | } 62 | 63 | creds := utils.UnsafeString(raw) 64 | 65 | index := strings.Index(creds, ":") 66 | if index == -1 { 67 | return ctx.SendStatus(fiber.StatusUnauthorized) 68 | } 69 | 70 | username := creds[:index] 71 | password := creds[index+1:] 72 | 73 | u, err := entClient.User. 74 | Query(). 75 | Where(user.Username(username)). 76 | Only(context.Background()) 77 | if err != nil { 78 | return ctx.SendStatus(fiber.StatusUnauthorized) 79 | } 80 | 81 | if u.Password != password { 82 | return ctx.SendStatus(fiber.StatusUnauthorized) 83 | } 84 | 85 | ctx.Locals("user_id", u.ID.String()) 86 | ctx.Locals("username", u.Username) 87 | // ctx.Locals("password", u.Password) 88 | ctx.Locals("role", u.Role) 89 | return ctx.Next() 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /api/v1/feeds/create.go: -------------------------------------------------------------------------------- 1 | package feeds 2 | 3 | import ( 4 | // "strings" 5 | "context" 6 | 7 | "github.com/go-playground/validator/v10" 8 | "github.com/google/uuid" 9 | 10 | "github.com/gofiber/fiber/v2" 11 | // "github.com/mrusme/journalist/ent/user" 12 | // "github.com/mrusme/journalist/ent" 13 | 14 | "github.com/mrusme/journalist/crawler" 15 | "github.com/mrusme/journalist/rss" 16 | 17 | "go.uber.org/zap" 18 | ) 19 | 20 | type FeedCreateResponse struct { 21 | Success bool `json:"success"` 22 | Feed *FeedShowModel `json:"feed"` 23 | Message string `json:"message"` 24 | } 25 | 26 | // Create godoc 27 | // @Summary Create a feed 28 | // @Description Add a new feed 29 | // @Tags feeds 30 | // @Accept json 31 | // @Produce json 32 | // @Param feed body FeedCreateModel true "Add feed" 33 | // @Success 200 {object} FeedCreateResponse 34 | // @Failure 400 {object} FeedCreateResponse 35 | // @Failure 404 {object} FeedCreateResponse 36 | // @Failure 500 {object} FeedCreateResponse 37 | // @Router /feeds [post] 38 | // @security BasicAuth 39 | func (h *handler) Create(ctx *fiber.Ctx) error { 40 | var err error 41 | 42 | // sessionId := ctx.Locals("user_id").(string) 43 | // sessionRole := ctx.Locals("role").(string) 44 | 45 | createFeed := new(FeedCreateModel) 46 | if err = ctx.BodyParser(createFeed); err != nil { 47 | h.logger.Debug( 48 | "Body parsing failed", 49 | zap.Error(err), 50 | ) 51 | return ctx. 52 | Status(fiber.StatusInternalServerError). 53 | JSON(FeedCreateResponse{ 54 | Success: false, 55 | Feed: nil, 56 | Message: err.Error(), 57 | }) 58 | } 59 | 60 | validate := validator.New() 61 | if err = validate.Struct(*createFeed); err != nil { 62 | h.logger.Debug( 63 | "Validation failed", 64 | zap.Error(err), 65 | ) 66 | return ctx. 67 | Status(fiber.StatusBadRequest). 68 | JSON(FeedCreateResponse{ 69 | Success: false, 70 | Feed: nil, 71 | Message: err.Error(), 72 | }) 73 | } 74 | 75 | crwlr := crawler.New(h.logger) 76 | defer crwlr.Close() 77 | 78 | crwlr.SetLocation(createFeed.URL) 79 | 80 | if createFeed.Username != "" && createFeed.Password != "" { 81 | crwlr.SetBasicAuth(createFeed.Username, createFeed.Password) 82 | } 83 | 84 | _, feedLink, err := crwlr.GetFeedLink() 85 | if err != nil { 86 | h.logger.Debug( 87 | "Could not get feed link", 88 | zap.Error(err), 89 | ) 90 | return ctx. 91 | Status(fiber.StatusBadRequest). 92 | JSON(FeedCreateResponse{ 93 | Success: false, 94 | Feed: nil, 95 | Message: err.Error(), 96 | }) 97 | } 98 | 99 | rc, errr := rss.NewClient( 100 | feedLink, 101 | createFeed.Username, 102 | createFeed.Password, 103 | false, 104 | []string{}, 105 | h.logger, 106 | ) 107 | if len(errr) > 0 { 108 | h.logger.Debug( 109 | "Could not fetch feed", 110 | zap.Error(err), 111 | ) 112 | return ctx. 113 | Status(fiber.StatusInternalServerError). 114 | JSON(FeedCreateResponse{ 115 | Success: false, 116 | Feed: nil, 117 | Message: err.Error(), 118 | }) 119 | } 120 | 121 | dbFeedTmp := h.entClient.Feed. 122 | Create() 123 | 124 | dbFeedTmp = rc.SetFeed( 125 | feedLink, 126 | createFeed.Username, 127 | createFeed.Password, 128 | dbFeedTmp, 129 | ) 130 | feedId, err := dbFeedTmp. 131 | OnConflictColumns("url", "username", "password"). 132 | UpdateNewValues(). 133 | ID(context.Background()) 134 | if err != nil { 135 | h.logger.Debug( 136 | "Could not upsert feed", 137 | zap.Error(err), 138 | ) 139 | return ctx. 140 | Status(fiber.StatusInternalServerError). 141 | JSON(FeedCreateResponse{ 142 | Success: false, 143 | Feed: nil, 144 | Message: err.Error(), 145 | }) 146 | } 147 | 148 | sessionUserId := ctx.Locals("user_id").(string) 149 | myId, err := uuid.Parse(sessionUserId) 150 | if err != nil { 151 | h.logger.Debug( 152 | "Could not parse user ID", 153 | zap.Error(err), 154 | ) 155 | return ctx. 156 | Status(fiber.StatusInternalServerError). 157 | JSON(FeedCreateResponse{ 158 | Success: false, 159 | Feed: nil, 160 | Message: err.Error(), 161 | }) 162 | } 163 | 164 | dbSubscriptionTmp := h.entClient.Subscription. 165 | Create(). 166 | SetUserID(myId). 167 | SetFeedID(feedId) 168 | 169 | if createFeed.Name != "" { 170 | dbSubscriptionTmp = dbSubscriptionTmp. 171 | SetName(createFeed.Name) 172 | } 173 | 174 | if createFeed.Group != "" { 175 | dbSubscriptionTmp = dbSubscriptionTmp. 176 | SetGroup(createFeed.Group) 177 | } 178 | 179 | dbSubscription, err := dbSubscriptionTmp. 180 | Save(context.Background()) 181 | if err != nil { 182 | h.logger.Debug( 183 | "Could not add feed subscription", 184 | zap.Error(err), 185 | ) 186 | return ctx. 187 | Status(fiber.StatusInternalServerError). 188 | JSON(FeedCreateResponse{ 189 | Success: false, 190 | Feed: nil, 191 | Message: err.Error(), 192 | }) 193 | } 194 | 195 | showFeed := FeedShowModel{ 196 | ID: feedId.String(), 197 | Name: dbSubscription.Name, 198 | URL: createFeed.URL, 199 | Group: dbSubscription.Group, 200 | } 201 | 202 | return ctx. 203 | Status(fiber.StatusOK). 204 | JSON(FeedCreateResponse{ 205 | Success: true, 206 | Feed: &showFeed, 207 | Message: "", 208 | }) 209 | } 210 | -------------------------------------------------------------------------------- /api/v1/feeds/feeds.go: -------------------------------------------------------------------------------- 1 | package feeds 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/mrusme/journalist/ent" 6 | "github.com/mrusme/journalist/lib" 7 | "go.uber.org/zap" 8 | ) 9 | 10 | type handler struct { 11 | jctx *lib.JournalistContext 12 | 13 | config *lib.Config 14 | entClient *ent.Client 15 | logger *zap.Logger 16 | } 17 | 18 | type FeedShowModel struct { 19 | ID string `json:"id"` 20 | Name string `json:"name,omitempty" validate:"omitempty,max=32"` 21 | URL string `json:"url"` 22 | Group string `json:"group,omitempty" validate:"omitempty,max=32"` 23 | } 24 | 25 | type FeedCreateModel struct { 26 | Name string `json:"name,omitempty" validate:"omitempty,max=32"` 27 | URL string `json:"url" validate:"required,url"` 28 | Username string `json:"username,omitempty" validate:"omitempty,required_with=password"` 29 | Password string `json:"password,omitempty" validate:"omitempty,required_with=username"` 30 | Group string `json:"group,omitempty" validate:"omitempty,max=32"` 31 | } 32 | 33 | /* type FeedUpdateModel struct { 34 | Password string `json:"password,omitempty" validate:"omitempty,min=5"` 35 | } */ 36 | 37 | func Register( 38 | jctx *lib.JournalistContext, 39 | fiberRouter *fiber.Router, 40 | ) { 41 | endpoint := new(handler) 42 | endpoint.jctx = jctx 43 | endpoint.config = endpoint.jctx.Config 44 | endpoint.entClient = endpoint.jctx.EntClient 45 | endpoint.logger = endpoint.jctx.Logger 46 | 47 | feedsRouter := (*fiberRouter).Group("/feeds") 48 | feedsRouter.Get("/", endpoint.List) 49 | feedsRouter.Get("/:id", endpoint.Show) 50 | feedsRouter.Post("/", endpoint.Create) 51 | // feedsRouter.Put("/:id", endpoint.Update) 52 | // feedsRouter.Delete("/:id", endpoint.Destroy) 53 | } 54 | -------------------------------------------------------------------------------- /api/v1/feeds/list.go: -------------------------------------------------------------------------------- 1 | package feeds 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | 8 | "github.com/gofiber/fiber/v2" 9 | "github.com/mrusme/journalist/ent/user" 10 | 11 | // "github.com/mrusme/journalist/ent" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | type FeedListResponse struct { 16 | Success bool `json:"success"` 17 | Feeds *[]FeedShowModel `json:"feeds"` 18 | Message string `json:"message"` 19 | } 20 | 21 | // List godoc 22 | // @Summary List feeds 23 | // @Description Get all feeds 24 | // @Tags feeds 25 | // @Accept json 26 | // @Produce json 27 | // @Success 200 {object} FeedListResponse 28 | // @Failure 400 {object} FeedListResponse 29 | // @Failure 404 {object} FeedListResponse 30 | // @Failure 500 {object} FeedListResponse 31 | // @Router /feeds [get] 32 | // @security BasicAuth 33 | func (h *handler) List(ctx *fiber.Ctx) error { 34 | var showFeeds []FeedShowModel 35 | 36 | role := ctx.Locals("role").(string) 37 | 38 | if role == "admin" { 39 | dbFeeds, err := h.entClient.Feed. 40 | Query(). 41 | All(context.Background()) 42 | if err != nil { 43 | h.logger.Debug( 44 | "Could not query all feeds", 45 | zap.Error(err), 46 | ) 47 | return ctx. 48 | Status(fiber.StatusInternalServerError). 49 | JSON(FeedListResponse{ 50 | Success: false, 51 | Feeds: nil, 52 | Message: err.Error(), 53 | }) 54 | } 55 | 56 | showFeeds = make([]FeedShowModel, len(dbFeeds)) 57 | 58 | for i, dbFeed := range dbFeeds { 59 | showFeeds[i] = FeedShowModel{ 60 | ID: dbFeed.ID.String(), 61 | Name: dbFeed.FeedTitle, 62 | URL: dbFeed.FeedFeedLink, 63 | Group: "*", 64 | } 65 | } 66 | } else { 67 | sessionUserId := ctx.Locals("user_id").(string) 68 | myId, err := uuid.Parse(sessionUserId) 69 | if err != nil { 70 | h.logger.Debug( 71 | "Could not parse user ID", 72 | zap.Error(err), 73 | ) 74 | return ctx. 75 | Status(fiber.StatusInternalServerError). 76 | JSON(FeedListResponse{ 77 | Success: false, 78 | Feeds: nil, 79 | Message: err.Error(), 80 | }) 81 | } 82 | 83 | dbUser, err := h.entClient.User. 84 | Query(). 85 | WithSubscribedFeeds(). 86 | WithSubscriptions(). 87 | Where( 88 | user.ID(myId), 89 | ). 90 | Only(context.Background()) 91 | 92 | for i, feed := range dbUser.Edges.SubscribedFeeds { 93 | showFeeds = append(showFeeds, FeedShowModel{ 94 | ID: feed.ID.String(), 95 | Name: dbUser.Edges.Subscriptions[i].Name, 96 | URL: feed.URL, 97 | Group: dbUser.Edges.Subscriptions[i].Group, 98 | }) 99 | } 100 | } 101 | 102 | return ctx. 103 | Status(fiber.StatusOK). 104 | JSON(FeedListResponse{ 105 | Success: true, 106 | Feeds: &showFeeds, 107 | Message: "", 108 | }) 109 | } 110 | -------------------------------------------------------------------------------- /api/v1/feeds/show.go: -------------------------------------------------------------------------------- 1 | package feeds 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | 8 | "github.com/gofiber/fiber/v2" 9 | "github.com/mrusme/journalist/ent/feed" 10 | 11 | // "github.com/mrusme/journalist/ent" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | type FeedShowResponse struct { 16 | Success bool `json:"success"` 17 | Feed *FeedShowModel `json:"feed"` 18 | Message string `json:"message"` 19 | } 20 | 21 | // Show godoc 22 | // @Summary Show a feed 23 | // @Description Get feed by ID 24 | // @Tags feeds 25 | // @Accept json 26 | // @Produce json 27 | // @Param id path string true "Feed ID" 28 | // @Success 200 {object} FeedShowResponse 29 | // @Failure 400 {object} FeedShowResponse 30 | // @Failure 404 {object} FeedShowResponse 31 | // @Failure 500 {object} FeedShowResponse 32 | // @Router /feeds/{id} [get] 33 | // @security BasicAuth 34 | func (h *handler) Show(ctx *fiber.Ctx) error { 35 | var err error 36 | 37 | param_id := ctx.Params("id") 38 | id, err := uuid.Parse(param_id) 39 | if err != nil { 40 | h.logger.Debug( 41 | "Could not parse user ID", 42 | zap.Error(err), 43 | ) 44 | return ctx. 45 | Status(fiber.StatusBadRequest). 46 | JSON(FeedShowResponse{ 47 | Success: false, 48 | Feed: nil, 49 | Message: err.Error(), 50 | }) 51 | } 52 | 53 | dbFeed, err := h.entClient.Feed. 54 | Query(). 55 | Where( 56 | feed.ID(id), 57 | ). 58 | Only(context.Background()) 59 | if err != nil { 60 | h.logger.Debug( 61 | "Could not query feed", 62 | zap.String("feedID", param_id), 63 | zap.Error(err), 64 | ) 65 | return ctx. 66 | Status(fiber.StatusInternalServerError). 67 | JSON(FeedShowResponse{ 68 | Success: false, 69 | Feed: nil, 70 | Message: err.Error(), 71 | }) 72 | } 73 | 74 | // TODO: Check if user is subscribed to feed. Unless it's an admin, the should 75 | // not be able to query feeds that they're not subscribed to. 76 | // role := ctx.Locals("role").(string) 77 | // if param_id != feed_id && role != "admin" { 78 | // h.logger.Debug( 79 | // "User not allowed to see other feeds", 80 | // zap.Error(err), 81 | // ) 82 | // return ctx. 83 | // Status(fiber.StatusForbidden). 84 | // JSON(FeedShowResponse{ 85 | // Success: false, 86 | // Feed: nil, 87 | // Message: "Only admins are allowed to see other feeds", 88 | // }) 89 | // } 90 | 91 | showFeed := FeedShowModel{ 92 | ID: dbFeed.ID.String(), 93 | Name: dbFeed.FeedTitle, 94 | URL: dbFeed.FeedFeedLink, 95 | Group: "*", 96 | } 97 | 98 | return ctx. 99 | Status(fiber.StatusOK). 100 | JSON(FeedShowResponse{ 101 | Success: true, 102 | Feed: &showFeed, 103 | Message: "", 104 | }) 105 | } 106 | -------------------------------------------------------------------------------- /api/v1/helpers.go: -------------------------------------------------------------------------------- 1 | package v1 2 | -------------------------------------------------------------------------------- /api/v1/tokens/create.go: -------------------------------------------------------------------------------- 1 | package tokens 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/go-playground/validator/v10" 9 | "github.com/google/uuid" 10 | "github.com/mrusme/journalist/rss" 11 | "go.uber.org/zap" 12 | 13 | "github.com/gofiber/fiber/v2" 14 | // "github.com/mrusme/journalist/ent/token" 15 | // "github.com/mrusme/journalist/ent" 16 | ) 17 | 18 | type TokenCreateResponse struct { 19 | Success bool `json:"success"` 20 | Token *TokenShowModel `json:"token"` 21 | Message string `json:"message"` 22 | } 23 | 24 | // Create godoc 25 | // @Summary Create a token 26 | // @Description Add a new token 27 | // @Tags tokens 28 | // @Accept json 29 | // @Produce json 30 | // @Param token body TokenCreateModel true "Add token" 31 | // @Success 200 {object} TokenCreateResponse 32 | // @Failure 400 {object} TokenCreateResponse 33 | // @Failure 404 {object} TokenCreateResponse 34 | // @Failure 500 {object} TokenCreateResponse 35 | // @Router /tokens [post] 36 | // @security BasicAuth 37 | func (h *handler) Create(ctx *fiber.Ctx) error { 38 | var err error 39 | 40 | createToken := new(TokenCreateModel) 41 | if err = ctx.BodyParser(createToken); err != nil { 42 | h.logger.Debug( 43 | "Body parsing failed", 44 | zap.Error(err), 45 | ) 46 | return ctx. 47 | Status(fiber.StatusInternalServerError). 48 | JSON(TokenCreateResponse{ 49 | Success: false, 50 | Token: nil, 51 | Message: err.Error(), 52 | }) 53 | } 54 | 55 | validate := validator.New() 56 | if err = validate.Struct(*createToken); err != nil { 57 | h.logger.Debug( 58 | "Validation failed", 59 | zap.Error(err), 60 | ) 61 | return ctx. 62 | Status(fiber.StatusBadRequest). 63 | JSON(TokenCreateResponse{ 64 | Success: false, 65 | Token: nil, 66 | Message: err.Error(), 67 | }) 68 | } 69 | 70 | sessionUserId := ctx.Locals("user_id").(string) 71 | myId, err := uuid.Parse(sessionUserId) 72 | if err != nil { 73 | h.logger.Debug( 74 | "Could not parse user ID", 75 | zap.Error(err), 76 | ) 77 | return ctx. 78 | Status(fiber.StatusInternalServerError). 79 | JSON(TokenCreateResponse{ 80 | Success: false, 81 | Token: nil, 82 | Message: err.Error(), 83 | }) 84 | } 85 | 86 | // TODO: Move GenerateGUID to a common helper, rename 87 | token := rss.GenerateGUID( 88 | fmt.Sprintf( 89 | "%s-%d", 90 | sessionUserId, 91 | time.Now().UnixNano(), 92 | ), 93 | ) 94 | 95 | dbToken, err := h.entClient.Token. 96 | Create(). 97 | SetType("qat"). 98 | SetName(createToken.Name). 99 | SetToken(token). 100 | Save(context.Background()) 101 | 102 | if err != nil { 103 | h.logger.Debug( 104 | "Could create token", 105 | zap.Error(err), 106 | ) 107 | return ctx. 108 | Status(fiber.StatusInternalServerError). 109 | JSON(TokenCreateResponse{ 110 | Success: false, 111 | Token: nil, 112 | Message: err.Error(), 113 | }) 114 | } 115 | 116 | _, err = h.entClient.User. 117 | UpdateOneID(myId). 118 | AddTokenIDs(dbToken.ID). 119 | Save(context.Background()) 120 | 121 | if err != nil { 122 | h.logger.Debug( 123 | "Could not add new token to user", 124 | zap.Error(err), 125 | ) 126 | return ctx. 127 | Status(fiber.StatusInternalServerError). 128 | JSON(TokenCreateResponse{ 129 | Success: false, 130 | Token: nil, 131 | Message: err.Error(), 132 | }) 133 | } 134 | 135 | showToken := TokenShowModel{ 136 | ID: dbToken.ID.String(), 137 | Type: dbToken.Type, 138 | Name: dbToken.Name, 139 | Token: dbToken.Token, 140 | } 141 | 142 | return ctx. 143 | Status(fiber.StatusOK). 144 | JSON(TokenCreateResponse{ 145 | Success: true, 146 | Token: &showToken, 147 | Message: "", 148 | }) 149 | } 150 | -------------------------------------------------------------------------------- /api/v1/tokens/tokens.go: -------------------------------------------------------------------------------- 1 | package tokens 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/mrusme/journalist/ent" 6 | "github.com/mrusme/journalist/lib" 7 | "go.uber.org/zap" 8 | ) 9 | 10 | type handler struct { 11 | jctx *lib.JournalistContext 12 | config *lib.Config 13 | entClient *ent.Client 14 | logger *zap.Logger 15 | } 16 | 17 | type TokenShowModel struct { 18 | ID string `json:"id"` 19 | Type string `json:"type"` 20 | Name string `json:"tokenname"` 21 | Token string `json:"token"` 22 | } 23 | 24 | type TokenCreateModel struct { 25 | Name string `json:"name" validate:"required,alphanum,max=32"` 26 | } 27 | 28 | func Register( 29 | jctx *lib.JournalistContext, 30 | fiberRouter *fiber.Router, 31 | ) { 32 | endpoint := new(handler) 33 | endpoint.jctx = jctx 34 | endpoint.config = endpoint.jctx.Config 35 | endpoint.entClient = endpoint.jctx.EntClient 36 | endpoint.logger = endpoint.jctx.Logger 37 | 38 | tokensRouter := (*fiberRouter).Group("/tokens") 39 | // tokensRouter.Get("/", endpoint.List) 40 | // tokensRouter.Get("/:id", endpoint.Show) 41 | tokensRouter.Post("/", endpoint.Create) 42 | // tokensRouter.Put("/:id", endpoint.Update) 43 | // tokensRouter.Delete("/:id", endpoint.Destroy) 44 | } 45 | -------------------------------------------------------------------------------- /api/v1/users/create.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | // "github.com/google/uuid" 6 | "github.com/go-playground/validator/v10" 7 | "go.uber.org/zap" 8 | 9 | "github.com/gofiber/fiber/v2" 10 | // "github.com/mrusme/journalist/ent/user" 11 | // "github.com/mrusme/journalist/ent" 12 | ) 13 | 14 | type UserCreateResponse struct { 15 | Success bool `json:"success"` 16 | User *UserShowModel `json:"user"` 17 | Message string `json:"message"` 18 | } 19 | 20 | // Create godoc 21 | // @Summary Create a user 22 | // @Description Add a new user 23 | // @Tags users 24 | // @Accept json 25 | // @Produce json 26 | // @Param user body UserCreateModel true "Add user" 27 | // @Success 200 {object} UserCreateResponse 28 | // @Failure 400 {object} UserCreateResponse 29 | // @Failure 404 {object} UserCreateResponse 30 | // @Failure 500 {object} UserCreateResponse 31 | // @Router /users [post] 32 | // @security BasicAuth 33 | func (h *handler) Create(ctx *fiber.Ctx) error { 34 | var err error 35 | 36 | role := ctx.Locals("role").(string) 37 | 38 | if role != "admin" { 39 | h.logger.Debug( 40 | "User not allowed to create users", 41 | zap.Error(err), 42 | ) 43 | return ctx. 44 | Status(fiber.StatusForbidden). 45 | JSON(UserCreateResponse{ 46 | Success: false, 47 | User: nil, 48 | Message: "Only admins are allowed to create users", 49 | }) 50 | } 51 | 52 | createUser := new(UserCreateModel) 53 | if err = ctx.BodyParser(createUser); err != nil { 54 | h.logger.Debug( 55 | "Body parsing failed", 56 | zap.Error(err), 57 | ) 58 | return ctx. 59 | Status(fiber.StatusInternalServerError). 60 | JSON(UserCreateResponse{ 61 | Success: false, 62 | User: nil, 63 | Message: err.Error(), 64 | }) 65 | } 66 | 67 | validate := validator.New() 68 | if err = validate.Struct(*createUser); err != nil { 69 | h.logger.Debug( 70 | "Validation failed", 71 | zap.Error(err), 72 | ) 73 | return ctx. 74 | Status(fiber.StatusBadRequest). 75 | JSON(UserCreateResponse{ 76 | Success: false, 77 | User: nil, 78 | Message: err.Error(), 79 | }) 80 | } 81 | 82 | dbUser, err := h.entClient.User. 83 | Create(). 84 | SetUsername(createUser.Username). 85 | SetPassword(createUser.Password). 86 | SetRole(createUser.Role). 87 | Save(context.Background()) 88 | 89 | if err != nil { 90 | h.logger.Debug( 91 | "Could not create user", 92 | zap.Error(err), 93 | ) 94 | return ctx. 95 | Status(fiber.StatusInternalServerError). 96 | JSON(UserCreateResponse{ 97 | Success: false, 98 | User: nil, 99 | Message: err.Error(), 100 | }) 101 | } 102 | 103 | showUser := UserShowModel{ 104 | ID: dbUser.ID.String(), 105 | Username: dbUser.Username, 106 | Role: dbUser.Role, 107 | } 108 | 109 | return ctx. 110 | Status(fiber.StatusOK). 111 | JSON(UserCreateResponse{ 112 | Success: true, 113 | User: &showUser, 114 | Message: "", 115 | }) 116 | } 117 | -------------------------------------------------------------------------------- /api/v1/users/list.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | // "github.com/google/uuid" 6 | 7 | "github.com/gofiber/fiber/v2" 8 | "go.uber.org/zap" 9 | // "github.com/mrusme/journalist/ent/user" 10 | // "github.com/mrusme/journalist/ent" 11 | ) 12 | 13 | type UserListResponse struct { 14 | Success bool `json:"success"` 15 | Users *[]UserShowModel `json:"users"` 16 | Message string `json:"message"` 17 | } 18 | 19 | // List godoc 20 | // @Summary List users 21 | // @Description Get all users 22 | // @Tags users 23 | // @Accept json 24 | // @Produce json 25 | // @Success 200 {object} UserListResponse 26 | // @Failure 400 {object} UserListResponse 27 | // @Failure 404 {object} UserListResponse 28 | // @Failure 500 {object} UserListResponse 29 | // @Router /users [get] 30 | // @security BasicAuth 31 | func (h *handler) List(ctx *fiber.Ctx) error { 32 | var err error 33 | 34 | role := ctx.Locals("role").(string) 35 | 36 | if role != "admin" { 37 | h.logger.Debug( 38 | "User not allowed to list users", 39 | zap.Error(err), 40 | ) 41 | return ctx. 42 | Status(fiber.StatusForbidden). 43 | JSON(UserListResponse{ 44 | Success: false, 45 | Users: nil, 46 | Message: "Only admins are allowed to list users", 47 | }) 48 | } 49 | 50 | dbUsers, err := h.entClient.User. 51 | Query(). 52 | All(context.Background()) 53 | if err != nil { 54 | return ctx. 55 | Status(fiber.StatusInternalServerError). 56 | JSON(UserListResponse{ 57 | Success: false, 58 | Users: nil, 59 | Message: err.Error(), 60 | }) 61 | } 62 | 63 | showUsers := make([]UserShowModel, len(dbUsers)) 64 | 65 | for i, dbUser := range dbUsers { 66 | showUsers[i] = UserShowModel{ 67 | ID: dbUser.ID.String(), 68 | Username: dbUser.Username, 69 | Role: dbUser.Role, 70 | } 71 | } 72 | 73 | return ctx. 74 | Status(fiber.StatusOK). 75 | JSON(UserListResponse{ 76 | Success: true, 77 | Users: &showUsers, 78 | Message: "", 79 | }) 80 | } 81 | -------------------------------------------------------------------------------- /api/v1/users/show.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | 8 | "github.com/gofiber/fiber/v2" 9 | "github.com/mrusme/journalist/ent/user" 10 | // "github.com/mrusme/journalist/ent" 11 | ) 12 | 13 | type UserShowResponse struct { 14 | Success bool `json:"success"` 15 | User *UserShowModel `json:"user"` 16 | Message string `json:"message"` 17 | } 18 | 19 | // Show godoc 20 | // @Summary Show a user 21 | // @Description Get user by ID 22 | // @Tags users 23 | // @Accept json 24 | // @Produce json 25 | // @Param id path string true "User ID" 26 | // @Success 200 {object} UserShowResponse 27 | // @Failure 400 {object} UserShowResponse 28 | // @Failure 404 {object} UserShowResponse 29 | // @Failure 500 {object} UserShowResponse 30 | // @Router /users/{id} [get] 31 | // @security BasicAuth 32 | func (h *handler) Show(ctx *fiber.Ctx) error { 33 | var err error 34 | 35 | param_id := ctx.Params("id") 36 | id, err := uuid.Parse(param_id) 37 | if err != nil { 38 | return ctx. 39 | Status(fiber.StatusBadRequest). 40 | JSON(UserShowResponse{ 41 | Success: false, 42 | User: nil, 43 | Message: err.Error(), 44 | }) 45 | } 46 | 47 | user_id := ctx.Locals("user_id").(string) 48 | role := ctx.Locals("role").(string) 49 | 50 | if param_id != user_id && role != "admin" { 51 | return ctx. 52 | Status(fiber.StatusForbidden). 53 | JSON(UserShowResponse{ 54 | Success: false, 55 | User: nil, 56 | Message: "Only admins are allowed to see other users", 57 | }) 58 | } 59 | 60 | dbUser, err := h.entClient.User. 61 | Query(). 62 | Where( 63 | user.ID(id), 64 | ). 65 | Only(context.Background()) 66 | if err != nil { 67 | return ctx. 68 | Status(fiber.StatusInternalServerError). 69 | JSON(UserShowResponse{ 70 | Success: false, 71 | User: nil, 72 | Message: err.Error(), 73 | }) 74 | } 75 | 76 | showUser := UserShowModel{ 77 | ID: dbUser.ID.String(), 78 | Username: dbUser.Username, 79 | Role: dbUser.Role, 80 | } 81 | 82 | return ctx. 83 | Status(fiber.StatusOK). 84 | JSON(UserShowResponse{ 85 | Success: true, 86 | User: &showUser, 87 | Message: "", 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /api/v1/users/update.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/go-playground/validator/v10" 7 | "github.com/google/uuid" 8 | 9 | "github.com/gofiber/fiber/v2" 10 | // "github.com/mrusme/journalist/ent/user" 11 | // "github.com/mrusme/journalist/ent" 12 | ) 13 | 14 | type UserUpdateResponse struct { 15 | Success bool `json:"success"` 16 | User *UserShowModel `json:"user"` 17 | Message string `json:"message"` 18 | } 19 | 20 | // Update godoc 21 | // @Summary Update a user 22 | // @Description Change an existing user 23 | // @Tags users 24 | // @Accept json 25 | // @Produce json 26 | // @Param id path string true "User ID" 27 | // @Param user body UserUpdateModel true "Change user" 28 | // @Success 200 {object} UserUpdateResponse 29 | // @Failure 400 {object} UserUpdateResponse 30 | // @Failure 404 {object} UserUpdateResponse 31 | // @Failure 500 {object} UserUpdateResponse 32 | // @Router /users/{id} [put] 33 | // @security BasicAuth 34 | func (h *handler) Update(ctx *fiber.Ctx) error { 35 | var err error 36 | 37 | param_id := ctx.Params("id") 38 | id, err := uuid.Parse(param_id) 39 | if err != nil { 40 | return ctx. 41 | Status(fiber.StatusBadRequest). 42 | JSON(UserUpdateResponse{ 43 | Success: false, 44 | User: nil, 45 | Message: err.Error(), 46 | }) 47 | } 48 | 49 | user_id := ctx.Locals("user_id").(string) 50 | role := ctx.Locals("role").(string) 51 | 52 | if param_id != user_id && role != "admin" { 53 | return ctx. 54 | Status(fiber.StatusForbidden). 55 | JSON(UserUpdateResponse{ 56 | Success: false, 57 | User: nil, 58 | Message: "Only admins are allowed to update other users", 59 | }) 60 | } 61 | 62 | updateUser := new(UserUpdateModel) 63 | if err = ctx.BodyParser(updateUser); err != nil { 64 | return ctx. 65 | Status(fiber.StatusInternalServerError). 66 | JSON(UserUpdateResponse{ 67 | Success: false, 68 | User: nil, 69 | Message: err.Error(), 70 | }) 71 | } 72 | 73 | validate := validator.New() 74 | if err = validate.Struct(*updateUser); err != nil { 75 | return ctx. 76 | Status(fiber.StatusBadRequest). 77 | JSON(UserUpdateResponse{ 78 | Success: false, 79 | User: nil, 80 | Message: err.Error(), 81 | }) 82 | } 83 | 84 | dbUserTmp := h.entClient.User. 85 | UpdateOneID(id) 86 | 87 | if updateUser.Role != "" { 88 | if role == "admin" { 89 | dbUserTmp = dbUserTmp.SetRole(updateUser.Role) 90 | } else { 91 | return ctx. 92 | Status(fiber.StatusForbidden). 93 | JSON(UserUpdateResponse{ 94 | Success: false, 95 | User: nil, 96 | Message: "Only admins are allowed to update roles", 97 | }) 98 | } 99 | } 100 | 101 | if updateUser.Password != "" { 102 | dbUserTmp = dbUserTmp. 103 | SetPassword(updateUser.Password) 104 | } 105 | 106 | dbUser, err := dbUserTmp.Save(context.Background()) 107 | 108 | if err != nil { 109 | return ctx. 110 | Status(fiber.StatusInternalServerError). 111 | JSON(UserUpdateResponse{ 112 | Success: false, 113 | User: nil, 114 | Message: err.Error(), 115 | }) 116 | } 117 | 118 | showUser := UserShowModel{ 119 | ID: dbUser.ID.String(), 120 | Username: dbUser.Username, 121 | Role: dbUser.Role, 122 | } 123 | 124 | return ctx. 125 | Status(fiber.StatusOK). 126 | JSON(UserUpdateResponse{ 127 | Success: true, 128 | User: &showUser, 129 | Message: "", 130 | }) 131 | } 132 | -------------------------------------------------------------------------------- /api/v1/users/users.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/mrusme/journalist/ent" 6 | "github.com/mrusme/journalist/lib" 7 | "go.uber.org/zap" 8 | ) 9 | 10 | type handler struct { 11 | jctx *lib.JournalistContext 12 | config *lib.Config 13 | entClient *ent.Client 14 | logger *zap.Logger 15 | } 16 | 17 | type UserShowModel struct { 18 | ID string `json:"id"` 19 | Username string `json:"username"` 20 | Role string `json:"role"` 21 | } 22 | 23 | type UserCreateModel struct { 24 | Username string `json:"username" validate:"required,alphanum,max=32"` 25 | Password string `json:"password" validate:"required"` 26 | Role string `json:"role" validate:"required"` 27 | } 28 | 29 | type UserUpdateModel struct { 30 | Password string `json:"password,omitempty" validate:"omitempty,min=5"` 31 | Role string `json:"role,omitempty" validate:"omitempty"` 32 | } 33 | 34 | func Register( 35 | jctx *lib.JournalistContext, 36 | fiberRouter *fiber.Router, 37 | ) { 38 | endpoint := new(handler) 39 | endpoint.jctx = jctx 40 | endpoint.config = endpoint.jctx.Config 41 | endpoint.entClient = endpoint.jctx.EntClient 42 | endpoint.logger = endpoint.jctx.Logger 43 | 44 | usersRouter := (*fiberRouter).Group("/users") 45 | usersRouter.Get("/", endpoint.List) 46 | usersRouter.Get("/:id", endpoint.Show) 47 | usersRouter.Post("/", endpoint.Create) 48 | usersRouter.Put("/:id", endpoint.Update) 49 | // usersRouter.Delete("/:id", endpoint.Destroy) 50 | } 51 | -------------------------------------------------------------------------------- /api/v1/v1.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/mrusme/journalist/api/v1/feeds" 6 | "github.com/mrusme/journalist/api/v1/tokens" 7 | "github.com/mrusme/journalist/api/v1/users" 8 | "github.com/mrusme/journalist/lib" 9 | ) 10 | 11 | func Register( 12 | jctx *lib.JournalistContext, 13 | fiberRouter *fiber.Router, 14 | ) { 15 | v1 := (*fiberRouter).Group("/v1") 16 | 17 | users.Register( 18 | jctx, 19 | &v1, 20 | ) 21 | 22 | tokens.Register( 23 | jctx, 24 | &v1, 25 | ) 26 | 27 | feeds.Register( 28 | jctx, 29 | &v1, 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Journalist", 3 | "description": "An RSS aggregator", 4 | "keywords": [ 5 | "journalist", 6 | "rss", 7 | "aggregator" 8 | ], 9 | "website": "https://xn--gckvb8fzb.com/tag/journalist", 10 | "repository": "https://github.com/mrusme/journalist", 11 | "logo": "https://github.com/mrusme/journalist/raw/master/journalist.png", 12 | "image": "mrusme/journalist", 13 | "env": { 14 | "JOURNALIST_SERVER_BINDIP": { 15 | "value": "0.0.0.0" 16 | } 17 | }, 18 | "formation": { 19 | "web": { 20 | "quantity": 1, 21 | "size": "standard-1x" 22 | } 23 | }, 24 | "addons": [ 25 | { 26 | "plan": "heroku-postgresql", 27 | "options": { 28 | "version": "14" 29 | } 30 | } 31 | ] 32 | } 33 | 34 | -------------------------------------------------------------------------------- /crawler/crawler.go: -------------------------------------------------------------------------------- 1 | package crawler 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "net/http" 7 | "net/http/cookiejar" 8 | "net/url" 9 | "os" 10 | "strings" 11 | 12 | "go.uber.org/zap" 13 | "golang.org/x/net/publicsuffix" 14 | 15 | "github.com/Danny-Dasilva/CycleTLS/cycletls" 16 | scraper "github.com/memclutter/go-cloudflare-scraper" 17 | 18 | "github.com/go-shiori/go-readability" 19 | ) 20 | 21 | type ItemCrawled struct { 22 | Title string 23 | Author string 24 | Excerpt string 25 | SiteName string 26 | Image string 27 | ContentHtml string 28 | ContentText string 29 | } 30 | 31 | type Crawler struct { 32 | source io.ReadCloser 33 | sourceLocation string 34 | sourceLocationUrl *url.URL 35 | 36 | UserAgent string 37 | 38 | username string 39 | password string 40 | 41 | contentType string 42 | 43 | logger *zap.Logger 44 | } 45 | 46 | func New(logger *zap.Logger) *Crawler { 47 | crawler := new(Crawler) 48 | crawler.logger = logger 49 | 50 | crawler.source = nil 51 | crawler.Reset() 52 | return crawler 53 | } 54 | 55 | func (c *Crawler) Close() { 56 | if c.source != nil { 57 | c.source.Close() 58 | c.source = nil 59 | } 60 | } 61 | 62 | func (c *Crawler) Reset() { 63 | c.Close() 64 | c.sourceLocation = "" 65 | c.sourceLocationUrl = nil 66 | 67 | c.UserAgent = 68 | "Mozilla/5.0 AppleWebKit/537.36 " + 69 | "(KHTML, like Gecko; compatible; " + 70 | "Googlebot/2.1; +http://www.google.com/bot.html)" 71 | 72 | c.username = "" 73 | c.password = "" 74 | 75 | c.contentType = "" 76 | } 77 | 78 | func (c *Crawler) SetLocation(sourceLocation string) error { 79 | var urlUrl *url.URL 80 | var err error 81 | 82 | if sourceLocation != "-" { 83 | urlUrl, err = url.Parse(sourceLocation) 84 | if err != nil { 85 | return err 86 | } 87 | } 88 | 89 | c.sourceLocation = sourceLocation 90 | c.sourceLocationUrl = urlUrl 91 | 92 | return nil 93 | } 94 | 95 | func (c *Crawler) SetBasicAuth(username string, password string) { 96 | c.username = username 97 | c.password = password 98 | } 99 | 100 | func (c *Crawler) GetSource() io.ReadCloser { 101 | return c.source 102 | } 103 | 104 | func (c *Crawler) GetReadable(useCycleTLS bool) (ItemCrawled, error) { 105 | if err := c.FromAuto(useCycleTLS); err != nil { 106 | return ItemCrawled{}, err 107 | } 108 | 109 | article, err := readability.FromReader(c.source, c.sourceLocationUrl) 110 | if err != nil { 111 | return ItemCrawled{}, err 112 | } 113 | 114 | item := ItemCrawled{ 115 | Title: article.Title, 116 | Author: article.Byline, 117 | Excerpt: article.Excerpt, 118 | SiteName: article.SiteName, 119 | Image: article.Image, 120 | ContentHtml: article.Content, 121 | ContentText: article.TextContent, 122 | } 123 | 124 | return item, nil 125 | } 126 | 127 | func (c *Crawler) FromAuto(useCycleTLS bool) error { 128 | var err error 129 | 130 | switch c.sourceLocation { 131 | case "-": 132 | err = c.FromStdin() 133 | default: 134 | switch c.sourceLocationUrl.Scheme { 135 | case "http", "https": 136 | if useCycleTLS { 137 | err = c.FromHTTPCycleTLS() 138 | } else { 139 | err = c.FromHTTP() 140 | } 141 | default: 142 | err = c.FromFile() 143 | } 144 | } 145 | 146 | return err 147 | } 148 | 149 | func (c *Crawler) FromHTTP() error { 150 | jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) 151 | if err != nil { 152 | return err 153 | } 154 | 155 | scraper, err := scraper.NewTransport(http.DefaultTransport) 156 | client := &http.Client{ 157 | Jar: jar, 158 | Transport: scraper, 159 | } 160 | 161 | req, err := http.NewRequest("GET", c.sourceLocation, nil) 162 | if err != nil { 163 | return err 164 | } 165 | 166 | req.Header.Set("User-Agent", 167 | c.UserAgent) 168 | req.Header.Set("Accept", 169 | "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,"+ 170 | "image/webp,*/*;q=0.8") 171 | req.Header.Set("Accept-Language", 172 | "en-US,en;q=0.5") 173 | req.Header.Set("DNT", 174 | "1") 175 | 176 | if c.username != "" && c.password != "" { 177 | req.SetBasicAuth(c.username, c.password) 178 | } 179 | 180 | resp, err := client.Do(req) 181 | if err != nil { 182 | return err 183 | } 184 | 185 | c.Close() 186 | c.source = resp.Body 187 | return nil 188 | } 189 | 190 | func (c *Crawler) FromHTTPCycleTLS() error { 191 | client := cycletls.Init() 192 | 193 | resp, err := client.Do(c.sourceLocation, cycletls.Options{ 194 | Body: "", 195 | Ja3: "771,4865-4867-4866-49195-49199-52393-52392-49196-49200-49162-49161-49171-49172-51-57-47-53-10,0-23-65281-10-11-35-16-5-51-43-13-45-28-21,29-23-24-25-256-257,0", 196 | UserAgent: "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:87.0) Gecko/20100101 Firefox/87.0", 197 | }, "GET") 198 | if err != nil { 199 | return err 200 | } 201 | 202 | c.Close() 203 | c.source = io.NopCloser(strings.NewReader(resp.Body)) 204 | return nil 205 | } 206 | 207 | func (c *Crawler) FromFile() error { 208 | file, err := os.Open(c.sourceLocation) 209 | if err != nil { 210 | return err 211 | } 212 | 213 | c.Close() 214 | c.source = file 215 | return nil 216 | } 217 | 218 | func (c *Crawler) FromStdin() error { 219 | c.Close() 220 | c.source = io.NopCloser(bufio.NewReader(os.Stdin)) 221 | return nil 222 | } 223 | 224 | func (c *Crawler) Detect() error { 225 | buf := make([]byte, 512) 226 | _, err := c.source.Read(buf) 227 | if err != nil { 228 | return err 229 | } 230 | 231 | c.contentType = http.DetectContentType(buf) 232 | return nil 233 | } 234 | 235 | func (c *Crawler) GetContentType() string { 236 | return c.contentType 237 | } 238 | -------------------------------------------------------------------------------- /crawler/feed.go: -------------------------------------------------------------------------------- 1 | package crawler 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/mmcdole/gofeed" 8 | "golang.org/x/net/html" 9 | 10 | "errors" 11 | ) 12 | 13 | func (c *Crawler) GetFeedLink() (string, string, error) { 14 | if err := c.FromAuto(false); err != nil { 15 | return "", "", err 16 | } 17 | 18 | if c.source == nil { 19 | return "", "", errors.New("No source available!") 20 | } 21 | 22 | if c.contentType == "" { 23 | if err := c.Detect(); err != nil { 24 | return "", "", err 25 | } 26 | 27 | if c.contentType == "" { 28 | return "", "", errors.New("Could not detect content type!") 29 | } 30 | } 31 | 32 | if strings.Contains(c.contentType, "text/xml") { 33 | return "", c.sourceLocation, nil 34 | } else if strings.Contains(c.contentType, "text/html") { 35 | return c.GetFeedLinkFromHTML() 36 | } 37 | 38 | return "", "", errors.New("No feed link found") 39 | } 40 | 41 | func (c *Crawler) GetFeedLinkFromHTML() (string, string, error) { 42 | doc, err := html.Parse(c.source) 43 | if err != nil { 44 | return "", "", err 45 | } 46 | 47 | var f func(*html.Node) (bool, string, string) 48 | f = func(n *html.Node) (bool, string, string) { 49 | if n.Type == html.ElementNode && n.Data == "link" { 50 | var feedType *string = nil 51 | var feedHref *string = nil 52 | 53 | for i := 0; i < len(n.Attr); i++ { 54 | attr := n.Attr[i] 55 | if attr.Key == "type" { 56 | if strings.Contains(attr.Val, "rss") || strings.Contains(attr.Val, "atom") { 57 | feedType = &attr.Val 58 | } 59 | } else if attr.Key == "href" { 60 | feedHref = &attr.Val 61 | } 62 | } 63 | 64 | if feedType != nil && feedHref != nil { 65 | return true, *feedType, *feedHref 66 | } 67 | 68 | return false, "", "" 69 | } 70 | for c := n.FirstChild; c != nil; c = c.NextSibling { 71 | fF, fT, fH := f(c) 72 | if fF == true { 73 | return fF, fT, fH 74 | } 75 | } 76 | return false, "", "" 77 | } 78 | 79 | found, feedType, feedHref := f(doc) 80 | if found == true { 81 | if strings.HasPrefix(feedHref, "./") { 82 | feedHref = fmt.Sprintf( 83 | "%s/%s", 84 | strings.TrimRight(c.sourceLocation, "/"), 85 | strings.TrimLeft(feedHref, "./"), 86 | ) 87 | } else if strings.HasPrefix(feedHref, "/") { 88 | feedHref = fmt.Sprintf( 89 | "%s/%s", 90 | strings.TrimRight(c.sourceLocation, "/"), 91 | strings.TrimLeft(feedHref, "/"), 92 | ) 93 | } 94 | return feedType, feedHref, nil 95 | } 96 | 97 | return "", "", errors.New("No feed URL found!") 98 | } 99 | 100 | func (c *Crawler) ParseFeed() (*gofeed.Feed, error) { 101 | if err := c.FromAuto(false); err != nil { 102 | return nil, err 103 | } 104 | 105 | if c.source == nil { 106 | return nil, errors.New("No source available!") 107 | } 108 | 109 | gfp := gofeed.NewParser() 110 | feed, err := gfp.Parse(c.source) 111 | if err != nil { 112 | return nil, err 113 | } 114 | 115 | return feed, nil 116 | } 117 | -------------------------------------------------------------------------------- /ent/enttest/enttest.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package enttest 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/mrusme/journalist/ent" 9 | // required by schema hooks. 10 | _ "github.com/mrusme/journalist/ent/runtime" 11 | 12 | "entgo.io/ent/dialect/sql/schema" 13 | "github.com/mrusme/journalist/ent/migrate" 14 | ) 15 | 16 | type ( 17 | // TestingT is the interface that is shared between 18 | // testing.T and testing.B and used by enttest. 19 | TestingT interface { 20 | FailNow() 21 | Error(...any) 22 | } 23 | 24 | // Option configures client creation. 25 | Option func(*options) 26 | 27 | options struct { 28 | opts []ent.Option 29 | migrateOpts []schema.MigrateOption 30 | } 31 | ) 32 | 33 | // WithOptions forwards options to client creation. 34 | func WithOptions(opts ...ent.Option) Option { 35 | return func(o *options) { 36 | o.opts = append(o.opts, opts...) 37 | } 38 | } 39 | 40 | // WithMigrateOptions forwards options to auto migration. 41 | func WithMigrateOptions(opts ...schema.MigrateOption) Option { 42 | return func(o *options) { 43 | o.migrateOpts = append(o.migrateOpts, opts...) 44 | } 45 | } 46 | 47 | func newOptions(opts []Option) *options { 48 | o := &options{} 49 | for _, opt := range opts { 50 | opt(o) 51 | } 52 | return o 53 | } 54 | 55 | // Open calls ent.Open and auto-run migration. 56 | func Open(t TestingT, driverName, dataSourceName string, opts ...Option) *ent.Client { 57 | o := newOptions(opts) 58 | c, err := ent.Open(driverName, dataSourceName, o.opts...) 59 | if err != nil { 60 | t.Error(err) 61 | t.FailNow() 62 | } 63 | migrateSchema(t, c, o) 64 | return c 65 | } 66 | 67 | // NewClient calls ent.NewClient and auto-run migration. 68 | func NewClient(t TestingT, opts ...Option) *ent.Client { 69 | o := newOptions(opts) 70 | c := ent.NewClient(o.opts...) 71 | migrateSchema(t, c, o) 72 | return c 73 | } 74 | func migrateSchema(t TestingT, c *ent.Client, o *options) { 75 | tables, err := schema.CopyTables(migrate.Tables) 76 | if err != nil { 77 | t.Error(err) 78 | t.FailNow() 79 | } 80 | if err := migrate.Create(context.Background(), c.Schema, tables, o.migrateOpts...); err != nil { 81 | t.Error(err) 82 | t.FailNow() 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /ent/feed_delete.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package ent 4 | 5 | import ( 6 | "context" 7 | 8 | "entgo.io/ent/dialect/sql" 9 | "entgo.io/ent/dialect/sql/sqlgraph" 10 | "entgo.io/ent/schema/field" 11 | "github.com/mrusme/journalist/ent/feed" 12 | "github.com/mrusme/journalist/ent/predicate" 13 | ) 14 | 15 | // FeedDelete is the builder for deleting a Feed entity. 16 | type FeedDelete struct { 17 | config 18 | hooks []Hook 19 | mutation *FeedMutation 20 | } 21 | 22 | // Where appends a list predicates to the FeedDelete builder. 23 | func (fd *FeedDelete) Where(ps ...predicate.Feed) *FeedDelete { 24 | fd.mutation.Where(ps...) 25 | return fd 26 | } 27 | 28 | // Exec executes the deletion query and returns how many vertices were deleted. 29 | func (fd *FeedDelete) Exec(ctx context.Context) (int, error) { 30 | return withHooks(ctx, fd.sqlExec, fd.mutation, fd.hooks) 31 | } 32 | 33 | // ExecX is like Exec, but panics if an error occurs. 34 | func (fd *FeedDelete) ExecX(ctx context.Context) int { 35 | n, err := fd.Exec(ctx) 36 | if err != nil { 37 | panic(err) 38 | } 39 | return n 40 | } 41 | 42 | func (fd *FeedDelete) sqlExec(ctx context.Context) (int, error) { 43 | _spec := sqlgraph.NewDeleteSpec(feed.Table, sqlgraph.NewFieldSpec(feed.FieldID, field.TypeUUID)) 44 | if ps := fd.mutation.predicates; len(ps) > 0 { 45 | _spec.Predicate = func(selector *sql.Selector) { 46 | for i := range ps { 47 | ps[i](selector) 48 | } 49 | } 50 | } 51 | affected, err := sqlgraph.DeleteNodes(ctx, fd.driver, _spec) 52 | if err != nil && sqlgraph.IsConstraintError(err) { 53 | err = &ConstraintError{msg: err.Error(), wrap: err} 54 | } 55 | fd.mutation.done = true 56 | return affected, err 57 | } 58 | 59 | // FeedDeleteOne is the builder for deleting a single Feed entity. 60 | type FeedDeleteOne struct { 61 | fd *FeedDelete 62 | } 63 | 64 | // Where appends a list predicates to the FeedDelete builder. 65 | func (fdo *FeedDeleteOne) Where(ps ...predicate.Feed) *FeedDeleteOne { 66 | fdo.fd.mutation.Where(ps...) 67 | return fdo 68 | } 69 | 70 | // Exec executes the deletion query. 71 | func (fdo *FeedDeleteOne) Exec(ctx context.Context) error { 72 | n, err := fdo.fd.Exec(ctx) 73 | switch { 74 | case err != nil: 75 | return err 76 | case n == 0: 77 | return &NotFoundError{feed.Label} 78 | default: 79 | return nil 80 | } 81 | } 82 | 83 | // ExecX is like Exec, but panics if an error occurs. 84 | func (fdo *FeedDeleteOne) ExecX(ctx context.Context) { 85 | if err := fdo.Exec(ctx); err != nil { 86 | panic(err) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /ent/generate.go: -------------------------------------------------------------------------------- 1 | package ent 2 | 3 | //go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/upsert ./schema 4 | -------------------------------------------------------------------------------- /ent/hook/hook.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package hook 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | 9 | "github.com/mrusme/journalist/ent" 10 | ) 11 | 12 | // The FeedFunc type is an adapter to allow the use of ordinary 13 | // function as Feed mutator. 14 | type FeedFunc func(context.Context, *ent.FeedMutation) (ent.Value, error) 15 | 16 | // Mutate calls f(ctx, m). 17 | func (f FeedFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) { 18 | if mv, ok := m.(*ent.FeedMutation); ok { 19 | return f(ctx, mv) 20 | } 21 | return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.FeedMutation", m) 22 | } 23 | 24 | // The ItemFunc type is an adapter to allow the use of ordinary 25 | // function as Item mutator. 26 | type ItemFunc func(context.Context, *ent.ItemMutation) (ent.Value, error) 27 | 28 | // Mutate calls f(ctx, m). 29 | func (f ItemFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) { 30 | if mv, ok := m.(*ent.ItemMutation); ok { 31 | return f(ctx, mv) 32 | } 33 | return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.ItemMutation", m) 34 | } 35 | 36 | // The ReadFunc type is an adapter to allow the use of ordinary 37 | // function as Read mutator. 38 | type ReadFunc func(context.Context, *ent.ReadMutation) (ent.Value, error) 39 | 40 | // Mutate calls f(ctx, m). 41 | func (f ReadFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) { 42 | if mv, ok := m.(*ent.ReadMutation); ok { 43 | return f(ctx, mv) 44 | } 45 | return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.ReadMutation", m) 46 | } 47 | 48 | // The SubscriptionFunc type is an adapter to allow the use of ordinary 49 | // function as Subscription mutator. 50 | type SubscriptionFunc func(context.Context, *ent.SubscriptionMutation) (ent.Value, error) 51 | 52 | // Mutate calls f(ctx, m). 53 | func (f SubscriptionFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) { 54 | if mv, ok := m.(*ent.SubscriptionMutation); ok { 55 | return f(ctx, mv) 56 | } 57 | return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.SubscriptionMutation", m) 58 | } 59 | 60 | // The TokenFunc type is an adapter to allow the use of ordinary 61 | // function as Token mutator. 62 | type TokenFunc func(context.Context, *ent.TokenMutation) (ent.Value, error) 63 | 64 | // Mutate calls f(ctx, m). 65 | func (f TokenFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) { 66 | if mv, ok := m.(*ent.TokenMutation); ok { 67 | return f(ctx, mv) 68 | } 69 | return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.TokenMutation", m) 70 | } 71 | 72 | // The UserFunc type is an adapter to allow the use of ordinary 73 | // function as User mutator. 74 | type UserFunc func(context.Context, *ent.UserMutation) (ent.Value, error) 75 | 76 | // Mutate calls f(ctx, m). 77 | func (f UserFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) { 78 | if mv, ok := m.(*ent.UserMutation); ok { 79 | return f(ctx, mv) 80 | } 81 | return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.UserMutation", m) 82 | } 83 | 84 | // Condition is a hook condition function. 85 | type Condition func(context.Context, ent.Mutation) bool 86 | 87 | // And groups conditions with the AND operator. 88 | func And(first, second Condition, rest ...Condition) Condition { 89 | return func(ctx context.Context, m ent.Mutation) bool { 90 | if !first(ctx, m) || !second(ctx, m) { 91 | return false 92 | } 93 | for _, cond := range rest { 94 | if !cond(ctx, m) { 95 | return false 96 | } 97 | } 98 | return true 99 | } 100 | } 101 | 102 | // Or groups conditions with the OR operator. 103 | func Or(first, second Condition, rest ...Condition) Condition { 104 | return func(ctx context.Context, m ent.Mutation) bool { 105 | if first(ctx, m) || second(ctx, m) { 106 | return true 107 | } 108 | for _, cond := range rest { 109 | if cond(ctx, m) { 110 | return true 111 | } 112 | } 113 | return false 114 | } 115 | } 116 | 117 | // Not negates a given condition. 118 | func Not(cond Condition) Condition { 119 | return func(ctx context.Context, m ent.Mutation) bool { 120 | return !cond(ctx, m) 121 | } 122 | } 123 | 124 | // HasOp is a condition testing mutation operation. 125 | func HasOp(op ent.Op) Condition { 126 | return func(_ context.Context, m ent.Mutation) bool { 127 | return m.Op().Is(op) 128 | } 129 | } 130 | 131 | // HasAddedFields is a condition validating `.AddedField` on fields. 132 | func HasAddedFields(field string, fields ...string) Condition { 133 | return func(_ context.Context, m ent.Mutation) bool { 134 | if _, exists := m.AddedField(field); !exists { 135 | return false 136 | } 137 | for _, field := range fields { 138 | if _, exists := m.AddedField(field); !exists { 139 | return false 140 | } 141 | } 142 | return true 143 | } 144 | } 145 | 146 | // HasClearedFields is a condition validating `.FieldCleared` on fields. 147 | func HasClearedFields(field string, fields ...string) Condition { 148 | return func(_ context.Context, m ent.Mutation) bool { 149 | if exists := m.FieldCleared(field); !exists { 150 | return false 151 | } 152 | for _, field := range fields { 153 | if exists := m.FieldCleared(field); !exists { 154 | return false 155 | } 156 | } 157 | return true 158 | } 159 | } 160 | 161 | // HasFields is a condition validating `.Field` on fields. 162 | func HasFields(field string, fields ...string) Condition { 163 | return func(_ context.Context, m ent.Mutation) bool { 164 | if _, exists := m.Field(field); !exists { 165 | return false 166 | } 167 | for _, field := range fields { 168 | if _, exists := m.Field(field); !exists { 169 | return false 170 | } 171 | } 172 | return true 173 | } 174 | } 175 | 176 | // If executes the given hook under condition. 177 | // 178 | // hook.If(ComputeAverage, And(HasFields(...), HasAddedFields(...))) 179 | func If(hk ent.Hook, cond Condition) ent.Hook { 180 | return func(next ent.Mutator) ent.Mutator { 181 | return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) { 182 | if cond(ctx, m) { 183 | return hk(next).Mutate(ctx, m) 184 | } 185 | return next.Mutate(ctx, m) 186 | }) 187 | } 188 | } 189 | 190 | // On executes the given hook only for the given operation. 191 | // 192 | // hook.On(Log, ent.Delete|ent.Create) 193 | func On(hk ent.Hook, op ent.Op) ent.Hook { 194 | return If(hk, HasOp(op)) 195 | } 196 | 197 | // Unless skips the given hook only for the given operation. 198 | // 199 | // hook.Unless(Log, ent.Update|ent.UpdateOne) 200 | func Unless(hk ent.Hook, op ent.Op) ent.Hook { 201 | return If(hk, Not(HasOp(op))) 202 | } 203 | 204 | // FixedError is a hook returning a fixed error. 205 | func FixedError(err error) ent.Hook { 206 | return func(ent.Mutator) ent.Mutator { 207 | return ent.MutateFunc(func(context.Context, ent.Mutation) (ent.Value, error) { 208 | return nil, err 209 | }) 210 | } 211 | } 212 | 213 | // Reject returns a hook that rejects all operations that match op. 214 | // 215 | // func (T) Hooks() []ent.Hook { 216 | // return []ent.Hook{ 217 | // Reject(ent.Delete|ent.Update), 218 | // } 219 | // } 220 | func Reject(op ent.Op) ent.Hook { 221 | hk := FixedError(fmt.Errorf("%s operation is not allowed", op)) 222 | return On(hk, op) 223 | } 224 | 225 | // Chain acts as a list of hooks and is effectively immutable. 226 | // Once created, it will always hold the same set of hooks in the same order. 227 | type Chain struct { 228 | hooks []ent.Hook 229 | } 230 | 231 | // NewChain creates a new chain of hooks. 232 | func NewChain(hooks ...ent.Hook) Chain { 233 | return Chain{append([]ent.Hook(nil), hooks...)} 234 | } 235 | 236 | // Hook chains the list of hooks and returns the final hook. 237 | func (c Chain) Hook() ent.Hook { 238 | return func(mutator ent.Mutator) ent.Mutator { 239 | for i := len(c.hooks) - 1; i >= 0; i-- { 240 | mutator = c.hooks[i](mutator) 241 | } 242 | return mutator 243 | } 244 | } 245 | 246 | // Append extends a chain, adding the specified hook 247 | // as the last ones in the mutation flow. 248 | func (c Chain) Append(hooks ...ent.Hook) Chain { 249 | newHooks := make([]ent.Hook, 0, len(c.hooks)+len(hooks)) 250 | newHooks = append(newHooks, c.hooks...) 251 | newHooks = append(newHooks, hooks...) 252 | return Chain{newHooks} 253 | } 254 | 255 | // Extend extends a chain, adding the specified chain 256 | // as the last ones in the mutation flow. 257 | func (c Chain) Extend(chain Chain) Chain { 258 | return c.Append(chain.hooks...) 259 | } 260 | -------------------------------------------------------------------------------- /ent/item_delete.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package ent 4 | 5 | import ( 6 | "context" 7 | 8 | "entgo.io/ent/dialect/sql" 9 | "entgo.io/ent/dialect/sql/sqlgraph" 10 | "entgo.io/ent/schema/field" 11 | "github.com/mrusme/journalist/ent/item" 12 | "github.com/mrusme/journalist/ent/predicate" 13 | ) 14 | 15 | // ItemDelete is the builder for deleting a Item entity. 16 | type ItemDelete struct { 17 | config 18 | hooks []Hook 19 | mutation *ItemMutation 20 | } 21 | 22 | // Where appends a list predicates to the ItemDelete builder. 23 | func (id *ItemDelete) Where(ps ...predicate.Item) *ItemDelete { 24 | id.mutation.Where(ps...) 25 | return id 26 | } 27 | 28 | // Exec executes the deletion query and returns how many vertices were deleted. 29 | func (id *ItemDelete) Exec(ctx context.Context) (int, error) { 30 | return withHooks(ctx, id.sqlExec, id.mutation, id.hooks) 31 | } 32 | 33 | // ExecX is like Exec, but panics if an error occurs. 34 | func (id *ItemDelete) ExecX(ctx context.Context) int { 35 | n, err := id.Exec(ctx) 36 | if err != nil { 37 | panic(err) 38 | } 39 | return n 40 | } 41 | 42 | func (id *ItemDelete) sqlExec(ctx context.Context) (int, error) { 43 | _spec := sqlgraph.NewDeleteSpec(item.Table, sqlgraph.NewFieldSpec(item.FieldID, field.TypeUUID)) 44 | if ps := id.mutation.predicates; len(ps) > 0 { 45 | _spec.Predicate = func(selector *sql.Selector) { 46 | for i := range ps { 47 | ps[i](selector) 48 | } 49 | } 50 | } 51 | affected, err := sqlgraph.DeleteNodes(ctx, id.driver, _spec) 52 | if err != nil && sqlgraph.IsConstraintError(err) { 53 | err = &ConstraintError{msg: err.Error(), wrap: err} 54 | } 55 | id.mutation.done = true 56 | return affected, err 57 | } 58 | 59 | // ItemDeleteOne is the builder for deleting a single Item entity. 60 | type ItemDeleteOne struct { 61 | id *ItemDelete 62 | } 63 | 64 | // Where appends a list predicates to the ItemDelete builder. 65 | func (ido *ItemDeleteOne) Where(ps ...predicate.Item) *ItemDeleteOne { 66 | ido.id.mutation.Where(ps...) 67 | return ido 68 | } 69 | 70 | // Exec executes the deletion query. 71 | func (ido *ItemDeleteOne) Exec(ctx context.Context) error { 72 | n, err := ido.id.Exec(ctx) 73 | switch { 74 | case err != nil: 75 | return err 76 | case n == 0: 77 | return &NotFoundError{item.Label} 78 | default: 79 | return nil 80 | } 81 | } 82 | 83 | // ExecX is like Exec, but panics if an error occurs. 84 | func (ido *ItemDeleteOne) ExecX(ctx context.Context) { 85 | if err := ido.Exec(ctx); err != nil { 86 | panic(err) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /ent/migrate/migrate.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package migrate 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "io" 9 | 10 | "entgo.io/ent/dialect" 11 | "entgo.io/ent/dialect/sql/schema" 12 | ) 13 | 14 | var ( 15 | // WithGlobalUniqueID sets the universal ids options to the migration. 16 | // If this option is enabled, ent migration will allocate a 1<<32 range 17 | // for the ids of each entity (table). 18 | // Note that this option cannot be applied on tables that already exist. 19 | WithGlobalUniqueID = schema.WithGlobalUniqueID 20 | // WithDropColumn sets the drop column option to the migration. 21 | // If this option is enabled, ent migration will drop old columns 22 | // that were used for both fields and edges. This defaults to false. 23 | WithDropColumn = schema.WithDropColumn 24 | // WithDropIndex sets the drop index option to the migration. 25 | // If this option is enabled, ent migration will drop old indexes 26 | // that were defined in the schema. This defaults to false. 27 | // Note that unique constraints are defined using `UNIQUE INDEX`, 28 | // and therefore, it's recommended to enable this option to get more 29 | // flexibility in the schema changes. 30 | WithDropIndex = schema.WithDropIndex 31 | // WithForeignKeys enables creating foreign-key in schema DDL. This defaults to true. 32 | WithForeignKeys = schema.WithForeignKeys 33 | ) 34 | 35 | // Schema is the API for creating, migrating and dropping a schema. 36 | type Schema struct { 37 | drv dialect.Driver 38 | } 39 | 40 | // NewSchema creates a new schema client. 41 | func NewSchema(drv dialect.Driver) *Schema { return &Schema{drv: drv} } 42 | 43 | // Create creates all schema resources. 44 | func (s *Schema) Create(ctx context.Context, opts ...schema.MigrateOption) error { 45 | return Create(ctx, s, Tables, opts...) 46 | } 47 | 48 | // Create creates all table resources using the given schema driver. 49 | func Create(ctx context.Context, s *Schema, tables []*schema.Table, opts ...schema.MigrateOption) error { 50 | migrate, err := schema.NewMigrate(s.drv, opts...) 51 | if err != nil { 52 | return fmt.Errorf("ent/migrate: %w", err) 53 | } 54 | return migrate.Create(ctx, tables...) 55 | } 56 | 57 | // WriteTo writes the schema changes to w instead of running them against the database. 58 | // 59 | // if err := client.Schema.WriteTo(context.Background(), os.Stdout); err != nil { 60 | // log.Fatal(err) 61 | // } 62 | func (s *Schema) WriteTo(ctx context.Context, w io.Writer, opts ...schema.MigrateOption) error { 63 | return Create(ctx, &Schema{drv: &schema.WriteDriver{Writer: w, Driver: s.drv}}, Tables, opts...) 64 | } 65 | -------------------------------------------------------------------------------- /ent/migrate/schema.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package migrate 4 | 5 | import ( 6 | "entgo.io/ent/dialect/sql/schema" 7 | "entgo.io/ent/schema/field" 8 | ) 9 | 10 | var ( 11 | // FeedsColumns holds the columns for the "feeds" table. 12 | FeedsColumns = []*schema.Column{ 13 | {Name: "id", Type: field.TypeUUID}, 14 | {Name: "url", Type: field.TypeString}, 15 | {Name: "username", Type: field.TypeString, Default: ""}, 16 | {Name: "password", Type: field.TypeString, Default: ""}, 17 | {Name: "feed_title", Type: field.TypeString}, 18 | {Name: "feed_description", Type: field.TypeString}, 19 | {Name: "feed_link", Type: field.TypeString}, 20 | {Name: "feed_feed_link", Type: field.TypeString}, 21 | {Name: "feed_updated", Type: field.TypeTime}, 22 | {Name: "feed_published", Type: field.TypeTime}, 23 | {Name: "feed_author_name", Type: field.TypeString, Nullable: true}, 24 | {Name: "feed_author_email", Type: field.TypeString, Nullable: true}, 25 | {Name: "feed_language", Type: field.TypeString}, 26 | {Name: "feed_image_title", Type: field.TypeString, Nullable: true}, 27 | {Name: "feed_image_url", Type: field.TypeString, Nullable: true}, 28 | {Name: "feed_copyright", Type: field.TypeString}, 29 | {Name: "feed_generator", Type: field.TypeString}, 30 | {Name: "feed_categories", Type: field.TypeString}, 31 | {Name: "created_at", Type: field.TypeTime}, 32 | {Name: "updated_at", Type: field.TypeTime}, 33 | {Name: "deleted_at", Type: field.TypeTime, Nullable: true}, 34 | } 35 | // FeedsTable holds the schema information for the "feeds" table. 36 | FeedsTable = &schema.Table{ 37 | Name: "feeds", 38 | Columns: FeedsColumns, 39 | PrimaryKey: []*schema.Column{FeedsColumns[0]}, 40 | Indexes: []*schema.Index{ 41 | { 42 | Name: "feed_url_username_password", 43 | Unique: true, 44 | Columns: []*schema.Column{FeedsColumns[1], FeedsColumns[2], FeedsColumns[3]}, 45 | }, 46 | }, 47 | } 48 | // ItemsColumns holds the columns for the "items" table. 49 | ItemsColumns = []*schema.Column{ 50 | {Name: "id", Type: field.TypeUUID}, 51 | {Name: "item_guid", Type: field.TypeString, Unique: true}, 52 | {Name: "item_title", Type: field.TypeString}, 53 | {Name: "item_description", Type: field.TypeString}, 54 | {Name: "item_content", Type: field.TypeString}, 55 | {Name: "item_link", Type: field.TypeString}, 56 | {Name: "item_updated", Type: field.TypeTime}, 57 | {Name: "item_published", Type: field.TypeTime}, 58 | {Name: "item_author_name", Type: field.TypeString, Nullable: true}, 59 | {Name: "item_author_email", Type: field.TypeString, Nullable: true}, 60 | {Name: "item_image_title", Type: field.TypeString, Nullable: true}, 61 | {Name: "item_image_url", Type: field.TypeString, Nullable: true}, 62 | {Name: "item_categories", Type: field.TypeString}, 63 | {Name: "item_enclosures", Type: field.TypeString}, 64 | {Name: "crawler_title", Type: field.TypeString, Nullable: true}, 65 | {Name: "crawler_author", Type: field.TypeString, Nullable: true}, 66 | {Name: "crawler_excerpt", Type: field.TypeString, Nullable: true}, 67 | {Name: "crawler_site_name", Type: field.TypeString, Nullable: true}, 68 | {Name: "crawler_image", Type: field.TypeString, Nullable: true}, 69 | {Name: "crawler_content_html", Type: field.TypeString, Nullable: true}, 70 | {Name: "crawler_content_text", Type: field.TypeString, Nullable: true}, 71 | {Name: "created_at", Type: field.TypeTime}, 72 | {Name: "updated_at", Type: field.TypeTime}, 73 | {Name: "feed_items", Type: field.TypeUUID, Nullable: true}, 74 | } 75 | // ItemsTable holds the schema information for the "items" table. 76 | ItemsTable = &schema.Table{ 77 | Name: "items", 78 | Columns: ItemsColumns, 79 | PrimaryKey: []*schema.Column{ItemsColumns[0]}, 80 | ForeignKeys: []*schema.ForeignKey{ 81 | { 82 | Symbol: "items_feeds_items", 83 | Columns: []*schema.Column{ItemsColumns[23]}, 84 | RefColumns: []*schema.Column{FeedsColumns[0]}, 85 | OnDelete: schema.SetNull, 86 | }, 87 | }, 88 | Indexes: []*schema.Index{ 89 | { 90 | Name: "item_item_guid", 91 | Unique: true, 92 | Columns: []*schema.Column{ItemsColumns[1]}, 93 | }, 94 | }, 95 | } 96 | // ReadsColumns holds the columns for the "reads" table. 97 | ReadsColumns = []*schema.Column{ 98 | {Name: "id", Type: field.TypeUUID}, 99 | {Name: "created_at", Type: field.TypeTime}, 100 | {Name: "user_id", Type: field.TypeUUID}, 101 | {Name: "item_id", Type: field.TypeUUID}, 102 | } 103 | // ReadsTable holds the schema information for the "reads" table. 104 | ReadsTable = &schema.Table{ 105 | Name: "reads", 106 | Columns: ReadsColumns, 107 | PrimaryKey: []*schema.Column{ReadsColumns[0]}, 108 | ForeignKeys: []*schema.ForeignKey{ 109 | { 110 | Symbol: "reads_users_user", 111 | Columns: []*schema.Column{ReadsColumns[2]}, 112 | RefColumns: []*schema.Column{UsersColumns[0]}, 113 | OnDelete: schema.NoAction, 114 | }, 115 | { 116 | Symbol: "reads_items_item", 117 | Columns: []*schema.Column{ReadsColumns[3]}, 118 | RefColumns: []*schema.Column{ItemsColumns[0]}, 119 | OnDelete: schema.NoAction, 120 | }, 121 | }, 122 | Indexes: []*schema.Index{ 123 | { 124 | Name: "read_user_id_item_id", 125 | Unique: true, 126 | Columns: []*schema.Column{ReadsColumns[2], ReadsColumns[3]}, 127 | }, 128 | }, 129 | } 130 | // SubscriptionsColumns holds the columns for the "subscriptions" table. 131 | SubscriptionsColumns = []*schema.Column{ 132 | {Name: "id", Type: field.TypeUUID}, 133 | {Name: "name", Type: field.TypeString}, 134 | {Name: "group", Type: field.TypeString}, 135 | {Name: "created_at", Type: field.TypeTime}, 136 | {Name: "user_id", Type: field.TypeUUID}, 137 | {Name: "feed_id", Type: field.TypeUUID}, 138 | } 139 | // SubscriptionsTable holds the schema information for the "subscriptions" table. 140 | SubscriptionsTable = &schema.Table{ 141 | Name: "subscriptions", 142 | Columns: SubscriptionsColumns, 143 | PrimaryKey: []*schema.Column{SubscriptionsColumns[0]}, 144 | ForeignKeys: []*schema.ForeignKey{ 145 | { 146 | Symbol: "subscriptions_users_user", 147 | Columns: []*schema.Column{SubscriptionsColumns[4]}, 148 | RefColumns: []*schema.Column{UsersColumns[0]}, 149 | OnDelete: schema.NoAction, 150 | }, 151 | { 152 | Symbol: "subscriptions_feeds_feed", 153 | Columns: []*schema.Column{SubscriptionsColumns[5]}, 154 | RefColumns: []*schema.Column{FeedsColumns[0]}, 155 | OnDelete: schema.NoAction, 156 | }, 157 | }, 158 | Indexes: []*schema.Index{ 159 | { 160 | Name: "subscription_user_id_feed_id", 161 | Unique: true, 162 | Columns: []*schema.Column{SubscriptionsColumns[4], SubscriptionsColumns[5]}, 163 | }, 164 | }, 165 | } 166 | // TokensColumns holds the columns for the "tokens" table. 167 | TokensColumns = []*schema.Column{ 168 | {Name: "id", Type: field.TypeUUID}, 169 | {Name: "type", Type: field.TypeString, Default: "qat"}, 170 | {Name: "name", Type: field.TypeString}, 171 | {Name: "token", Type: field.TypeString, Unique: true}, 172 | {Name: "created_at", Type: field.TypeTime}, 173 | {Name: "updated_at", Type: field.TypeTime}, 174 | {Name: "deleted_at", Type: field.TypeTime, Nullable: true}, 175 | {Name: "user_tokens", Type: field.TypeUUID, Nullable: true}, 176 | } 177 | // TokensTable holds the schema information for the "tokens" table. 178 | TokensTable = &schema.Table{ 179 | Name: "tokens", 180 | Columns: TokensColumns, 181 | PrimaryKey: []*schema.Column{TokensColumns[0]}, 182 | ForeignKeys: []*schema.ForeignKey{ 183 | { 184 | Symbol: "tokens_users_tokens", 185 | Columns: []*schema.Column{TokensColumns[7]}, 186 | RefColumns: []*schema.Column{UsersColumns[0]}, 187 | OnDelete: schema.SetNull, 188 | }, 189 | }, 190 | } 191 | // UsersColumns holds the columns for the "users" table. 192 | UsersColumns = []*schema.Column{ 193 | {Name: "id", Type: field.TypeUUID}, 194 | {Name: "username", Type: field.TypeString, Unique: true}, 195 | {Name: "password", Type: field.TypeString}, 196 | {Name: "role", Type: field.TypeString, Default: "user"}, 197 | {Name: "created_at", Type: field.TypeTime}, 198 | {Name: "updated_at", Type: field.TypeTime}, 199 | {Name: "deleted_at", Type: field.TypeTime, Nullable: true}, 200 | } 201 | // UsersTable holds the schema information for the "users" table. 202 | UsersTable = &schema.Table{ 203 | Name: "users", 204 | Columns: UsersColumns, 205 | PrimaryKey: []*schema.Column{UsersColumns[0]}, 206 | } 207 | // Tables holds all the tables in the schema. 208 | Tables = []*schema.Table{ 209 | FeedsTable, 210 | ItemsTable, 211 | ReadsTable, 212 | SubscriptionsTable, 213 | TokensTable, 214 | UsersTable, 215 | } 216 | ) 217 | 218 | func init() { 219 | ItemsTable.ForeignKeys[0].RefTable = FeedsTable 220 | ReadsTable.ForeignKeys[0].RefTable = UsersTable 221 | ReadsTable.ForeignKeys[1].RefTable = ItemsTable 222 | SubscriptionsTable.ForeignKeys[0].RefTable = UsersTable 223 | SubscriptionsTable.ForeignKeys[1].RefTable = FeedsTable 224 | TokensTable.ForeignKeys[0].RefTable = UsersTable 225 | } 226 | -------------------------------------------------------------------------------- /ent/predicate/predicate.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package predicate 4 | 5 | import ( 6 | "entgo.io/ent/dialect/sql" 7 | ) 8 | 9 | // Feed is the predicate function for feed builders. 10 | type Feed func(*sql.Selector) 11 | 12 | // Item is the predicate function for item builders. 13 | type Item func(*sql.Selector) 14 | 15 | // Read is the predicate function for read builders. 16 | type Read func(*sql.Selector) 17 | 18 | // Subscription is the predicate function for subscription builders. 19 | type Subscription func(*sql.Selector) 20 | 21 | // Token is the predicate function for token builders. 22 | type Token func(*sql.Selector) 23 | 24 | // User is the predicate function for user builders. 25 | type User func(*sql.Selector) 26 | -------------------------------------------------------------------------------- /ent/read.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package ent 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | "entgo.io/ent" 11 | "entgo.io/ent/dialect/sql" 12 | "github.com/google/uuid" 13 | "github.com/mrusme/journalist/ent/item" 14 | "github.com/mrusme/journalist/ent/read" 15 | "github.com/mrusme/journalist/ent/user" 16 | ) 17 | 18 | // Read is the model entity for the Read schema. 19 | type Read struct { 20 | config `json:"-"` 21 | // ID of the ent. 22 | ID uuid.UUID `json:"id,omitempty"` 23 | // UserID holds the value of the "user_id" field. 24 | UserID uuid.UUID `json:"user_id,omitempty"` 25 | // ItemID holds the value of the "item_id" field. 26 | ItemID uuid.UUID `json:"item_id,omitempty"` 27 | // CreatedAt holds the value of the "created_at" field. 28 | CreatedAt time.Time `json:"created_at,omitempty"` 29 | // Edges holds the relations/edges for other nodes in the graph. 30 | // The values are being populated by the ReadQuery when eager-loading is set. 31 | Edges ReadEdges `json:"edges"` 32 | selectValues sql.SelectValues 33 | } 34 | 35 | // ReadEdges holds the relations/edges for other nodes in the graph. 36 | type ReadEdges struct { 37 | // User holds the value of the user edge. 38 | User *User `json:"user,omitempty"` 39 | // Item holds the value of the item edge. 40 | Item *Item `json:"item,omitempty"` 41 | // loadedTypes holds the information for reporting if a 42 | // type was loaded (or requested) in eager-loading or not. 43 | loadedTypes [2]bool 44 | } 45 | 46 | // UserOrErr returns the User value or an error if the edge 47 | // was not loaded in eager-loading, or loaded but was not found. 48 | func (e ReadEdges) UserOrErr() (*User, error) { 49 | if e.User != nil { 50 | return e.User, nil 51 | } else if e.loadedTypes[0] { 52 | return nil, &NotFoundError{label: user.Label} 53 | } 54 | return nil, &NotLoadedError{edge: "user"} 55 | } 56 | 57 | // ItemOrErr returns the Item value or an error if the edge 58 | // was not loaded in eager-loading, or loaded but was not found. 59 | func (e ReadEdges) ItemOrErr() (*Item, error) { 60 | if e.Item != nil { 61 | return e.Item, nil 62 | } else if e.loadedTypes[1] { 63 | return nil, &NotFoundError{label: item.Label} 64 | } 65 | return nil, &NotLoadedError{edge: "item"} 66 | } 67 | 68 | // scanValues returns the types for scanning values from sql.Rows. 69 | func (*Read) scanValues(columns []string) ([]any, error) { 70 | values := make([]any, len(columns)) 71 | for i := range columns { 72 | switch columns[i] { 73 | case read.FieldCreatedAt: 74 | values[i] = new(sql.NullTime) 75 | case read.FieldID, read.FieldUserID, read.FieldItemID: 76 | values[i] = new(uuid.UUID) 77 | default: 78 | values[i] = new(sql.UnknownType) 79 | } 80 | } 81 | return values, nil 82 | } 83 | 84 | // assignValues assigns the values that were returned from sql.Rows (after scanning) 85 | // to the Read fields. 86 | func (r *Read) assignValues(columns []string, values []any) error { 87 | if m, n := len(values), len(columns); m < n { 88 | return fmt.Errorf("mismatch number of scan values: %d != %d", m, n) 89 | } 90 | for i := range columns { 91 | switch columns[i] { 92 | case read.FieldID: 93 | if value, ok := values[i].(*uuid.UUID); !ok { 94 | return fmt.Errorf("unexpected type %T for field id", values[i]) 95 | } else if value != nil { 96 | r.ID = *value 97 | } 98 | case read.FieldUserID: 99 | if value, ok := values[i].(*uuid.UUID); !ok { 100 | return fmt.Errorf("unexpected type %T for field user_id", values[i]) 101 | } else if value != nil { 102 | r.UserID = *value 103 | } 104 | case read.FieldItemID: 105 | if value, ok := values[i].(*uuid.UUID); !ok { 106 | return fmt.Errorf("unexpected type %T for field item_id", values[i]) 107 | } else if value != nil { 108 | r.ItemID = *value 109 | } 110 | case read.FieldCreatedAt: 111 | if value, ok := values[i].(*sql.NullTime); !ok { 112 | return fmt.Errorf("unexpected type %T for field created_at", values[i]) 113 | } else if value.Valid { 114 | r.CreatedAt = value.Time 115 | } 116 | default: 117 | r.selectValues.Set(columns[i], values[i]) 118 | } 119 | } 120 | return nil 121 | } 122 | 123 | // Value returns the ent.Value that was dynamically selected and assigned to the Read. 124 | // This includes values selected through modifiers, order, etc. 125 | func (r *Read) Value(name string) (ent.Value, error) { 126 | return r.selectValues.Get(name) 127 | } 128 | 129 | // QueryUser queries the "user" edge of the Read entity. 130 | func (r *Read) QueryUser() *UserQuery { 131 | return NewReadClient(r.config).QueryUser(r) 132 | } 133 | 134 | // QueryItem queries the "item" edge of the Read entity. 135 | func (r *Read) QueryItem() *ItemQuery { 136 | return NewReadClient(r.config).QueryItem(r) 137 | } 138 | 139 | // Update returns a builder for updating this Read. 140 | // Note that you need to call Read.Unwrap() before calling this method if this Read 141 | // was returned from a transaction, and the transaction was committed or rolled back. 142 | func (r *Read) Update() *ReadUpdateOne { 143 | return NewReadClient(r.config).UpdateOne(r) 144 | } 145 | 146 | // Unwrap unwraps the Read entity that was returned from a transaction after it was closed, 147 | // so that all future queries will be executed through the driver which created the transaction. 148 | func (r *Read) Unwrap() *Read { 149 | _tx, ok := r.config.driver.(*txDriver) 150 | if !ok { 151 | panic("ent: Read is not a transactional entity") 152 | } 153 | r.config.driver = _tx.drv 154 | return r 155 | } 156 | 157 | // String implements the fmt.Stringer. 158 | func (r *Read) String() string { 159 | var builder strings.Builder 160 | builder.WriteString("Read(") 161 | builder.WriteString(fmt.Sprintf("id=%v, ", r.ID)) 162 | builder.WriteString("user_id=") 163 | builder.WriteString(fmt.Sprintf("%v", r.UserID)) 164 | builder.WriteString(", ") 165 | builder.WriteString("item_id=") 166 | builder.WriteString(fmt.Sprintf("%v", r.ItemID)) 167 | builder.WriteString(", ") 168 | builder.WriteString("created_at=") 169 | builder.WriteString(r.CreatedAt.Format(time.ANSIC)) 170 | builder.WriteByte(')') 171 | return builder.String() 172 | } 173 | 174 | // Reads is a parsable slice of Read. 175 | type Reads []*Read 176 | -------------------------------------------------------------------------------- /ent/read/read.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package read 4 | 5 | import ( 6 | "time" 7 | 8 | "entgo.io/ent/dialect/sql" 9 | "entgo.io/ent/dialect/sql/sqlgraph" 10 | "github.com/google/uuid" 11 | ) 12 | 13 | const ( 14 | // Label holds the string label denoting the read type in the database. 15 | Label = "read" 16 | // FieldID holds the string denoting the id field in the database. 17 | FieldID = "id" 18 | // FieldUserID holds the string denoting the user_id field in the database. 19 | FieldUserID = "user_id" 20 | // FieldItemID holds the string denoting the item_id field in the database. 21 | FieldItemID = "item_id" 22 | // FieldCreatedAt holds the string denoting the created_at field in the database. 23 | FieldCreatedAt = "created_at" 24 | // EdgeUser holds the string denoting the user edge name in mutations. 25 | EdgeUser = "user" 26 | // EdgeItem holds the string denoting the item edge name in mutations. 27 | EdgeItem = "item" 28 | // Table holds the table name of the read in the database. 29 | Table = "reads" 30 | // UserTable is the table that holds the user relation/edge. 31 | UserTable = "reads" 32 | // UserInverseTable is the table name for the User entity. 33 | // It exists in this package in order to avoid circular dependency with the "user" package. 34 | UserInverseTable = "users" 35 | // UserColumn is the table column denoting the user relation/edge. 36 | UserColumn = "user_id" 37 | // ItemTable is the table that holds the item relation/edge. 38 | ItemTable = "reads" 39 | // ItemInverseTable is the table name for the Item entity. 40 | // It exists in this package in order to avoid circular dependency with the "item" package. 41 | ItemInverseTable = "items" 42 | // ItemColumn is the table column denoting the item relation/edge. 43 | ItemColumn = "item_id" 44 | ) 45 | 46 | // Columns holds all SQL columns for read fields. 47 | var Columns = []string{ 48 | FieldID, 49 | FieldUserID, 50 | FieldItemID, 51 | FieldCreatedAt, 52 | } 53 | 54 | // ValidColumn reports if the column name is valid (part of the table columns). 55 | func ValidColumn(column string) bool { 56 | for i := range Columns { 57 | if column == Columns[i] { 58 | return true 59 | } 60 | } 61 | return false 62 | } 63 | 64 | var ( 65 | // DefaultCreatedAt holds the default value on creation for the "created_at" field. 66 | DefaultCreatedAt func() time.Time 67 | // DefaultID holds the default value on creation for the "id" field. 68 | DefaultID func() uuid.UUID 69 | ) 70 | 71 | // OrderOption defines the ordering options for the Read queries. 72 | type OrderOption func(*sql.Selector) 73 | 74 | // ByID orders the results by the id field. 75 | func ByID(opts ...sql.OrderTermOption) OrderOption { 76 | return sql.OrderByField(FieldID, opts...).ToFunc() 77 | } 78 | 79 | // ByUserID orders the results by the user_id field. 80 | func ByUserID(opts ...sql.OrderTermOption) OrderOption { 81 | return sql.OrderByField(FieldUserID, opts...).ToFunc() 82 | } 83 | 84 | // ByItemID orders the results by the item_id field. 85 | func ByItemID(opts ...sql.OrderTermOption) OrderOption { 86 | return sql.OrderByField(FieldItemID, opts...).ToFunc() 87 | } 88 | 89 | // ByCreatedAt orders the results by the created_at field. 90 | func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption { 91 | return sql.OrderByField(FieldCreatedAt, opts...).ToFunc() 92 | } 93 | 94 | // ByUserField orders the results by user field. 95 | func ByUserField(field string, opts ...sql.OrderTermOption) OrderOption { 96 | return func(s *sql.Selector) { 97 | sqlgraph.OrderByNeighborTerms(s, newUserStep(), sql.OrderByField(field, opts...)) 98 | } 99 | } 100 | 101 | // ByItemField orders the results by item field. 102 | func ByItemField(field string, opts ...sql.OrderTermOption) OrderOption { 103 | return func(s *sql.Selector) { 104 | sqlgraph.OrderByNeighborTerms(s, newItemStep(), sql.OrderByField(field, opts...)) 105 | } 106 | } 107 | func newUserStep() *sqlgraph.Step { 108 | return sqlgraph.NewStep( 109 | sqlgraph.From(Table, FieldID), 110 | sqlgraph.To(UserInverseTable, FieldID), 111 | sqlgraph.Edge(sqlgraph.M2O, false, UserTable, UserColumn), 112 | ) 113 | } 114 | func newItemStep() *sqlgraph.Step { 115 | return sqlgraph.NewStep( 116 | sqlgraph.From(Table, FieldID), 117 | sqlgraph.To(ItemInverseTable, FieldID), 118 | sqlgraph.Edge(sqlgraph.M2O, false, ItemTable, ItemColumn), 119 | ) 120 | } 121 | -------------------------------------------------------------------------------- /ent/read/where.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package read 4 | 5 | import ( 6 | "time" 7 | 8 | "entgo.io/ent/dialect/sql" 9 | "entgo.io/ent/dialect/sql/sqlgraph" 10 | "github.com/google/uuid" 11 | "github.com/mrusme/journalist/ent/predicate" 12 | ) 13 | 14 | // ID filters vertices based on their ID field. 15 | func ID(id uuid.UUID) predicate.Read { 16 | return predicate.Read(sql.FieldEQ(FieldID, id)) 17 | } 18 | 19 | // IDEQ applies the EQ predicate on the ID field. 20 | func IDEQ(id uuid.UUID) predicate.Read { 21 | return predicate.Read(sql.FieldEQ(FieldID, id)) 22 | } 23 | 24 | // IDNEQ applies the NEQ predicate on the ID field. 25 | func IDNEQ(id uuid.UUID) predicate.Read { 26 | return predicate.Read(sql.FieldNEQ(FieldID, id)) 27 | } 28 | 29 | // IDIn applies the In predicate on the ID field. 30 | func IDIn(ids ...uuid.UUID) predicate.Read { 31 | return predicate.Read(sql.FieldIn(FieldID, ids...)) 32 | } 33 | 34 | // IDNotIn applies the NotIn predicate on the ID field. 35 | func IDNotIn(ids ...uuid.UUID) predicate.Read { 36 | return predicate.Read(sql.FieldNotIn(FieldID, ids...)) 37 | } 38 | 39 | // IDGT applies the GT predicate on the ID field. 40 | func IDGT(id uuid.UUID) predicate.Read { 41 | return predicate.Read(sql.FieldGT(FieldID, id)) 42 | } 43 | 44 | // IDGTE applies the GTE predicate on the ID field. 45 | func IDGTE(id uuid.UUID) predicate.Read { 46 | return predicate.Read(sql.FieldGTE(FieldID, id)) 47 | } 48 | 49 | // IDLT applies the LT predicate on the ID field. 50 | func IDLT(id uuid.UUID) predicate.Read { 51 | return predicate.Read(sql.FieldLT(FieldID, id)) 52 | } 53 | 54 | // IDLTE applies the LTE predicate on the ID field. 55 | func IDLTE(id uuid.UUID) predicate.Read { 56 | return predicate.Read(sql.FieldLTE(FieldID, id)) 57 | } 58 | 59 | // UserID applies equality check predicate on the "user_id" field. It's identical to UserIDEQ. 60 | func UserID(v uuid.UUID) predicate.Read { 61 | return predicate.Read(sql.FieldEQ(FieldUserID, v)) 62 | } 63 | 64 | // ItemID applies equality check predicate on the "item_id" field. It's identical to ItemIDEQ. 65 | func ItemID(v uuid.UUID) predicate.Read { 66 | return predicate.Read(sql.FieldEQ(FieldItemID, v)) 67 | } 68 | 69 | // CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ. 70 | func CreatedAt(v time.Time) predicate.Read { 71 | return predicate.Read(sql.FieldEQ(FieldCreatedAt, v)) 72 | } 73 | 74 | // UserIDEQ applies the EQ predicate on the "user_id" field. 75 | func UserIDEQ(v uuid.UUID) predicate.Read { 76 | return predicate.Read(sql.FieldEQ(FieldUserID, v)) 77 | } 78 | 79 | // UserIDNEQ applies the NEQ predicate on the "user_id" field. 80 | func UserIDNEQ(v uuid.UUID) predicate.Read { 81 | return predicate.Read(sql.FieldNEQ(FieldUserID, v)) 82 | } 83 | 84 | // UserIDIn applies the In predicate on the "user_id" field. 85 | func UserIDIn(vs ...uuid.UUID) predicate.Read { 86 | return predicate.Read(sql.FieldIn(FieldUserID, vs...)) 87 | } 88 | 89 | // UserIDNotIn applies the NotIn predicate on the "user_id" field. 90 | func UserIDNotIn(vs ...uuid.UUID) predicate.Read { 91 | return predicate.Read(sql.FieldNotIn(FieldUserID, vs...)) 92 | } 93 | 94 | // ItemIDEQ applies the EQ predicate on the "item_id" field. 95 | func ItemIDEQ(v uuid.UUID) predicate.Read { 96 | return predicate.Read(sql.FieldEQ(FieldItemID, v)) 97 | } 98 | 99 | // ItemIDNEQ applies the NEQ predicate on the "item_id" field. 100 | func ItemIDNEQ(v uuid.UUID) predicate.Read { 101 | return predicate.Read(sql.FieldNEQ(FieldItemID, v)) 102 | } 103 | 104 | // ItemIDIn applies the In predicate on the "item_id" field. 105 | func ItemIDIn(vs ...uuid.UUID) predicate.Read { 106 | return predicate.Read(sql.FieldIn(FieldItemID, vs...)) 107 | } 108 | 109 | // ItemIDNotIn applies the NotIn predicate on the "item_id" field. 110 | func ItemIDNotIn(vs ...uuid.UUID) predicate.Read { 111 | return predicate.Read(sql.FieldNotIn(FieldItemID, vs...)) 112 | } 113 | 114 | // CreatedAtEQ applies the EQ predicate on the "created_at" field. 115 | func CreatedAtEQ(v time.Time) predicate.Read { 116 | return predicate.Read(sql.FieldEQ(FieldCreatedAt, v)) 117 | } 118 | 119 | // CreatedAtNEQ applies the NEQ predicate on the "created_at" field. 120 | func CreatedAtNEQ(v time.Time) predicate.Read { 121 | return predicate.Read(sql.FieldNEQ(FieldCreatedAt, v)) 122 | } 123 | 124 | // CreatedAtIn applies the In predicate on the "created_at" field. 125 | func CreatedAtIn(vs ...time.Time) predicate.Read { 126 | return predicate.Read(sql.FieldIn(FieldCreatedAt, vs...)) 127 | } 128 | 129 | // CreatedAtNotIn applies the NotIn predicate on the "created_at" field. 130 | func CreatedAtNotIn(vs ...time.Time) predicate.Read { 131 | return predicate.Read(sql.FieldNotIn(FieldCreatedAt, vs...)) 132 | } 133 | 134 | // CreatedAtGT applies the GT predicate on the "created_at" field. 135 | func CreatedAtGT(v time.Time) predicate.Read { 136 | return predicate.Read(sql.FieldGT(FieldCreatedAt, v)) 137 | } 138 | 139 | // CreatedAtGTE applies the GTE predicate on the "created_at" field. 140 | func CreatedAtGTE(v time.Time) predicate.Read { 141 | return predicate.Read(sql.FieldGTE(FieldCreatedAt, v)) 142 | } 143 | 144 | // CreatedAtLT applies the LT predicate on the "created_at" field. 145 | func CreatedAtLT(v time.Time) predicate.Read { 146 | return predicate.Read(sql.FieldLT(FieldCreatedAt, v)) 147 | } 148 | 149 | // CreatedAtLTE applies the LTE predicate on the "created_at" field. 150 | func CreatedAtLTE(v time.Time) predicate.Read { 151 | return predicate.Read(sql.FieldLTE(FieldCreatedAt, v)) 152 | } 153 | 154 | // HasUser applies the HasEdge predicate on the "user" edge. 155 | func HasUser() predicate.Read { 156 | return predicate.Read(func(s *sql.Selector) { 157 | step := sqlgraph.NewStep( 158 | sqlgraph.From(Table, FieldID), 159 | sqlgraph.Edge(sqlgraph.M2O, false, UserTable, UserColumn), 160 | ) 161 | sqlgraph.HasNeighbors(s, step) 162 | }) 163 | } 164 | 165 | // HasUserWith applies the HasEdge predicate on the "user" edge with a given conditions (other predicates). 166 | func HasUserWith(preds ...predicate.User) predicate.Read { 167 | return predicate.Read(func(s *sql.Selector) { 168 | step := newUserStep() 169 | sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { 170 | for _, p := range preds { 171 | p(s) 172 | } 173 | }) 174 | }) 175 | } 176 | 177 | // HasItem applies the HasEdge predicate on the "item" edge. 178 | func HasItem() predicate.Read { 179 | return predicate.Read(func(s *sql.Selector) { 180 | step := sqlgraph.NewStep( 181 | sqlgraph.From(Table, FieldID), 182 | sqlgraph.Edge(sqlgraph.M2O, false, ItemTable, ItemColumn), 183 | ) 184 | sqlgraph.HasNeighbors(s, step) 185 | }) 186 | } 187 | 188 | // HasItemWith applies the HasEdge predicate on the "item" edge with a given conditions (other predicates). 189 | func HasItemWith(preds ...predicate.Item) predicate.Read { 190 | return predicate.Read(func(s *sql.Selector) { 191 | step := newItemStep() 192 | sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { 193 | for _, p := range preds { 194 | p(s) 195 | } 196 | }) 197 | }) 198 | } 199 | 200 | // And groups predicates with the AND operator between them. 201 | func And(predicates ...predicate.Read) predicate.Read { 202 | return predicate.Read(sql.AndPredicates(predicates...)) 203 | } 204 | 205 | // Or groups predicates with the OR operator between them. 206 | func Or(predicates ...predicate.Read) predicate.Read { 207 | return predicate.Read(sql.OrPredicates(predicates...)) 208 | } 209 | 210 | // Not applies the not operator on the given predicate. 211 | func Not(p predicate.Read) predicate.Read { 212 | return predicate.Read(sql.NotPredicates(p)) 213 | } 214 | -------------------------------------------------------------------------------- /ent/read_delete.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package ent 4 | 5 | import ( 6 | "context" 7 | 8 | "entgo.io/ent/dialect/sql" 9 | "entgo.io/ent/dialect/sql/sqlgraph" 10 | "entgo.io/ent/schema/field" 11 | "github.com/mrusme/journalist/ent/predicate" 12 | "github.com/mrusme/journalist/ent/read" 13 | ) 14 | 15 | // ReadDelete is the builder for deleting a Read entity. 16 | type ReadDelete struct { 17 | config 18 | hooks []Hook 19 | mutation *ReadMutation 20 | } 21 | 22 | // Where appends a list predicates to the ReadDelete builder. 23 | func (rd *ReadDelete) Where(ps ...predicate.Read) *ReadDelete { 24 | rd.mutation.Where(ps...) 25 | return rd 26 | } 27 | 28 | // Exec executes the deletion query and returns how many vertices were deleted. 29 | func (rd *ReadDelete) Exec(ctx context.Context) (int, error) { 30 | return withHooks(ctx, rd.sqlExec, rd.mutation, rd.hooks) 31 | } 32 | 33 | // ExecX is like Exec, but panics if an error occurs. 34 | func (rd *ReadDelete) ExecX(ctx context.Context) int { 35 | n, err := rd.Exec(ctx) 36 | if err != nil { 37 | panic(err) 38 | } 39 | return n 40 | } 41 | 42 | func (rd *ReadDelete) sqlExec(ctx context.Context) (int, error) { 43 | _spec := sqlgraph.NewDeleteSpec(read.Table, sqlgraph.NewFieldSpec(read.FieldID, field.TypeUUID)) 44 | if ps := rd.mutation.predicates; len(ps) > 0 { 45 | _spec.Predicate = func(selector *sql.Selector) { 46 | for i := range ps { 47 | ps[i](selector) 48 | } 49 | } 50 | } 51 | affected, err := sqlgraph.DeleteNodes(ctx, rd.driver, _spec) 52 | if err != nil && sqlgraph.IsConstraintError(err) { 53 | err = &ConstraintError{msg: err.Error(), wrap: err} 54 | } 55 | rd.mutation.done = true 56 | return affected, err 57 | } 58 | 59 | // ReadDeleteOne is the builder for deleting a single Read entity. 60 | type ReadDeleteOne struct { 61 | rd *ReadDelete 62 | } 63 | 64 | // Where appends a list predicates to the ReadDelete builder. 65 | func (rdo *ReadDeleteOne) Where(ps ...predicate.Read) *ReadDeleteOne { 66 | rdo.rd.mutation.Where(ps...) 67 | return rdo 68 | } 69 | 70 | // Exec executes the deletion query. 71 | func (rdo *ReadDeleteOne) Exec(ctx context.Context) error { 72 | n, err := rdo.rd.Exec(ctx) 73 | switch { 74 | case err != nil: 75 | return err 76 | case n == 0: 77 | return &NotFoundError{read.Label} 78 | default: 79 | return nil 80 | } 81 | } 82 | 83 | // ExecX is like Exec, but panics if an error occurs. 84 | func (rdo *ReadDeleteOne) ExecX(ctx context.Context) { 85 | if err := rdo.Exec(ctx); err != nil { 86 | panic(err) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /ent/runtime/runtime.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package runtime 4 | 5 | // The schema-stitching logic is generated in github.com/mrusme/journalist/ent/runtime.go 6 | 7 | const ( 8 | Version = "v0.13.1" // Version of ent codegen. 9 | Sum = "h1:uD8QwN1h6SNphdCCzmkMN3feSUzNnVvV/WIkHKMbzOE=" // Sum of ent codegen. 10 | ) 11 | -------------------------------------------------------------------------------- /ent/schema/feed.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/go-playground/validator/v10" 7 | 8 | "entgo.io/ent" 9 | "entgo.io/ent/schema/edge" 10 | "entgo.io/ent/schema/field" 11 | "entgo.io/ent/schema/index" 12 | "github.com/google/uuid" 13 | ) 14 | 15 | // Feed holds the schema definition for the Feed entity. 16 | type Feed struct { 17 | ent.Schema 18 | } 19 | 20 | // Fields of the Feed. 21 | func (Feed) Fields() []ent.Field { 22 | validate := validator.New() 23 | 24 | return []ent.Field{ 25 | field.UUID("id", uuid.UUID{}). 26 | Default(uuid.New), 27 | // StorageKey("oid"), 28 | field.String("url"). 29 | Validate(func(s string) error { 30 | return validate.Var(s, "required,url") 31 | }), 32 | field.String("username"). 33 | Default(""). 34 | Sensitive(), 35 | field.String("password"). 36 | Default(""). 37 | Sensitive(), 38 | 39 | field.String("feed_title"), 40 | field.String("feed_description"), 41 | field.String("feed_link"), 42 | field.String("feed_feed_link"), 43 | field.Time("feed_updated"), 44 | field.Time("feed_published"), 45 | field.String("feed_author_name"). 46 | Optional(), 47 | field.String("feed_author_email"). 48 | Optional(), 49 | field.String("feed_language"), 50 | field.String("feed_image_title"). 51 | Optional(), 52 | field.String("feed_image_url"). 53 | Optional(), 54 | field.String("feed_copyright"), 55 | field.String("feed_generator"), 56 | field.String("feed_categories"), 57 | 58 | field.Time("created_at"). 59 | Default(time.Now), 60 | field.Time("updated_at"). 61 | Default(time.Now). 62 | UpdateDefault(time.Now), 63 | field.Time("deleted_at"). 64 | Default(nil). 65 | Optional(). 66 | Nillable(), 67 | } 68 | } 69 | 70 | func (Feed) Indexes() []ent.Index { 71 | return []ent.Index{ 72 | index.Fields("url", "username", "password"). 73 | Unique(), 74 | } 75 | } 76 | 77 | // Edges of the Feed. 78 | func (Feed) Edges() []ent.Edge { 79 | return []ent.Edge{ 80 | edge.To("items", Item.Type), 81 | edge.From("subscribed_users", User.Type). 82 | Ref("subscribed_feeds"). 83 | Through("subscriptions", Subscription.Type), 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /ent/schema/item.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/go-playground/validator/v10" 7 | 8 | "entgo.io/ent" 9 | "entgo.io/ent/schema/edge" 10 | "entgo.io/ent/schema/field" 11 | "entgo.io/ent/schema/index" 12 | "github.com/google/uuid" 13 | ) 14 | 15 | // Item holds the schema definition for the Item entity. 16 | type Item struct { 17 | ent.Schema 18 | } 19 | 20 | // Fields of the Item. 21 | func (Item) Fields() []ent.Field { 22 | validate := validator.New() 23 | 24 | return []ent.Field{ 25 | field.UUID("id", uuid.UUID{}). 26 | Default(uuid.New), 27 | // StorageKey("oid"), 28 | 29 | field.String("item_guid"). 30 | Unique(), 31 | field.String("item_title"), 32 | field.String("item_description"), 33 | field.String("item_content"), 34 | field.String("item_link"). 35 | Validate(func(s string) error { 36 | return validate.Var(s, "required,url") 37 | }), 38 | field.Time("item_updated"), 39 | field.Time("item_published"), 40 | field.String("item_author_name"). 41 | Optional(), 42 | field.String("item_author_email"). 43 | Optional(), 44 | field.String("item_image_title"). 45 | Optional(), 46 | field.String("item_image_url"). 47 | Optional(), 48 | field.String("item_categories"), 49 | field.String("item_enclosures"), 50 | 51 | field.String("crawler_title"). 52 | Optional(), 53 | field.String("crawler_author"). 54 | Optional(), 55 | field.String("crawler_excerpt"). 56 | Optional(), 57 | field.String("crawler_site_name"). 58 | Optional(), 59 | field.String("crawler_image"). 60 | Optional(), 61 | field.String("crawler_content_html"). 62 | Optional(), 63 | field.String("crawler_content_text"). 64 | Optional(), 65 | 66 | field.Time("created_at"). 67 | Default(time.Now), 68 | field.Time("updated_at"). 69 | Default(time.Now). 70 | UpdateDefault(time.Now), 71 | } 72 | } 73 | 74 | func (Item) Indexes() []ent.Index { 75 | return []ent.Index{ 76 | index.Fields("item_guid"). 77 | Unique(), 78 | } 79 | } 80 | 81 | // Edges of the Item. 82 | func (Item) Edges() []ent.Edge { 83 | return []ent.Edge{ 84 | edge.From("feed", Feed.Type). 85 | Ref("items"). 86 | Unique(), 87 | edge.From("read_by_users", User.Type). 88 | Ref("read_items"). 89 | Through("reads", Read.Type), 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /ent/schema/read.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "time" 5 | 6 | "entgo.io/ent" 7 | "entgo.io/ent/schema/edge" 8 | "entgo.io/ent/schema/field" 9 | "github.com/google/uuid" 10 | ) 11 | 12 | // Read holds the schema definition for the Read entity. 13 | type Read struct { 14 | ent.Schema 15 | } 16 | 17 | // Fields of the Read. 18 | func (Read) Fields() []ent.Field { 19 | return []ent.Field{ 20 | field.UUID("id", uuid.UUID{}). 21 | Default(uuid.New), 22 | // StorageKey("oid"), 23 | 24 | field.UUID("user_id", uuid.UUID{}), 25 | field.UUID("item_id", uuid.UUID{}), 26 | 27 | field.Time("created_at"). 28 | Default(time.Now), 29 | } 30 | } 31 | 32 | // Edges of the Read. 33 | func (Read) Edges() []ent.Edge { 34 | return []ent.Edge{ 35 | edge.To("user", User.Type). 36 | Unique(). 37 | Required(). 38 | Field("user_id"), 39 | edge.To("item", Item.Type). 40 | Unique(). 41 | Required(). 42 | Field("item_id"), 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /ent/schema/subscription.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/go-playground/validator/v10" 7 | 8 | "entgo.io/ent" 9 | "entgo.io/ent/schema/edge" 10 | "entgo.io/ent/schema/field" 11 | "github.com/google/uuid" 12 | ) 13 | 14 | // Subscription holds the schema definition for the Subscription entity. 15 | type Subscription struct { 16 | ent.Schema 17 | } 18 | 19 | // Fields of the Subscription. 20 | func (Subscription) Fields() []ent.Field { 21 | validate := validator.New() 22 | 23 | return []ent.Field{ 24 | field.UUID("id", uuid.UUID{}). 25 | Default(uuid.New), 26 | // StorageKey("oid"), 27 | 28 | field.UUID("user_id", uuid.UUID{}), 29 | field.UUID("feed_id", uuid.UUID{}), 30 | 31 | field.String("name"). 32 | Validate(func(s string) error { 33 | return validate.Var(s, "required") 34 | }), 35 | field.String("group"). 36 | Validate(func(s string) error { 37 | return validate.Var(s, "required,alphanum,max=32") 38 | }), 39 | field.Time("created_at"). 40 | Default(time.Now), 41 | } 42 | } 43 | 44 | // Edges of the Subscription. 45 | func (Subscription) Edges() []ent.Edge { 46 | return []ent.Edge{ 47 | edge.To("user", User.Type). 48 | Unique(). 49 | Required(). 50 | Field("user_id"), 51 | edge.To("feed", Feed.Type). 52 | Unique(). 53 | Required(). 54 | Field("feed_id"), 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /ent/schema/token.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "regexp" 5 | "time" 6 | 7 | "github.com/go-playground/validator/v10" 8 | 9 | "entgo.io/ent" 10 | "entgo.io/ent/schema/edge" 11 | "entgo.io/ent/schema/field" 12 | "github.com/google/uuid" 13 | ) 14 | 15 | // Token holds the schema definition for the Token entity. 16 | type Token struct { 17 | ent.Schema 18 | } 19 | 20 | // Fields of the Token. 21 | func (Token) Fields() []ent.Field { 22 | validate := validator.New() 23 | 24 | return []ent.Field{ 25 | field.UUID("id", uuid.UUID{}). 26 | Default(uuid.New), 27 | // StorageKey("oid"), 28 | field.String("type"). 29 | Default("qat"). 30 | Match(regexp.MustCompile("^(qat|jwt)$")), 31 | field.String("name"). 32 | Validate(func(s string) error { 33 | return validate.Var(s, "required,alphanum,max=32") 34 | }), 35 | field.String("token"). 36 | Unique(). 37 | Sensitive(), 38 | field.Time("created_at"). 39 | Default(time.Now), 40 | field.Time("updated_at"). 41 | Default(time.Now). 42 | UpdateDefault(time.Now), 43 | field.Time("deleted_at"). 44 | Default(nil). 45 | Optional(). 46 | Nillable(), 47 | } 48 | } 49 | 50 | // Edges of the Token. 51 | func (Token) Edges() []ent.Edge { 52 | return []ent.Edge{ 53 | edge.From("owner", User.Type). 54 | Ref("tokens"). 55 | Unique(), 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /ent/schema/user.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "regexp" 5 | "time" 6 | 7 | "github.com/go-playground/validator/v10" 8 | 9 | "entgo.io/ent" 10 | "entgo.io/ent/schema/edge" 11 | "entgo.io/ent/schema/field" 12 | "github.com/google/uuid" 13 | ) 14 | 15 | // User holds the schema definition for the User entity. 16 | type User struct { 17 | ent.Schema 18 | } 19 | 20 | // Fields of the User. 21 | func (User) Fields() []ent.Field { 22 | validate := validator.New() 23 | 24 | return []ent.Field{ 25 | field.UUID("id", uuid.UUID{}). 26 | Default(uuid.New), 27 | // StorageKey("oid"), 28 | field.String("username"). 29 | Validate(func(s string) error { 30 | return validate.Var(s, "required,alphanum,max=32") 31 | }). 32 | Unique(), 33 | field.String("password"). 34 | Validate(func(s string) error { 35 | return validate.Var(s, "required,min=5") 36 | }). 37 | Sensitive(), 38 | field.String("role"). 39 | Default("user"). 40 | Match(regexp.MustCompile("^(admin|user)$")), 41 | field.Time("created_at"). 42 | Default(time.Now), 43 | field.Time("updated_at"). 44 | Default(time.Now). 45 | UpdateDefault(time.Now), 46 | field.Time("deleted_at"). 47 | Default(nil). 48 | Optional(). 49 | Nillable(), 50 | } 51 | } 52 | 53 | // Edges of the User. 54 | func (User) Edges() []ent.Edge { 55 | return []ent.Edge{ 56 | edge.To("tokens", Token.Type), 57 | edge.To("subscribed_feeds", Feed.Type). 58 | Through("subscriptions", Subscription.Type), 59 | edge.To("read_items", Item.Type). 60 | Through("reads", Read.Type), 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /ent/subscription.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package ent 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | "entgo.io/ent" 11 | "entgo.io/ent/dialect/sql" 12 | "github.com/google/uuid" 13 | "github.com/mrusme/journalist/ent/feed" 14 | "github.com/mrusme/journalist/ent/subscription" 15 | "github.com/mrusme/journalist/ent/user" 16 | ) 17 | 18 | // Subscription is the model entity for the Subscription schema. 19 | type Subscription struct { 20 | config `json:"-"` 21 | // ID of the ent. 22 | ID uuid.UUID `json:"id,omitempty"` 23 | // UserID holds the value of the "user_id" field. 24 | UserID uuid.UUID `json:"user_id,omitempty"` 25 | // FeedID holds the value of the "feed_id" field. 26 | FeedID uuid.UUID `json:"feed_id,omitempty"` 27 | // Name holds the value of the "name" field. 28 | Name string `json:"name,omitempty"` 29 | // Group holds the value of the "group" field. 30 | Group string `json:"group,omitempty"` 31 | // CreatedAt holds the value of the "created_at" field. 32 | CreatedAt time.Time `json:"created_at,omitempty"` 33 | // Edges holds the relations/edges for other nodes in the graph. 34 | // The values are being populated by the SubscriptionQuery when eager-loading is set. 35 | Edges SubscriptionEdges `json:"edges"` 36 | selectValues sql.SelectValues 37 | } 38 | 39 | // SubscriptionEdges holds the relations/edges for other nodes in the graph. 40 | type SubscriptionEdges struct { 41 | // User holds the value of the user edge. 42 | User *User `json:"user,omitempty"` 43 | // Feed holds the value of the feed edge. 44 | Feed *Feed `json:"feed,omitempty"` 45 | // loadedTypes holds the information for reporting if a 46 | // type was loaded (or requested) in eager-loading or not. 47 | loadedTypes [2]bool 48 | } 49 | 50 | // UserOrErr returns the User value or an error if the edge 51 | // was not loaded in eager-loading, or loaded but was not found. 52 | func (e SubscriptionEdges) UserOrErr() (*User, error) { 53 | if e.User != nil { 54 | return e.User, nil 55 | } else if e.loadedTypes[0] { 56 | return nil, &NotFoundError{label: user.Label} 57 | } 58 | return nil, &NotLoadedError{edge: "user"} 59 | } 60 | 61 | // FeedOrErr returns the Feed value or an error if the edge 62 | // was not loaded in eager-loading, or loaded but was not found. 63 | func (e SubscriptionEdges) FeedOrErr() (*Feed, error) { 64 | if e.Feed != nil { 65 | return e.Feed, nil 66 | } else if e.loadedTypes[1] { 67 | return nil, &NotFoundError{label: feed.Label} 68 | } 69 | return nil, &NotLoadedError{edge: "feed"} 70 | } 71 | 72 | // scanValues returns the types for scanning values from sql.Rows. 73 | func (*Subscription) scanValues(columns []string) ([]any, error) { 74 | values := make([]any, len(columns)) 75 | for i := range columns { 76 | switch columns[i] { 77 | case subscription.FieldName, subscription.FieldGroup: 78 | values[i] = new(sql.NullString) 79 | case subscription.FieldCreatedAt: 80 | values[i] = new(sql.NullTime) 81 | case subscription.FieldID, subscription.FieldUserID, subscription.FieldFeedID: 82 | values[i] = new(uuid.UUID) 83 | default: 84 | values[i] = new(sql.UnknownType) 85 | } 86 | } 87 | return values, nil 88 | } 89 | 90 | // assignValues assigns the values that were returned from sql.Rows (after scanning) 91 | // to the Subscription fields. 92 | func (s *Subscription) assignValues(columns []string, values []any) error { 93 | if m, n := len(values), len(columns); m < n { 94 | return fmt.Errorf("mismatch number of scan values: %d != %d", m, n) 95 | } 96 | for i := range columns { 97 | switch columns[i] { 98 | case subscription.FieldID: 99 | if value, ok := values[i].(*uuid.UUID); !ok { 100 | return fmt.Errorf("unexpected type %T for field id", values[i]) 101 | } else if value != nil { 102 | s.ID = *value 103 | } 104 | case subscription.FieldUserID: 105 | if value, ok := values[i].(*uuid.UUID); !ok { 106 | return fmt.Errorf("unexpected type %T for field user_id", values[i]) 107 | } else if value != nil { 108 | s.UserID = *value 109 | } 110 | case subscription.FieldFeedID: 111 | if value, ok := values[i].(*uuid.UUID); !ok { 112 | return fmt.Errorf("unexpected type %T for field feed_id", values[i]) 113 | } else if value != nil { 114 | s.FeedID = *value 115 | } 116 | case subscription.FieldName: 117 | if value, ok := values[i].(*sql.NullString); !ok { 118 | return fmt.Errorf("unexpected type %T for field name", values[i]) 119 | } else if value.Valid { 120 | s.Name = value.String 121 | } 122 | case subscription.FieldGroup: 123 | if value, ok := values[i].(*sql.NullString); !ok { 124 | return fmt.Errorf("unexpected type %T for field group", values[i]) 125 | } else if value.Valid { 126 | s.Group = value.String 127 | } 128 | case subscription.FieldCreatedAt: 129 | if value, ok := values[i].(*sql.NullTime); !ok { 130 | return fmt.Errorf("unexpected type %T for field created_at", values[i]) 131 | } else if value.Valid { 132 | s.CreatedAt = value.Time 133 | } 134 | default: 135 | s.selectValues.Set(columns[i], values[i]) 136 | } 137 | } 138 | return nil 139 | } 140 | 141 | // Value returns the ent.Value that was dynamically selected and assigned to the Subscription. 142 | // This includes values selected through modifiers, order, etc. 143 | func (s *Subscription) Value(name string) (ent.Value, error) { 144 | return s.selectValues.Get(name) 145 | } 146 | 147 | // QueryUser queries the "user" edge of the Subscription entity. 148 | func (s *Subscription) QueryUser() *UserQuery { 149 | return NewSubscriptionClient(s.config).QueryUser(s) 150 | } 151 | 152 | // QueryFeed queries the "feed" edge of the Subscription entity. 153 | func (s *Subscription) QueryFeed() *FeedQuery { 154 | return NewSubscriptionClient(s.config).QueryFeed(s) 155 | } 156 | 157 | // Update returns a builder for updating this Subscription. 158 | // Note that you need to call Subscription.Unwrap() before calling this method if this Subscription 159 | // was returned from a transaction, and the transaction was committed or rolled back. 160 | func (s *Subscription) Update() *SubscriptionUpdateOne { 161 | return NewSubscriptionClient(s.config).UpdateOne(s) 162 | } 163 | 164 | // Unwrap unwraps the Subscription entity that was returned from a transaction after it was closed, 165 | // so that all future queries will be executed through the driver which created the transaction. 166 | func (s *Subscription) Unwrap() *Subscription { 167 | _tx, ok := s.config.driver.(*txDriver) 168 | if !ok { 169 | panic("ent: Subscription is not a transactional entity") 170 | } 171 | s.config.driver = _tx.drv 172 | return s 173 | } 174 | 175 | // String implements the fmt.Stringer. 176 | func (s *Subscription) String() string { 177 | var builder strings.Builder 178 | builder.WriteString("Subscription(") 179 | builder.WriteString(fmt.Sprintf("id=%v, ", s.ID)) 180 | builder.WriteString("user_id=") 181 | builder.WriteString(fmt.Sprintf("%v", s.UserID)) 182 | builder.WriteString(", ") 183 | builder.WriteString("feed_id=") 184 | builder.WriteString(fmt.Sprintf("%v", s.FeedID)) 185 | builder.WriteString(", ") 186 | builder.WriteString("name=") 187 | builder.WriteString(s.Name) 188 | builder.WriteString(", ") 189 | builder.WriteString("group=") 190 | builder.WriteString(s.Group) 191 | builder.WriteString(", ") 192 | builder.WriteString("created_at=") 193 | builder.WriteString(s.CreatedAt.Format(time.ANSIC)) 194 | builder.WriteByte(')') 195 | return builder.String() 196 | } 197 | 198 | // Subscriptions is a parsable slice of Subscription. 199 | type Subscriptions []*Subscription 200 | -------------------------------------------------------------------------------- /ent/subscription/subscription.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package subscription 4 | 5 | import ( 6 | "time" 7 | 8 | "entgo.io/ent/dialect/sql" 9 | "entgo.io/ent/dialect/sql/sqlgraph" 10 | "github.com/google/uuid" 11 | ) 12 | 13 | const ( 14 | // Label holds the string label denoting the subscription type in the database. 15 | Label = "subscription" 16 | // FieldID holds the string denoting the id field in the database. 17 | FieldID = "id" 18 | // FieldUserID holds the string denoting the user_id field in the database. 19 | FieldUserID = "user_id" 20 | // FieldFeedID holds the string denoting the feed_id field in the database. 21 | FieldFeedID = "feed_id" 22 | // FieldName holds the string denoting the name field in the database. 23 | FieldName = "name" 24 | // FieldGroup holds the string denoting the group field in the database. 25 | FieldGroup = "group" 26 | // FieldCreatedAt holds the string denoting the created_at field in the database. 27 | FieldCreatedAt = "created_at" 28 | // EdgeUser holds the string denoting the user edge name in mutations. 29 | EdgeUser = "user" 30 | // EdgeFeed holds the string denoting the feed edge name in mutations. 31 | EdgeFeed = "feed" 32 | // Table holds the table name of the subscription in the database. 33 | Table = "subscriptions" 34 | // UserTable is the table that holds the user relation/edge. 35 | UserTable = "subscriptions" 36 | // UserInverseTable is the table name for the User entity. 37 | // It exists in this package in order to avoid circular dependency with the "user" package. 38 | UserInverseTable = "users" 39 | // UserColumn is the table column denoting the user relation/edge. 40 | UserColumn = "user_id" 41 | // FeedTable is the table that holds the feed relation/edge. 42 | FeedTable = "subscriptions" 43 | // FeedInverseTable is the table name for the Feed entity. 44 | // It exists in this package in order to avoid circular dependency with the "feed" package. 45 | FeedInverseTable = "feeds" 46 | // FeedColumn is the table column denoting the feed relation/edge. 47 | FeedColumn = "feed_id" 48 | ) 49 | 50 | // Columns holds all SQL columns for subscription fields. 51 | var Columns = []string{ 52 | FieldID, 53 | FieldUserID, 54 | FieldFeedID, 55 | FieldName, 56 | FieldGroup, 57 | FieldCreatedAt, 58 | } 59 | 60 | // ValidColumn reports if the column name is valid (part of the table columns). 61 | func ValidColumn(column string) bool { 62 | for i := range Columns { 63 | if column == Columns[i] { 64 | return true 65 | } 66 | } 67 | return false 68 | } 69 | 70 | var ( 71 | // NameValidator is a validator for the "name" field. It is called by the builders before save. 72 | NameValidator func(string) error 73 | // GroupValidator is a validator for the "group" field. It is called by the builders before save. 74 | GroupValidator func(string) error 75 | // DefaultCreatedAt holds the default value on creation for the "created_at" field. 76 | DefaultCreatedAt func() time.Time 77 | // DefaultID holds the default value on creation for the "id" field. 78 | DefaultID func() uuid.UUID 79 | ) 80 | 81 | // OrderOption defines the ordering options for the Subscription queries. 82 | type OrderOption func(*sql.Selector) 83 | 84 | // ByID orders the results by the id field. 85 | func ByID(opts ...sql.OrderTermOption) OrderOption { 86 | return sql.OrderByField(FieldID, opts...).ToFunc() 87 | } 88 | 89 | // ByUserID orders the results by the user_id field. 90 | func ByUserID(opts ...sql.OrderTermOption) OrderOption { 91 | return sql.OrderByField(FieldUserID, opts...).ToFunc() 92 | } 93 | 94 | // ByFeedID orders the results by the feed_id field. 95 | func ByFeedID(opts ...sql.OrderTermOption) OrderOption { 96 | return sql.OrderByField(FieldFeedID, opts...).ToFunc() 97 | } 98 | 99 | // ByName orders the results by the name field. 100 | func ByName(opts ...sql.OrderTermOption) OrderOption { 101 | return sql.OrderByField(FieldName, opts...).ToFunc() 102 | } 103 | 104 | // ByGroup orders the results by the group field. 105 | func ByGroup(opts ...sql.OrderTermOption) OrderOption { 106 | return sql.OrderByField(FieldGroup, opts...).ToFunc() 107 | } 108 | 109 | // ByCreatedAt orders the results by the created_at field. 110 | func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption { 111 | return sql.OrderByField(FieldCreatedAt, opts...).ToFunc() 112 | } 113 | 114 | // ByUserField orders the results by user field. 115 | func ByUserField(field string, opts ...sql.OrderTermOption) OrderOption { 116 | return func(s *sql.Selector) { 117 | sqlgraph.OrderByNeighborTerms(s, newUserStep(), sql.OrderByField(field, opts...)) 118 | } 119 | } 120 | 121 | // ByFeedField orders the results by feed field. 122 | func ByFeedField(field string, opts ...sql.OrderTermOption) OrderOption { 123 | return func(s *sql.Selector) { 124 | sqlgraph.OrderByNeighborTerms(s, newFeedStep(), sql.OrderByField(field, opts...)) 125 | } 126 | } 127 | func newUserStep() *sqlgraph.Step { 128 | return sqlgraph.NewStep( 129 | sqlgraph.From(Table, FieldID), 130 | sqlgraph.To(UserInverseTable, FieldID), 131 | sqlgraph.Edge(sqlgraph.M2O, false, UserTable, UserColumn), 132 | ) 133 | } 134 | func newFeedStep() *sqlgraph.Step { 135 | return sqlgraph.NewStep( 136 | sqlgraph.From(Table, FieldID), 137 | sqlgraph.To(FeedInverseTable, FieldID), 138 | sqlgraph.Edge(sqlgraph.M2O, false, FeedTable, FeedColumn), 139 | ) 140 | } 141 | -------------------------------------------------------------------------------- /ent/subscription_delete.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package ent 4 | 5 | import ( 6 | "context" 7 | 8 | "entgo.io/ent/dialect/sql" 9 | "entgo.io/ent/dialect/sql/sqlgraph" 10 | "entgo.io/ent/schema/field" 11 | "github.com/mrusme/journalist/ent/predicate" 12 | "github.com/mrusme/journalist/ent/subscription" 13 | ) 14 | 15 | // SubscriptionDelete is the builder for deleting a Subscription entity. 16 | type SubscriptionDelete struct { 17 | config 18 | hooks []Hook 19 | mutation *SubscriptionMutation 20 | } 21 | 22 | // Where appends a list predicates to the SubscriptionDelete builder. 23 | func (sd *SubscriptionDelete) Where(ps ...predicate.Subscription) *SubscriptionDelete { 24 | sd.mutation.Where(ps...) 25 | return sd 26 | } 27 | 28 | // Exec executes the deletion query and returns how many vertices were deleted. 29 | func (sd *SubscriptionDelete) Exec(ctx context.Context) (int, error) { 30 | return withHooks(ctx, sd.sqlExec, sd.mutation, sd.hooks) 31 | } 32 | 33 | // ExecX is like Exec, but panics if an error occurs. 34 | func (sd *SubscriptionDelete) ExecX(ctx context.Context) int { 35 | n, err := sd.Exec(ctx) 36 | if err != nil { 37 | panic(err) 38 | } 39 | return n 40 | } 41 | 42 | func (sd *SubscriptionDelete) sqlExec(ctx context.Context) (int, error) { 43 | _spec := sqlgraph.NewDeleteSpec(subscription.Table, sqlgraph.NewFieldSpec(subscription.FieldID, field.TypeUUID)) 44 | if ps := sd.mutation.predicates; len(ps) > 0 { 45 | _spec.Predicate = func(selector *sql.Selector) { 46 | for i := range ps { 47 | ps[i](selector) 48 | } 49 | } 50 | } 51 | affected, err := sqlgraph.DeleteNodes(ctx, sd.driver, _spec) 52 | if err != nil && sqlgraph.IsConstraintError(err) { 53 | err = &ConstraintError{msg: err.Error(), wrap: err} 54 | } 55 | sd.mutation.done = true 56 | return affected, err 57 | } 58 | 59 | // SubscriptionDeleteOne is the builder for deleting a single Subscription entity. 60 | type SubscriptionDeleteOne struct { 61 | sd *SubscriptionDelete 62 | } 63 | 64 | // Where appends a list predicates to the SubscriptionDelete builder. 65 | func (sdo *SubscriptionDeleteOne) Where(ps ...predicate.Subscription) *SubscriptionDeleteOne { 66 | sdo.sd.mutation.Where(ps...) 67 | return sdo 68 | } 69 | 70 | // Exec executes the deletion query. 71 | func (sdo *SubscriptionDeleteOne) Exec(ctx context.Context) error { 72 | n, err := sdo.sd.Exec(ctx) 73 | switch { 74 | case err != nil: 75 | return err 76 | case n == 0: 77 | return &NotFoundError{subscription.Label} 78 | default: 79 | return nil 80 | } 81 | } 82 | 83 | // ExecX is like Exec, but panics if an error occurs. 84 | func (sdo *SubscriptionDeleteOne) ExecX(ctx context.Context) { 85 | if err := sdo.Exec(ctx); err != nil { 86 | panic(err) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /ent/token.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package ent 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | "entgo.io/ent" 11 | "entgo.io/ent/dialect/sql" 12 | "github.com/google/uuid" 13 | "github.com/mrusme/journalist/ent/token" 14 | "github.com/mrusme/journalist/ent/user" 15 | ) 16 | 17 | // Token is the model entity for the Token schema. 18 | type Token struct { 19 | config `json:"-"` 20 | // ID of the ent. 21 | ID uuid.UUID `json:"id,omitempty"` 22 | // Type holds the value of the "type" field. 23 | Type string `json:"type,omitempty"` 24 | // Name holds the value of the "name" field. 25 | Name string `json:"name,omitempty"` 26 | // Token holds the value of the "token" field. 27 | Token string `json:"-"` 28 | // CreatedAt holds the value of the "created_at" field. 29 | CreatedAt time.Time `json:"created_at,omitempty"` 30 | // UpdatedAt holds the value of the "updated_at" field. 31 | UpdatedAt time.Time `json:"updated_at,omitempty"` 32 | // DeletedAt holds the value of the "deleted_at" field. 33 | DeletedAt *time.Time `json:"deleted_at,omitempty"` 34 | // Edges holds the relations/edges for other nodes in the graph. 35 | // The values are being populated by the TokenQuery when eager-loading is set. 36 | Edges TokenEdges `json:"edges"` 37 | user_tokens *uuid.UUID 38 | selectValues sql.SelectValues 39 | } 40 | 41 | // TokenEdges holds the relations/edges for other nodes in the graph. 42 | type TokenEdges struct { 43 | // Owner holds the value of the owner edge. 44 | Owner *User `json:"owner,omitempty"` 45 | // loadedTypes holds the information for reporting if a 46 | // type was loaded (or requested) in eager-loading or not. 47 | loadedTypes [1]bool 48 | } 49 | 50 | // OwnerOrErr returns the Owner value or an error if the edge 51 | // was not loaded in eager-loading, or loaded but was not found. 52 | func (e TokenEdges) OwnerOrErr() (*User, error) { 53 | if e.Owner != nil { 54 | return e.Owner, nil 55 | } else if e.loadedTypes[0] { 56 | return nil, &NotFoundError{label: user.Label} 57 | } 58 | return nil, &NotLoadedError{edge: "owner"} 59 | } 60 | 61 | // scanValues returns the types for scanning values from sql.Rows. 62 | func (*Token) scanValues(columns []string) ([]any, error) { 63 | values := make([]any, len(columns)) 64 | for i := range columns { 65 | switch columns[i] { 66 | case token.FieldType, token.FieldName, token.FieldToken: 67 | values[i] = new(sql.NullString) 68 | case token.FieldCreatedAt, token.FieldUpdatedAt, token.FieldDeletedAt: 69 | values[i] = new(sql.NullTime) 70 | case token.FieldID: 71 | values[i] = new(uuid.UUID) 72 | case token.ForeignKeys[0]: // user_tokens 73 | values[i] = &sql.NullScanner{S: new(uuid.UUID)} 74 | default: 75 | values[i] = new(sql.UnknownType) 76 | } 77 | } 78 | return values, nil 79 | } 80 | 81 | // assignValues assigns the values that were returned from sql.Rows (after scanning) 82 | // to the Token fields. 83 | func (t *Token) assignValues(columns []string, values []any) error { 84 | if m, n := len(values), len(columns); m < n { 85 | return fmt.Errorf("mismatch number of scan values: %d != %d", m, n) 86 | } 87 | for i := range columns { 88 | switch columns[i] { 89 | case token.FieldID: 90 | if value, ok := values[i].(*uuid.UUID); !ok { 91 | return fmt.Errorf("unexpected type %T for field id", values[i]) 92 | } else if value != nil { 93 | t.ID = *value 94 | } 95 | case token.FieldType: 96 | if value, ok := values[i].(*sql.NullString); !ok { 97 | return fmt.Errorf("unexpected type %T for field type", values[i]) 98 | } else if value.Valid { 99 | t.Type = value.String 100 | } 101 | case token.FieldName: 102 | if value, ok := values[i].(*sql.NullString); !ok { 103 | return fmt.Errorf("unexpected type %T for field name", values[i]) 104 | } else if value.Valid { 105 | t.Name = value.String 106 | } 107 | case token.FieldToken: 108 | if value, ok := values[i].(*sql.NullString); !ok { 109 | return fmt.Errorf("unexpected type %T for field token", values[i]) 110 | } else if value.Valid { 111 | t.Token = value.String 112 | } 113 | case token.FieldCreatedAt: 114 | if value, ok := values[i].(*sql.NullTime); !ok { 115 | return fmt.Errorf("unexpected type %T for field created_at", values[i]) 116 | } else if value.Valid { 117 | t.CreatedAt = value.Time 118 | } 119 | case token.FieldUpdatedAt: 120 | if value, ok := values[i].(*sql.NullTime); !ok { 121 | return fmt.Errorf("unexpected type %T for field updated_at", values[i]) 122 | } else if value.Valid { 123 | t.UpdatedAt = value.Time 124 | } 125 | case token.FieldDeletedAt: 126 | if value, ok := values[i].(*sql.NullTime); !ok { 127 | return fmt.Errorf("unexpected type %T for field deleted_at", values[i]) 128 | } else if value.Valid { 129 | t.DeletedAt = new(time.Time) 130 | *t.DeletedAt = value.Time 131 | } 132 | case token.ForeignKeys[0]: 133 | if value, ok := values[i].(*sql.NullScanner); !ok { 134 | return fmt.Errorf("unexpected type %T for field user_tokens", values[i]) 135 | } else if value.Valid { 136 | t.user_tokens = new(uuid.UUID) 137 | *t.user_tokens = *value.S.(*uuid.UUID) 138 | } 139 | default: 140 | t.selectValues.Set(columns[i], values[i]) 141 | } 142 | } 143 | return nil 144 | } 145 | 146 | // Value returns the ent.Value that was dynamically selected and assigned to the Token. 147 | // This includes values selected through modifiers, order, etc. 148 | func (t *Token) Value(name string) (ent.Value, error) { 149 | return t.selectValues.Get(name) 150 | } 151 | 152 | // QueryOwner queries the "owner" edge of the Token entity. 153 | func (t *Token) QueryOwner() *UserQuery { 154 | return NewTokenClient(t.config).QueryOwner(t) 155 | } 156 | 157 | // Update returns a builder for updating this Token. 158 | // Note that you need to call Token.Unwrap() before calling this method if this Token 159 | // was returned from a transaction, and the transaction was committed or rolled back. 160 | func (t *Token) Update() *TokenUpdateOne { 161 | return NewTokenClient(t.config).UpdateOne(t) 162 | } 163 | 164 | // Unwrap unwraps the Token entity that was returned from a transaction after it was closed, 165 | // so that all future queries will be executed through the driver which created the transaction. 166 | func (t *Token) Unwrap() *Token { 167 | _tx, ok := t.config.driver.(*txDriver) 168 | if !ok { 169 | panic("ent: Token is not a transactional entity") 170 | } 171 | t.config.driver = _tx.drv 172 | return t 173 | } 174 | 175 | // String implements the fmt.Stringer. 176 | func (t *Token) String() string { 177 | var builder strings.Builder 178 | builder.WriteString("Token(") 179 | builder.WriteString(fmt.Sprintf("id=%v, ", t.ID)) 180 | builder.WriteString("type=") 181 | builder.WriteString(t.Type) 182 | builder.WriteString(", ") 183 | builder.WriteString("name=") 184 | builder.WriteString(t.Name) 185 | builder.WriteString(", ") 186 | builder.WriteString("token=") 187 | builder.WriteString(", ") 188 | builder.WriteString("created_at=") 189 | builder.WriteString(t.CreatedAt.Format(time.ANSIC)) 190 | builder.WriteString(", ") 191 | builder.WriteString("updated_at=") 192 | builder.WriteString(t.UpdatedAt.Format(time.ANSIC)) 193 | builder.WriteString(", ") 194 | if v := t.DeletedAt; v != nil { 195 | builder.WriteString("deleted_at=") 196 | builder.WriteString(v.Format(time.ANSIC)) 197 | } 198 | builder.WriteByte(')') 199 | return builder.String() 200 | } 201 | 202 | // Tokens is a parsable slice of Token. 203 | type Tokens []*Token 204 | -------------------------------------------------------------------------------- /ent/token/token.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package token 4 | 5 | import ( 6 | "time" 7 | 8 | "entgo.io/ent/dialect/sql" 9 | "entgo.io/ent/dialect/sql/sqlgraph" 10 | "github.com/google/uuid" 11 | ) 12 | 13 | const ( 14 | // Label holds the string label denoting the token type in the database. 15 | Label = "token" 16 | // FieldID holds the string denoting the id field in the database. 17 | FieldID = "id" 18 | // FieldType holds the string denoting the type field in the database. 19 | FieldType = "type" 20 | // FieldName holds the string denoting the name field in the database. 21 | FieldName = "name" 22 | // FieldToken holds the string denoting the token field in the database. 23 | FieldToken = "token" 24 | // FieldCreatedAt holds the string denoting the created_at field in the database. 25 | FieldCreatedAt = "created_at" 26 | // FieldUpdatedAt holds the string denoting the updated_at field in the database. 27 | FieldUpdatedAt = "updated_at" 28 | // FieldDeletedAt holds the string denoting the deleted_at field in the database. 29 | FieldDeletedAt = "deleted_at" 30 | // EdgeOwner holds the string denoting the owner edge name in mutations. 31 | EdgeOwner = "owner" 32 | // Table holds the table name of the token in the database. 33 | Table = "tokens" 34 | // OwnerTable is the table that holds the owner relation/edge. 35 | OwnerTable = "tokens" 36 | // OwnerInverseTable is the table name for the User entity. 37 | // It exists in this package in order to avoid circular dependency with the "user" package. 38 | OwnerInverseTable = "users" 39 | // OwnerColumn is the table column denoting the owner relation/edge. 40 | OwnerColumn = "user_tokens" 41 | ) 42 | 43 | // Columns holds all SQL columns for token fields. 44 | var Columns = []string{ 45 | FieldID, 46 | FieldType, 47 | FieldName, 48 | FieldToken, 49 | FieldCreatedAt, 50 | FieldUpdatedAt, 51 | FieldDeletedAt, 52 | } 53 | 54 | // ForeignKeys holds the SQL foreign-keys that are owned by the "tokens" 55 | // table and are not defined as standalone fields in the schema. 56 | var ForeignKeys = []string{ 57 | "user_tokens", 58 | } 59 | 60 | // ValidColumn reports if the column name is valid (part of the table columns). 61 | func ValidColumn(column string) bool { 62 | for i := range Columns { 63 | if column == Columns[i] { 64 | return true 65 | } 66 | } 67 | for i := range ForeignKeys { 68 | if column == ForeignKeys[i] { 69 | return true 70 | } 71 | } 72 | return false 73 | } 74 | 75 | var ( 76 | // DefaultType holds the default value on creation for the "type" field. 77 | DefaultType string 78 | // TypeValidator is a validator for the "type" field. It is called by the builders before save. 79 | TypeValidator func(string) error 80 | // NameValidator is a validator for the "name" field. It is called by the builders before save. 81 | NameValidator func(string) error 82 | // DefaultCreatedAt holds the default value on creation for the "created_at" field. 83 | DefaultCreatedAt func() time.Time 84 | // DefaultUpdatedAt holds the default value on creation for the "updated_at" field. 85 | DefaultUpdatedAt func() time.Time 86 | // UpdateDefaultUpdatedAt holds the default value on update for the "updated_at" field. 87 | UpdateDefaultUpdatedAt func() time.Time 88 | // DefaultID holds the default value on creation for the "id" field. 89 | DefaultID func() uuid.UUID 90 | ) 91 | 92 | // OrderOption defines the ordering options for the Token queries. 93 | type OrderOption func(*sql.Selector) 94 | 95 | // ByID orders the results by the id field. 96 | func ByID(opts ...sql.OrderTermOption) OrderOption { 97 | return sql.OrderByField(FieldID, opts...).ToFunc() 98 | } 99 | 100 | // ByType orders the results by the type field. 101 | func ByType(opts ...sql.OrderTermOption) OrderOption { 102 | return sql.OrderByField(FieldType, opts...).ToFunc() 103 | } 104 | 105 | // ByName orders the results by the name field. 106 | func ByName(opts ...sql.OrderTermOption) OrderOption { 107 | return sql.OrderByField(FieldName, opts...).ToFunc() 108 | } 109 | 110 | // ByToken orders the results by the token field. 111 | func ByToken(opts ...sql.OrderTermOption) OrderOption { 112 | return sql.OrderByField(FieldToken, opts...).ToFunc() 113 | } 114 | 115 | // ByCreatedAt orders the results by the created_at field. 116 | func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption { 117 | return sql.OrderByField(FieldCreatedAt, opts...).ToFunc() 118 | } 119 | 120 | // ByUpdatedAt orders the results by the updated_at field. 121 | func ByUpdatedAt(opts ...sql.OrderTermOption) OrderOption { 122 | return sql.OrderByField(FieldUpdatedAt, opts...).ToFunc() 123 | } 124 | 125 | // ByDeletedAt orders the results by the deleted_at field. 126 | func ByDeletedAt(opts ...sql.OrderTermOption) OrderOption { 127 | return sql.OrderByField(FieldDeletedAt, opts...).ToFunc() 128 | } 129 | 130 | // ByOwnerField orders the results by owner field. 131 | func ByOwnerField(field string, opts ...sql.OrderTermOption) OrderOption { 132 | return func(s *sql.Selector) { 133 | sqlgraph.OrderByNeighborTerms(s, newOwnerStep(), sql.OrderByField(field, opts...)) 134 | } 135 | } 136 | func newOwnerStep() *sqlgraph.Step { 137 | return sqlgraph.NewStep( 138 | sqlgraph.From(Table, FieldID), 139 | sqlgraph.To(OwnerInverseTable, FieldID), 140 | sqlgraph.Edge(sqlgraph.M2O, true, OwnerTable, OwnerColumn), 141 | ) 142 | } 143 | -------------------------------------------------------------------------------- /ent/token_delete.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package ent 4 | 5 | import ( 6 | "context" 7 | 8 | "entgo.io/ent/dialect/sql" 9 | "entgo.io/ent/dialect/sql/sqlgraph" 10 | "entgo.io/ent/schema/field" 11 | "github.com/mrusme/journalist/ent/predicate" 12 | "github.com/mrusme/journalist/ent/token" 13 | ) 14 | 15 | // TokenDelete is the builder for deleting a Token entity. 16 | type TokenDelete struct { 17 | config 18 | hooks []Hook 19 | mutation *TokenMutation 20 | } 21 | 22 | // Where appends a list predicates to the TokenDelete builder. 23 | func (td *TokenDelete) Where(ps ...predicate.Token) *TokenDelete { 24 | td.mutation.Where(ps...) 25 | return td 26 | } 27 | 28 | // Exec executes the deletion query and returns how many vertices were deleted. 29 | func (td *TokenDelete) Exec(ctx context.Context) (int, error) { 30 | return withHooks(ctx, td.sqlExec, td.mutation, td.hooks) 31 | } 32 | 33 | // ExecX is like Exec, but panics if an error occurs. 34 | func (td *TokenDelete) ExecX(ctx context.Context) int { 35 | n, err := td.Exec(ctx) 36 | if err != nil { 37 | panic(err) 38 | } 39 | return n 40 | } 41 | 42 | func (td *TokenDelete) sqlExec(ctx context.Context) (int, error) { 43 | _spec := sqlgraph.NewDeleteSpec(token.Table, sqlgraph.NewFieldSpec(token.FieldID, field.TypeUUID)) 44 | if ps := td.mutation.predicates; len(ps) > 0 { 45 | _spec.Predicate = func(selector *sql.Selector) { 46 | for i := range ps { 47 | ps[i](selector) 48 | } 49 | } 50 | } 51 | affected, err := sqlgraph.DeleteNodes(ctx, td.driver, _spec) 52 | if err != nil && sqlgraph.IsConstraintError(err) { 53 | err = &ConstraintError{msg: err.Error(), wrap: err} 54 | } 55 | td.mutation.done = true 56 | return affected, err 57 | } 58 | 59 | // TokenDeleteOne is the builder for deleting a single Token entity. 60 | type TokenDeleteOne struct { 61 | td *TokenDelete 62 | } 63 | 64 | // Where appends a list predicates to the TokenDelete builder. 65 | func (tdo *TokenDeleteOne) Where(ps ...predicate.Token) *TokenDeleteOne { 66 | tdo.td.mutation.Where(ps...) 67 | return tdo 68 | } 69 | 70 | // Exec executes the deletion query. 71 | func (tdo *TokenDeleteOne) Exec(ctx context.Context) error { 72 | n, err := tdo.td.Exec(ctx) 73 | switch { 74 | case err != nil: 75 | return err 76 | case n == 0: 77 | return &NotFoundError{token.Label} 78 | default: 79 | return nil 80 | } 81 | } 82 | 83 | // ExecX is like Exec, but panics if an error occurs. 84 | func (tdo *TokenDeleteOne) ExecX(ctx context.Context) { 85 | if err := tdo.Exec(ctx); err != nil { 86 | panic(err) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /ent/tx.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package ent 4 | 5 | import ( 6 | "context" 7 | "sync" 8 | 9 | "entgo.io/ent/dialect" 10 | ) 11 | 12 | // Tx is a transactional client that is created by calling Client.Tx(). 13 | type Tx struct { 14 | config 15 | // Feed is the client for interacting with the Feed builders. 16 | Feed *FeedClient 17 | // Item is the client for interacting with the Item builders. 18 | Item *ItemClient 19 | // Read is the client for interacting with the Read builders. 20 | Read *ReadClient 21 | // Subscription is the client for interacting with the Subscription builders. 22 | Subscription *SubscriptionClient 23 | // Token is the client for interacting with the Token builders. 24 | Token *TokenClient 25 | // User is the client for interacting with the User builders. 26 | User *UserClient 27 | 28 | // lazily loaded. 29 | client *Client 30 | clientOnce sync.Once 31 | // ctx lives for the life of the transaction. It is 32 | // the same context used by the underlying connection. 33 | ctx context.Context 34 | } 35 | 36 | type ( 37 | // Committer is the interface that wraps the Commit method. 38 | Committer interface { 39 | Commit(context.Context, *Tx) error 40 | } 41 | 42 | // The CommitFunc type is an adapter to allow the use of ordinary 43 | // function as a Committer. If f is a function with the appropriate 44 | // signature, CommitFunc(f) is a Committer that calls f. 45 | CommitFunc func(context.Context, *Tx) error 46 | 47 | // CommitHook defines the "commit middleware". A function that gets a Committer 48 | // and returns a Committer. For example: 49 | // 50 | // hook := func(next ent.Committer) ent.Committer { 51 | // return ent.CommitFunc(func(ctx context.Context, tx *ent.Tx) error { 52 | // // Do some stuff before. 53 | // if err := next.Commit(ctx, tx); err != nil { 54 | // return err 55 | // } 56 | // // Do some stuff after. 57 | // return nil 58 | // }) 59 | // } 60 | // 61 | CommitHook func(Committer) Committer 62 | ) 63 | 64 | // Commit calls f(ctx, m). 65 | func (f CommitFunc) Commit(ctx context.Context, tx *Tx) error { 66 | return f(ctx, tx) 67 | } 68 | 69 | // Commit commits the transaction. 70 | func (tx *Tx) Commit() error { 71 | txDriver := tx.config.driver.(*txDriver) 72 | var fn Committer = CommitFunc(func(context.Context, *Tx) error { 73 | return txDriver.tx.Commit() 74 | }) 75 | txDriver.mu.Lock() 76 | hooks := append([]CommitHook(nil), txDriver.onCommit...) 77 | txDriver.mu.Unlock() 78 | for i := len(hooks) - 1; i >= 0; i-- { 79 | fn = hooks[i](fn) 80 | } 81 | return fn.Commit(tx.ctx, tx) 82 | } 83 | 84 | // OnCommit adds a hook to call on commit. 85 | func (tx *Tx) OnCommit(f CommitHook) { 86 | txDriver := tx.config.driver.(*txDriver) 87 | txDriver.mu.Lock() 88 | txDriver.onCommit = append(txDriver.onCommit, f) 89 | txDriver.mu.Unlock() 90 | } 91 | 92 | type ( 93 | // Rollbacker is the interface that wraps the Rollback method. 94 | Rollbacker interface { 95 | Rollback(context.Context, *Tx) error 96 | } 97 | 98 | // The RollbackFunc type is an adapter to allow the use of ordinary 99 | // function as a Rollbacker. If f is a function with the appropriate 100 | // signature, RollbackFunc(f) is a Rollbacker that calls f. 101 | RollbackFunc func(context.Context, *Tx) error 102 | 103 | // RollbackHook defines the "rollback middleware". A function that gets a Rollbacker 104 | // and returns a Rollbacker. For example: 105 | // 106 | // hook := func(next ent.Rollbacker) ent.Rollbacker { 107 | // return ent.RollbackFunc(func(ctx context.Context, tx *ent.Tx) error { 108 | // // Do some stuff before. 109 | // if err := next.Rollback(ctx, tx); err != nil { 110 | // return err 111 | // } 112 | // // Do some stuff after. 113 | // return nil 114 | // }) 115 | // } 116 | // 117 | RollbackHook func(Rollbacker) Rollbacker 118 | ) 119 | 120 | // Rollback calls f(ctx, m). 121 | func (f RollbackFunc) Rollback(ctx context.Context, tx *Tx) error { 122 | return f(ctx, tx) 123 | } 124 | 125 | // Rollback rollbacks the transaction. 126 | func (tx *Tx) Rollback() error { 127 | txDriver := tx.config.driver.(*txDriver) 128 | var fn Rollbacker = RollbackFunc(func(context.Context, *Tx) error { 129 | return txDriver.tx.Rollback() 130 | }) 131 | txDriver.mu.Lock() 132 | hooks := append([]RollbackHook(nil), txDriver.onRollback...) 133 | txDriver.mu.Unlock() 134 | for i := len(hooks) - 1; i >= 0; i-- { 135 | fn = hooks[i](fn) 136 | } 137 | return fn.Rollback(tx.ctx, tx) 138 | } 139 | 140 | // OnRollback adds a hook to call on rollback. 141 | func (tx *Tx) OnRollback(f RollbackHook) { 142 | txDriver := tx.config.driver.(*txDriver) 143 | txDriver.mu.Lock() 144 | txDriver.onRollback = append(txDriver.onRollback, f) 145 | txDriver.mu.Unlock() 146 | } 147 | 148 | // Client returns a Client that binds to current transaction. 149 | func (tx *Tx) Client() *Client { 150 | tx.clientOnce.Do(func() { 151 | tx.client = &Client{config: tx.config} 152 | tx.client.init() 153 | }) 154 | return tx.client 155 | } 156 | 157 | func (tx *Tx) init() { 158 | tx.Feed = NewFeedClient(tx.config) 159 | tx.Item = NewItemClient(tx.config) 160 | tx.Read = NewReadClient(tx.config) 161 | tx.Subscription = NewSubscriptionClient(tx.config) 162 | tx.Token = NewTokenClient(tx.config) 163 | tx.User = NewUserClient(tx.config) 164 | } 165 | 166 | // txDriver wraps the given dialect.Tx with a nop dialect.Driver implementation. 167 | // The idea is to support transactions without adding any extra code to the builders. 168 | // When a builder calls to driver.Tx(), it gets the same dialect.Tx instance. 169 | // Commit and Rollback are nop for the internal builders and the user must call one 170 | // of them in order to commit or rollback the transaction. 171 | // 172 | // If a closed transaction is embedded in one of the generated entities, and the entity 173 | // applies a query, for example: Feed.QueryXXX(), the query will be executed 174 | // through the driver which created this transaction. 175 | // 176 | // Note that txDriver is not goroutine safe. 177 | type txDriver struct { 178 | // the driver we started the transaction from. 179 | drv dialect.Driver 180 | // tx is the underlying transaction. 181 | tx dialect.Tx 182 | // completion hooks. 183 | mu sync.Mutex 184 | onCommit []CommitHook 185 | onRollback []RollbackHook 186 | } 187 | 188 | // newTx creates a new transactional driver. 189 | func newTx(ctx context.Context, drv dialect.Driver) (*txDriver, error) { 190 | tx, err := drv.Tx(ctx) 191 | if err != nil { 192 | return nil, err 193 | } 194 | return &txDriver{tx: tx, drv: drv}, nil 195 | } 196 | 197 | // Tx returns the transaction wrapper (txDriver) to avoid Commit or Rollback calls 198 | // from the internal builders. Should be called only by the internal builders. 199 | func (tx *txDriver) Tx(context.Context) (dialect.Tx, error) { return tx, nil } 200 | 201 | // Dialect returns the dialect of the driver we started the transaction from. 202 | func (tx *txDriver) Dialect() string { return tx.drv.Dialect() } 203 | 204 | // Close is a nop close. 205 | func (*txDriver) Close() error { return nil } 206 | 207 | // Commit is a nop commit for the internal builders. 208 | // User must call `Tx.Commit` in order to commit the transaction. 209 | func (*txDriver) Commit() error { return nil } 210 | 211 | // Rollback is a nop rollback for the internal builders. 212 | // User must call `Tx.Rollback` in order to rollback the transaction. 213 | func (*txDriver) Rollback() error { return nil } 214 | 215 | // Exec calls tx.Exec. 216 | func (tx *txDriver) Exec(ctx context.Context, query string, args, v any) error { 217 | return tx.tx.Exec(ctx, query, args, v) 218 | } 219 | 220 | // Query calls tx.Query. 221 | func (tx *txDriver) Query(ctx context.Context, query string, args, v any) error { 222 | return tx.tx.Query(ctx, query, args, v) 223 | } 224 | 225 | var _ dialect.Driver = (*txDriver)(nil) 226 | -------------------------------------------------------------------------------- /ent/user_delete.go: -------------------------------------------------------------------------------- 1 | // Code generated by ent, DO NOT EDIT. 2 | 3 | package ent 4 | 5 | import ( 6 | "context" 7 | 8 | "entgo.io/ent/dialect/sql" 9 | "entgo.io/ent/dialect/sql/sqlgraph" 10 | "entgo.io/ent/schema/field" 11 | "github.com/mrusme/journalist/ent/predicate" 12 | "github.com/mrusme/journalist/ent/user" 13 | ) 14 | 15 | // UserDelete is the builder for deleting a User entity. 16 | type UserDelete struct { 17 | config 18 | hooks []Hook 19 | mutation *UserMutation 20 | } 21 | 22 | // Where appends a list predicates to the UserDelete builder. 23 | func (ud *UserDelete) Where(ps ...predicate.User) *UserDelete { 24 | ud.mutation.Where(ps...) 25 | return ud 26 | } 27 | 28 | // Exec executes the deletion query and returns how many vertices were deleted. 29 | func (ud *UserDelete) Exec(ctx context.Context) (int, error) { 30 | return withHooks(ctx, ud.sqlExec, ud.mutation, ud.hooks) 31 | } 32 | 33 | // ExecX is like Exec, but panics if an error occurs. 34 | func (ud *UserDelete) ExecX(ctx context.Context) int { 35 | n, err := ud.Exec(ctx) 36 | if err != nil { 37 | panic(err) 38 | } 39 | return n 40 | } 41 | 42 | func (ud *UserDelete) sqlExec(ctx context.Context) (int, error) { 43 | _spec := sqlgraph.NewDeleteSpec(user.Table, sqlgraph.NewFieldSpec(user.FieldID, field.TypeUUID)) 44 | if ps := ud.mutation.predicates; len(ps) > 0 { 45 | _spec.Predicate = func(selector *sql.Selector) { 46 | for i := range ps { 47 | ps[i](selector) 48 | } 49 | } 50 | } 51 | affected, err := sqlgraph.DeleteNodes(ctx, ud.driver, _spec) 52 | if err != nil && sqlgraph.IsConstraintError(err) { 53 | err = &ConstraintError{msg: err.Error(), wrap: err} 54 | } 55 | ud.mutation.done = true 56 | return affected, err 57 | } 58 | 59 | // UserDeleteOne is the builder for deleting a single User entity. 60 | type UserDeleteOne struct { 61 | ud *UserDelete 62 | } 63 | 64 | // Where appends a list predicates to the UserDelete builder. 65 | func (udo *UserDeleteOne) Where(ps ...predicate.User) *UserDeleteOne { 66 | udo.ud.mutation.Where(ps...) 67 | return udo 68 | } 69 | 70 | // Exec executes the deletion query. 71 | func (udo *UserDeleteOne) Exec(ctx context.Context) error { 72 | n, err := udo.ud.Exec(ctx) 73 | switch { 74 | case err != nil: 75 | return err 76 | case n == 0: 77 | return &NotFoundError{user.Label} 78 | default: 79 | return nil 80 | } 81 | } 82 | 83 | // ExecX is like Exec, but panics if an error occurs. 84 | func (udo *UserDeleteOne) ExecX(ctx context.Context) { 85 | if err := udo.Exec(ctx); err != nil { 86 | panic(err) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /examples/etc/journalist.toml: -------------------------------------------------------------------------------- 1 | Debug = false 2 | 3 | [Admin] 4 | Username = "admin" 5 | Password = "admin" 6 | 7 | [Database] 8 | Type = "sqlite3" 9 | Connection = "file:journalist.db?cache=shared&_fk=1" 10 | 11 | [Server] 12 | BindIP = "127.0.0.1" 13 | Port = 8000 14 | 15 | [Server.Endpoint] 16 | Api = "http://127.0.0.1:8000/api" 17 | Web = "http://127.0.0.1:8000/web" 18 | 19 | [Feeds] 20 | AutoRefresh = 900 21 | 22 | -------------------------------------------------------------------------------- /examples/etc/rc.d/journalist: -------------------------------------------------------------------------------- 1 | #!/bin/ksh 2 | 3 | daemon="/usr/local/bin/journalist" 4 | 5 | . /etc/rc.d/rc.subr 6 | 7 | rc_start() { 8 | ${rcexec} "${daemon} ${daemon_flags} 2>&1 | logger -t journalist &" 9 | } 10 | 11 | rc_cmd $1 12 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrusme/journalist/cfdd6e906fe5ca3d145511c9cd801cb2c8ef8436/favicon.ico -------------------------------------------------------------------------------- /favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrusme/journalist/cfdd6e906fe5ca3d145511c9cd801cb2c8ef8436/favicon.png -------------------------------------------------------------------------------- /gcf.go: -------------------------------------------------------------------------------- 1 | // Code from https://github.com/gofiber/recipes/blob/master/gcloud/functions.go 2 | // Copyright by https://github.com/alvarowolfx and https://github.com/Fenny 3 | 4 | package main 5 | 6 | import ( 7 | "bytes" 8 | "context" 9 | "fmt" 10 | "io" 11 | "io/ioutil" 12 | "log" 13 | "net" 14 | "net/http" 15 | "strings" 16 | 17 | "github.com/gofiber/fiber/v2" 18 | "github.com/valyala/fasthttp/fasthttputil" 19 | ) 20 | 21 | // CloudFunctionRouteToFiber route cloud function http.Handler to *fiber.App 22 | // Internally, google calls the function with the /execute base URL 23 | func CloudFunctionRouteToFiber(fiberApp *fiber.App, w http.ResponseWriter, r *http.Request) error { 24 | return RouteToFiber(fiberApp, w, r, "/execute") 25 | } 26 | 27 | // RouteToFiber route http.Handler to *fiber.App 28 | func RouteToFiber(fiberApp *fiber.App, w http.ResponseWriter, r *http.Request, rootURL ...string) error { 29 | ln := fasthttputil.NewInmemoryListener() 30 | defer ln.Close() 31 | 32 | // Copy request 33 | body, err := ioutil.ReadAll(r.Body) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | url := fmt.Sprintf("%s://%s%s", "http", "0.0.0.0", r.RequestURI) 39 | if len(rootURL) > 0 { 40 | url = strings.Replace(url, rootURL[0], "", -1) 41 | } 42 | 43 | proxyReq, err := http.NewRequest(r.Method, url, bytes.NewReader(body)) 44 | proxyReq.Header = r.Header 45 | 46 | // Create http client 47 | client := http.Client{ 48 | Transport: &http.Transport{ 49 | DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { 50 | return ln.Dial() 51 | }, 52 | }, 53 | } 54 | 55 | // Serve request to internal HTTP client 56 | go func() { 57 | log.Fatal(fiberApp.Listener(ln)) 58 | }() 59 | 60 | // Call internal Fiber API 61 | response, err := client.Do(proxyReq) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | // Copy response and headers 67 | for k, values := range response.Header { 68 | for _, v := range values { 69 | w.Header().Set(k, v) 70 | } 71 | } 72 | w.WriteHeader(response.StatusCode) 73 | 74 | io.Copy(w, response.Body) 75 | response.Body.Close() 76 | 77 | return nil 78 | } 79 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mrusme/journalist 2 | 3 | go 1.22.5 4 | toolchain go1.24.1 5 | 6 | require ( 7 | entgo.io/ent v0.13.1 8 | github.com/Danny-Dasilva/CycleTLS/cycletls v1.0.26 9 | github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de 10 | github.com/aws/aws-lambda-go v1.47.0 11 | github.com/awslabs/aws-lambda-go-api-proxy v0.16.2 12 | github.com/go-playground/validator/v10 v10.22.0 13 | github.com/go-shiori/go-readability v0.0.0-20240701094332-1070de7e32ef 14 | github.com/go-sql-driver/mysql v1.8.1 15 | github.com/gofiber/fiber/v2 v2.52.5 16 | github.com/gofiber/template v1.7.5 17 | github.com/google/uuid v1.6.0 18 | github.com/lib/pq v1.10.9 19 | github.com/mattn/go-sqlite3 v1.14.22 20 | github.com/memclutter/go-cloudflare-scraper v0.0.0-20220907170638-a1faa8b189bd 21 | github.com/microcosm-cc/bluemonday v1.0.27 22 | github.com/mmcdole/gofeed v1.3.0 23 | github.com/spf13/viper v1.19.0 24 | github.com/swaggo/swag v1.16.3 25 | github.com/valyala/fasthttp v1.55.0 26 | go.uber.org/zap v1.27.0 27 | golang.org/x/net v0.38.0 28 | ) 29 | 30 | require ( 31 | ariga.io/atlas v0.25.0 // indirect 32 | filippo.io/edwards25519 v1.1.0 // indirect 33 | github.com/Danny-Dasilva/fhttp v0.0.0-20240217042913-eeeb0b347ce1 // indirect 34 | github.com/KyleBanks/depth v1.2.1 // indirect 35 | github.com/PuerkitoBio/goquery v1.9.2 // indirect 36 | github.com/agext/levenshtein v1.2.3 // indirect 37 | github.com/andybalholm/brotli v1.1.0 // indirect 38 | github.com/andybalholm/cascadia v1.3.2 // indirect 39 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect 40 | github.com/aymerick/douceur v0.2.0 // indirect 41 | github.com/cloudflare/circl v1.5.0 // indirect 42 | github.com/fsnotify/fsnotify v1.7.0 // indirect 43 | github.com/gabriel-vasile/mimetype v1.4.4 // indirect 44 | github.com/go-openapi/inflect v0.21.0 // indirect 45 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 46 | github.com/go-openapi/jsonreference v0.21.0 // indirect 47 | github.com/go-openapi/spec v0.21.0 // indirect 48 | github.com/go-openapi/swag v0.23.0 // indirect 49 | github.com/go-playground/locales v0.14.1 // indirect 50 | github.com/go-playground/universal-translator v0.18.1 // indirect 51 | github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect 52 | github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect 53 | github.com/google/go-cmp v0.6.0 // indirect 54 | github.com/gorilla/css v1.0.1 // indirect 55 | github.com/gorilla/websocket v1.5.3 // indirect 56 | github.com/hashicorp/hcl v1.0.0 // indirect 57 | github.com/hashicorp/hcl/v2 v2.21.0 // indirect 58 | github.com/josharian/intern v1.0.0 // indirect 59 | github.com/json-iterator/go v1.1.12 // indirect 60 | github.com/klauspost/compress v1.17.9 // indirect 61 | github.com/leodido/go-urn v1.4.0 // indirect 62 | github.com/magiconair/properties v1.8.7 // indirect 63 | github.com/mailru/easyjson v0.7.7 // indirect 64 | github.com/mattn/go-colorable v0.1.13 // indirect 65 | github.com/mattn/go-isatty v0.0.20 // indirect 66 | github.com/mattn/go-runewidth v0.0.15 // indirect 67 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 68 | github.com/mitchellh/mapstructure v1.5.0 // indirect 69 | github.com/mmcdole/goxpp v1.1.1 // indirect 70 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 71 | github.com/modern-go/reflect2 v1.0.2 // indirect 72 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 73 | github.com/refraction-networking/utls v1.7.0 // indirect 74 | github.com/rivo/uniseg v0.4.7 // indirect 75 | github.com/robertkrimen/otto v0.4.0 // indirect 76 | github.com/sagikazarmark/locafero v0.6.0 // indirect 77 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 78 | github.com/sourcegraph/conc v0.3.0 // indirect 79 | github.com/spf13/afero v1.11.0 // indirect 80 | github.com/spf13/cast v1.6.0 // indirect 81 | github.com/spf13/pflag v1.0.5 // indirect 82 | github.com/subosito/gotenv v1.6.0 // indirect 83 | github.com/valyala/bytebufferpool v1.0.0 // indirect 84 | github.com/valyala/tcplisten v1.0.0 // indirect 85 | github.com/zclconf/go-cty v1.15.0 // indirect 86 | go.uber.org/multierr v1.11.0 // indirect 87 | golang.org/x/crypto v0.36.0 // indirect 88 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect 89 | golang.org/x/mod v0.19.0 // indirect 90 | golang.org/x/sync v0.12.0 // indirect 91 | golang.org/x/sys v0.31.0 // indirect 92 | golang.org/x/text v0.23.0 // indirect 93 | golang.org/x/tools v0.23.0 // indirect 94 | gopkg.in/ini.v1 v1.67.0 // indirect 95 | gopkg.in/sourcemap.v1 v1.0.5 // indirect 96 | gopkg.in/yaml.v3 v3.0.1 // indirect 97 | h12.io/socks v1.0.3 // indirect 98 | ) 99 | -------------------------------------------------------------------------------- /helpers/helpers.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | -------------------------------------------------------------------------------- /journalist.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/aws/aws-lambda-go/events" 11 | "github.com/aws/aws-lambda-go/lambda" 12 | fiberadapter "github.com/awslabs/aws-lambda-go-api-proxy/fiber" 13 | 14 | "go.uber.org/zap" 15 | 16 | "github.com/gofiber/fiber/v2" 17 | 18 | "github.com/mrusme/journalist/ent" 19 | "github.com/mrusme/journalist/journalistd" 20 | "github.com/mrusme/journalist/lib" 21 | 22 | "github.com/mrusme/journalist/api" 23 | "github.com/mrusme/journalist/middlewares/fiberzap" 24 | "github.com/mrusme/journalist/web" 25 | 26 | _ "github.com/go-sql-driver/mysql" 27 | _ "github.com/lib/pq" 28 | _ "github.com/mattn/go-sqlite3" 29 | ) 30 | 31 | //go:embed views/* 32 | var viewsfs embed.FS 33 | 34 | //go:embed favicon.ico 35 | var favicon embed.FS 36 | 37 | var fiberApp *fiber.App 38 | var fiberLambda *fiberadapter.FiberLambda 39 | 40 | var config lib.Config 41 | var logger *zap.Logger 42 | 43 | func init() { 44 | var err error 45 | 46 | fiberLambda = fiberadapter.New(fiberApp) 47 | config, err = lib.Cfg() 48 | if err != nil { 49 | panic(err) 50 | } 51 | 52 | if config.Debug == "true" { 53 | logger, _ = zap.NewDevelopment() 54 | } else { 55 | logger, _ = zap.NewProduction() 56 | } 57 | defer logger.Sync() 58 | // TODO: Use sugarLogger 59 | // sugar := logger.Sugar() 60 | } 61 | 62 | func AWSLambdaHandler( 63 | ctx context.Context, 64 | req events.APIGatewayProxyRequest, 65 | ) (events.APIGatewayProxyResponse, error) { 66 | return fiberLambda.ProxyWithContext(ctx, req) 67 | } 68 | 69 | func GCFHandler( 70 | w http.ResponseWriter, 71 | r *http.Request, 72 | ) { 73 | err := CloudFunctionRouteToFiber(fiberApp, w, r) 74 | if err != nil { 75 | logger.Error( 76 | "Handler error", 77 | zap.Error(err), 78 | ) 79 | return 80 | } 81 | } 82 | 83 | func main() { 84 | var err error 85 | var jctx lib.JournalistContext 86 | var entClient *ent.Client 87 | 88 | entClient, err = ent.Open(config.Database.Type, config.Database.Connection) 89 | if err != nil { 90 | logger.Error( 91 | "Failed initializing database", 92 | zap.Error(err), 93 | ) 94 | } 95 | defer entClient.Close() 96 | if err := entClient.Schema.Create(context.Background()); err != nil { 97 | logger.Error( 98 | "Failed initializing schema", 99 | zap.Error(err), 100 | ) 101 | } 102 | 103 | jctx = lib.JournalistContext{ 104 | Config: &config, 105 | EntClient: entClient, 106 | Logger: logger, 107 | } 108 | 109 | jd, err := journalistd.New( 110 | &jctx, 111 | ) 112 | if err != nil { 113 | panic(err) 114 | } 115 | 116 | engine := web.NewFileSystem(http.FS(viewsfs), ".html") 117 | fiberApp = fiber.New(fiber.Config{ 118 | Prefork: false, // TODO: Make configurable 119 | ServerHeader: "", // TODO: Make configurable 120 | StrictRouting: false, 121 | CaseSensitive: false, 122 | ETag: false, // TODO: Make configurable 123 | Concurrency: 256 * 1024, // TODO: Make configurable 124 | Views: engine, 125 | ProxyHeader: "", // TODO: Make configurable 126 | EnableTrustedProxyCheck: false, // TODO: Make configurable 127 | TrustedProxies: []string{}, // TODO: Make configurable 128 | DisableStartupMessage: true, 129 | AppName: "journalist", 130 | ReduceMemoryUsage: false, // TODO: Make configurable 131 | Network: fiber.NetworkTCP, // TODO: Make configurable 132 | EnablePrintRoutes: false, 133 | }) 134 | fiberApp.Use(fiberzap.New(fiberzap.Config{ 135 | Logger: logger, 136 | })) 137 | 138 | api.Register( 139 | &jctx, 140 | fiberApp, 141 | ) 142 | 143 | web.Register( 144 | &jctx, 145 | fiberApp, 146 | ) 147 | 148 | fiberApp.Get("/favicon.ico", func(ctx *fiber.Ctx) error { 149 | fi, err := favicon.Open("favicon.ico") 150 | if err != nil { 151 | return ctx.SendStatus(fiber.StatusInternalServerError) 152 | } 153 | return ctx.SendStream(fi) 154 | }) 155 | 156 | fiberApp.Get("/health", func(ctx *fiber.Ctx) error { 157 | // TODO: Check for issues 158 | return ctx.SendStatus(fiber.StatusNoContent) 159 | }) 160 | 161 | functionName := os.Getenv("AWS_LAMBDA_FUNCTION_NAME") 162 | 163 | if config.Feeds.AutoRefresh != "" { 164 | 165 | if functionName == "" { 166 | jd.Start() 167 | } else { 168 | logger.Warn( 169 | "Journalist won't start the feed auto refresh thread " + 170 | "while it is running as a Lambda function", 171 | ) 172 | } 173 | } 174 | 175 | if functionName == "" { 176 | listenAddr := fmt.Sprintf( 177 | "%s:%s", 178 | config.Server.BindIP, 179 | config.Server.Port, 180 | ) 181 | logger.Fatal( 182 | "Server failed", 183 | zap.Error(fiberApp.Listen(listenAddr)), 184 | ) 185 | } else { 186 | lambda.Start(AWSLambdaHandler) 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /journalist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrusme/journalist/cfdd6e906fe5ca3d145511c9cd801cb2c8ef8436/journalist.png -------------------------------------------------------------------------------- /journalistd/journalistd.go: -------------------------------------------------------------------------------- 1 | package journalistd 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | "time" 7 | 8 | "github.com/google/uuid" 9 | "go.uber.org/zap" 10 | 11 | "github.com/mrusme/journalist/ent" 12 | "github.com/mrusme/journalist/ent/feed" 13 | "github.com/mrusme/journalist/ent/item" 14 | "github.com/mrusme/journalist/ent/user" 15 | 16 | "github.com/mrusme/journalist/lib" 17 | "github.com/mrusme/journalist/rss" 18 | ) 19 | 20 | var VERSION string 21 | 22 | type Journalistd struct { 23 | jctx *lib.JournalistContext 24 | 25 | config *lib.Config 26 | entClient *ent.Client 27 | logger *zap.Logger 28 | 29 | daemonStop chan bool 30 | autoRefreshInterval time.Duration 31 | } 32 | 33 | func Version() string { 34 | return VERSION 35 | } 36 | 37 | func New( 38 | jctx *lib.JournalistContext, 39 | ) (*Journalistd, error) { 40 | jd := new(Journalistd) 41 | jd.jctx = jctx 42 | jd.config = jctx.Config 43 | jd.entClient = jctx.EntClient 44 | jd.logger = jctx.Logger 45 | 46 | if err := jd.initAdminUser(); err != nil { 47 | return nil, err 48 | } 49 | 50 | interval, err := strconv.Atoi(jd.config.Feeds.AutoRefresh) 51 | if err != nil { 52 | jd.logger.Error( 53 | "Feeds.AutoRefresh is not a valid number (seconds)", 54 | zap.Error(err), 55 | ) 56 | return nil, err 57 | } 58 | jd.autoRefreshInterval = time.Duration(interval) 59 | 60 | return jd, nil 61 | } 62 | 63 | func (jd *Journalistd) IsDebug() bool { 64 | debug, err := strconv.ParseBool(jd.config.Debug) 65 | if err != nil { 66 | return false 67 | } 68 | 69 | return debug 70 | } 71 | 72 | func (jd *Journalistd) initAdminUser() error { 73 | var admin *ent.User 74 | var err error 75 | 76 | admin, err = jd.entClient.User. 77 | Query(). 78 | Where(user.Username(jd.config.Admin.Username)). 79 | Only(context.Background()) 80 | if err != nil { 81 | admin, err = jd.entClient.User. 82 | Create(). 83 | SetUsername(jd.config.Admin.Username). 84 | SetPassword(jd.config.Admin.Password). 85 | SetRole("admin"). 86 | Save(context.Background()) 87 | if err != nil { 88 | jd.logger.Error( 89 | "Failed query/create admin user", 90 | zap.Error(err), 91 | ) 92 | return err 93 | } 94 | } 95 | 96 | if admin.Password == "admin" { 97 | jd.logger.Debug( 98 | "Admin user", 99 | zap.String("username", admin.Username), 100 | zap.String("password", admin.Password), 101 | ) 102 | } else { 103 | jd.logger.Debug( 104 | "Admin user", 105 | zap.String("username", admin.Username), 106 | zap.String("password", "xxxxxx"), 107 | ) 108 | } 109 | 110 | return nil 111 | } 112 | 113 | func (jd *Journalistd) Start() bool { 114 | jd.logger.Info( 115 | "Starting Journalist daemon", 116 | ) 117 | jd.daemonStop = make(chan bool) 118 | go jd.daemon() 119 | return true 120 | } 121 | 122 | func (jd *Journalistd) Stop() { 123 | jd.logger.Info( 124 | "Stopping Journalist daemon", 125 | ) 126 | jd.daemonStop <- true 127 | } 128 | 129 | func (jd *Journalistd) daemon() { 130 | jd.logger.Debug( 131 | "Journalist daemon started, looping", 132 | ) 133 | for { 134 | select { 135 | case <-jd.daemonStop: 136 | jd.logger.Debug( 137 | "Journalist daemon loop ended", 138 | ) 139 | return 140 | default: 141 | jd.logger.Debug( 142 | "RefreshAll starting, refreshing all feeds", 143 | ) 144 | errs := jd.RefreshAll() 145 | if len(errs) > 0 { 146 | jd.logger.Error( 147 | "RefreshAll completed with errors", 148 | zap.Errors("errors", errs), 149 | ) 150 | } else { 151 | jd.logger.Debug( 152 | "RefreshAll completed", 153 | ) 154 | } 155 | time.Sleep(time.Second * jd.autoRefreshInterval) 156 | } 157 | } 158 | } 159 | 160 | func (jd *Journalistd) RefreshAll() []error { 161 | var errs []error 162 | 163 | dbFeeds, err := jd.entClient.Feed. 164 | Query(). 165 | All(context.Background()) 166 | if err != nil { 167 | errs = append(errs, err) 168 | return errs 169 | } 170 | 171 | var feedIds []uuid.UUID = make([]uuid.UUID, len(dbFeeds)) 172 | for i, dbFeed := range dbFeeds { 173 | feedIds[i] = dbFeed.ID 174 | } 175 | 176 | return jd.Refresh(feedIds) 177 | } 178 | 179 | func (jd *Journalistd) Refresh(feedIds []uuid.UUID) []error { 180 | var errs []error 181 | 182 | dbFeeds, err := jd.entClient.Feed. 183 | Query(). 184 | Where( 185 | feed.IDIn(feedIds...), 186 | ). 187 | WithItems(func(query *ent.ItemQuery) { 188 | query. 189 | Select(item.FieldItemGUID). 190 | Where(item.CrawlerContentHTMLNEQ("")) 191 | }). 192 | All(context.Background()) 193 | if err != nil { 194 | errs = append(errs, err) 195 | return errs 196 | } 197 | 198 | for _, dbFeed := range dbFeeds { 199 | var exceptItemGUIDs []string 200 | for _, exceptItem := range dbFeed.Edges.Items { 201 | exceptItemGUIDs = append(exceptItemGUIDs, exceptItem.ItemGUID) 202 | } 203 | 204 | rc, errr := rss.NewClient( 205 | dbFeed.URL, 206 | dbFeed.Username, 207 | dbFeed.Password, 208 | true, 209 | exceptItemGUIDs, 210 | jd.logger, 211 | ) 212 | if len(errr) > 0 { 213 | errs = append(errs, errr...) 214 | continue 215 | } 216 | 217 | dbFeedTmp := jd.entClient.Feed. 218 | Create() 219 | rc.SetFeed( 220 | dbFeed.URL, 221 | dbFeed.Username, 222 | dbFeed.Password, 223 | dbFeedTmp, 224 | ) 225 | feedID, err := dbFeedTmp. 226 | OnConflictColumns("url", "username", "password"). 227 | UpdateNewValues(). 228 | ID(context.Background()) 229 | if err != nil { 230 | errs = append(errs, err) 231 | } 232 | 233 | dbItems := make([]*ent.ItemCreate, len(rc.Feed.Items)) 234 | for i := 0; i < len(rc.Feed.Items); i++ { 235 | dbItems[i] = jd.entClient.Item. 236 | Create() 237 | dbItems[i] = rc.SetItem( 238 | feedID, 239 | i, 240 | dbItems[i], 241 | ) 242 | } 243 | err = jd.entClient.Item. 244 | CreateBulk(dbItems...). 245 | OnConflictColumns("item_guid"). 246 | UpdateNewValues(). 247 | Exec(context.Background()) 248 | if err != nil { 249 | errs = append(errs, err) 250 | } 251 | } 252 | 253 | return errs 254 | } 255 | -------------------------------------------------------------------------------- /lib/config.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "net/url" 7 | "os" 8 | "strings" 9 | 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | type Config struct { 14 | Debug string 15 | 16 | Admin struct { 17 | Username string 18 | Password string 19 | } 20 | 21 | Database struct { 22 | Type string 23 | Connection string 24 | } 25 | 26 | Server struct { 27 | BindIP string 28 | Port string 29 | Endpoint struct { 30 | Api string 31 | Web string 32 | } 33 | } 34 | 35 | Feeds struct { 36 | AutoRefresh string 37 | } 38 | } 39 | 40 | func Cfg() (Config, error) { 41 | viper.SetDefault("Debug", "false") 42 | 43 | viper.SetDefault("Admin.Username", "admin") 44 | viper.SetDefault("Admin.Password", "admin") 45 | 46 | viper.SetDefault("Database.Type", "sqlite3") 47 | viper.SetDefault("Database.Connection", "file:ent?mode=memory&cache=shared&_fk=1") 48 | 49 | viper.SetDefault("Server.BindIP", "127.0.0.1") 50 | viper.SetDefault("Server.Port", "8000") 51 | viper.SetDefault("Server.Endpoint.Api", "http://127.0.0.1:8000/api") 52 | viper.SetDefault("Server.Endpoint.Web", "http://127.0.0.1:8000/web") 53 | 54 | viper.SetDefault("Feeds.AutoRefresh", "900") 55 | 56 | viper.SetConfigName("journalist.toml") 57 | viper.SetConfigType("toml") 58 | viper.AddConfigPath("/etc/") 59 | viper.AddConfigPath("$XDG_CONFIG_HOME/") 60 | viper.AddConfigPath("$HOME/.config/") 61 | viper.AddConfigPath("$HOME/") 62 | viper.AddConfigPath(".") 63 | 64 | viper.SetEnvPrefix("journalist") 65 | viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 66 | viper.AutomaticEnv() 67 | 68 | if err := viper.ReadInConfig(); err != nil { 69 | if _, ok := err.(viper.ConfigFileNotFoundError); !ok { 70 | return Config{}, err 71 | } 72 | } 73 | 74 | var config Config 75 | if err := viper.Unmarshal(&config); err != nil { 76 | return Config{}, err 77 | } 78 | 79 | config = *ParseDatabaseURL(&config) 80 | 81 | return config, nil 82 | } 83 | 84 | func ParseDatabaseURL(config *Config) *Config { 85 | databaseURL := os.Getenv("DATABASE_URL") 86 | if databaseURL == "" { 87 | return config 88 | } 89 | 90 | dbURL, err := url.Parse(databaseURL) 91 | if err != nil { 92 | return config 93 | } 94 | 95 | host, port, _ := net.SplitHostPort(dbURL.Host) 96 | dbname := strings.TrimLeft(dbURL.Path, "/") 97 | user := dbURL.User.Username() 98 | password, _ := dbURL.User.Password() 99 | 100 | switch dbURL.Scheme { 101 | case "postgresql", "postgres": 102 | if port == "" { 103 | port = "5432" 104 | } 105 | config.Database.Type = "postgres" 106 | config.Database.Connection = fmt.Sprintf( 107 | "host=%s port=%s dbname=%s user=%s password=%s", 108 | host, port, dbname, user, password, 109 | ) 110 | case "mysql": 111 | if port == "" { 112 | port = "3306" 113 | } 114 | config.Database.Type = "mysql" 115 | config.Database.Connection = fmt.Sprintf( 116 | "%s:%s@tcp(%s:%s)/%s?parseTime=True", 117 | user, password, host, port, dbname, 118 | ) 119 | } 120 | 121 | return config 122 | } 123 | -------------------------------------------------------------------------------- /lib/lib.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "go.uber.org/zap" 5 | 6 | "github.com/mrusme/journalist/ent" 7 | ) 8 | 9 | type JournalistContext struct { 10 | Config *Config 11 | EntClient *ent.Client 12 | Logger *zap.Logger 13 | } 14 | -------------------------------------------------------------------------------- /middlewares/fiberzap/fiberzap.go: -------------------------------------------------------------------------------- 1 | // Originally from https://gl.oddhunters.com/pub/fiberzap 2 | // Copyright (apparently) by Ozgur Boru 3 | // and "mert" (https://gl.oddhunters.com/mert) 4 | package fiberzap 5 | 6 | import ( 7 | "os" 8 | "strconv" 9 | "sync" 10 | "time" 11 | 12 | "github.com/gofiber/fiber/v2" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | // Config defines the config for middleware 17 | type Config struct { 18 | // Next defines a function to skip this middleware when returned true. 19 | // 20 | // Optional. Default: nil 21 | Next func(c *fiber.Ctx) bool 22 | 23 | // Logger defines zap logger instance 24 | Logger *zap.Logger 25 | } 26 | 27 | // New creates a new middleware handler 28 | func New(config Config) fiber.Handler { 29 | var ( 30 | errPadding = 15 31 | start, stop time.Time 32 | once sync.Once 33 | errHandler fiber.ErrorHandler 34 | ) 35 | 36 | return func(c *fiber.Ctx) error { 37 | if config.Next != nil && config.Next(c) { 38 | return c.Next() 39 | } 40 | 41 | once.Do(func() { 42 | errHandler = c.App().Config().ErrorHandler 43 | stack := c.App().Stack() 44 | for m := range stack { 45 | for r := range stack[m] { 46 | if len(stack[m][r].Path) > errPadding { 47 | errPadding = len(stack[m][r].Path) 48 | } 49 | } 50 | } 51 | }) 52 | 53 | start = time.Now() 54 | 55 | chainErr := c.Next() 56 | 57 | if chainErr != nil { 58 | if err := errHandler(c, chainErr); err != nil { 59 | _ = c.SendStatus(fiber.StatusInternalServerError) 60 | } 61 | } 62 | 63 | stop = time.Now() 64 | 65 | fields := []zap.Field{ 66 | zap.Namespace("context"), 67 | zap.String("pid", strconv.Itoa(os.Getpid())), 68 | zap.String("time", stop.Sub(start).String()), 69 | zap.Object("response", Resp(c.Response())), 70 | zap.Object("request", Req(c)), 71 | } 72 | 73 | if u := c.Locals("userId"); u != nil { 74 | fields = append(fields, zap.Uint("userId", u.(uint))) 75 | } 76 | 77 | formatErr := "" 78 | if chainErr != nil { 79 | formatErr = chainErr.Error() 80 | fields = append(fields, zap.String("error", formatErr)) 81 | config.Logger.With(fields...).Error(formatErr) 82 | 83 | return nil 84 | } 85 | 86 | config.Logger.With(fields...).Info("api.request") 87 | 88 | return nil 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /middlewares/fiberzap/types.go: -------------------------------------------------------------------------------- 1 | // Originally from https://gl.oddhunters.com/pub/fiberzap 2 | // Copyright (apparently) by Ozgur Boru 3 | // and "mert" (https://gl.oddhunters.com/mert) 4 | package fiberzap 5 | 6 | import ( 7 | "bytes" 8 | "encoding/json" 9 | "strings" 10 | 11 | "github.com/gofiber/fiber/v2" 12 | "github.com/valyala/fasthttp" 13 | "go.uber.org/zap/zapcore" 14 | ) 15 | 16 | func getAllowedHeaders() map[string]bool { 17 | return map[string]bool{ 18 | "User-Agent": true, 19 | "X-Mobile": true, 20 | } 21 | } 22 | 23 | type resp struct { 24 | code int 25 | _type string 26 | } 27 | 28 | func Resp(r *fasthttp.Response) *resp { 29 | return &resp{ 30 | code: r.StatusCode(), 31 | _type: bytes.NewBuffer(r.Header.ContentType()).String(), 32 | } 33 | } 34 | 35 | func (r *resp) MarshalLogObject(enc zapcore.ObjectEncoder) error { 36 | enc.AddString("type", r._type) 37 | enc.AddInt("code", r.code) 38 | 39 | return nil 40 | } 41 | 42 | type req struct { 43 | body string 44 | fullPath string 45 | user string 46 | ip string 47 | method string 48 | route string 49 | headers *headerbag 50 | } 51 | 52 | func Req(c *fiber.Ctx) *req { 53 | reqq := c.Request() 54 | var body []byte 55 | buffer := new(bytes.Buffer) 56 | err := json.Compact(buffer, reqq.Body()) 57 | if err != nil { 58 | body = reqq.Body() 59 | } else { 60 | body = buffer.Bytes() 61 | } 62 | 63 | headers := &headerbag{ 64 | vals: make(map[string]string), 65 | } 66 | allowedHeaders := getAllowedHeaders() 67 | reqq.Header.VisitAll(func(key, val []byte) { 68 | k := bytes.NewBuffer(key).String() 69 | if _, exist := allowedHeaders[k]; exist { 70 | headers.vals[strings.ToLower(k)] = bytes.NewBuffer(val).String() 71 | } 72 | }) 73 | 74 | var userEmail string 75 | if u := c.Locals("userEmail"); u != nil { 76 | userEmail = u.(string) 77 | } 78 | 79 | return &req{ 80 | body: bytes.NewBuffer(body).String(), 81 | fullPath: bytes.NewBuffer(reqq.RequestURI()).String(), 82 | headers: headers, 83 | ip: c.IP(), 84 | method: c.Method(), 85 | route: c.Route().Path, 86 | user: userEmail, 87 | } 88 | } 89 | 90 | func (r *req) MarshalLogObject(enc zapcore.ObjectEncoder) error { 91 | enc.AddString("fullPath", r.fullPath) 92 | enc.AddString("ip", r.ip) 93 | enc.AddString("method", r.method) 94 | enc.AddString("route", r.route) 95 | 96 | if r.body != "" { 97 | enc.AddString("body", r.body) 98 | } 99 | 100 | if r.user != "" { 101 | enc.AddString("user", r.user) 102 | } 103 | 104 | err := enc.AddObject("headers", r.headers) 105 | if err != nil { 106 | return err 107 | } 108 | 109 | return nil 110 | } 111 | 112 | type headerbag struct { 113 | vals map[string]string 114 | } 115 | 116 | func (h *headerbag) MarshalLogObject(enc zapcore.ObjectEncoder) error { 117 | for k, v := range h.vals { 118 | enc.AddString(k, v) 119 | } 120 | 121 | return nil 122 | } 123 | -------------------------------------------------------------------------------- /redacteur: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | api_url="$JOURNALIST_API_URL" 4 | 5 | depcheck() { 6 | dep="$1" 7 | if ! type "$dep" > /dev/null 8 | then 9 | printf "%s missing, please install\n" "$dep" 10 | exit 1 11 | fi 12 | } 13 | 14 | usage() { 15 | printf "Redacteur, the Journalist API client\n" 16 | printf "https://github.com/mrusme/journalist\n" 17 | printf "\n" 18 | printf "usage: %s [args...]\n" "$0" 19 | printf "\n" 20 | printf "SUBCOMMANDS\n" 21 | printf "\n" 22 | printf " perform: Perform raw API call\n" 23 | printf " args:\n" 24 | printf " on as " 25 | printf "[with ]\n" 26 | printf "\n" 27 | printf " action: get, post, put, delete\n" 28 | printf " endpoint: users[/], tokens[/], feeds[/]\n" 29 | printf " payload: JSON string\n" 30 | printf "\n" 31 | printf " env: JOURNALIST_API_URL\n" 32 | printf "\n" 33 | printf " EXAMPLES\n" 34 | printf "\n" 35 | printf " %s perform get \\ \n" "$0" 36 | printf " on users/d83807a0-22ec-4c9f-94bf-fee5cf882d6e\\ \n" 37 | printf " as admin \$(pass show journalist/admin)\n" 38 | printf "\n" 39 | printf " %s perform post \\ \n" "$0" 40 | printf " on tokens \\ \n" 41 | printf " as myuser mypassword \\ \n" 42 | printf " with '{ \"name\": \"mytoken\" }'\n" 43 | printf "\n" 44 | printf " add: Helper for adding users, tokens, feeds, etc.\n" 45 | printf " args:\n" 46 | printf " \n" 47 | printf "\n" 48 | printf " entity: user, token, feed\n" 49 | printf "\n" 50 | printf " env: JOURNALIST_API_URL, JOURNALIST_API_USERNAME,\n" 51 | printf " JOURNALIST_API_PASSWORD\n" 52 | printf "\n" 53 | printf " EXAMPLES\n" 54 | printf "\n" 55 | printf " %s add user\n" "$0" 56 | printf "\n" 57 | printf " %s add token\n" "$0" 58 | printf "\n" 59 | printf "ENVIRONMENT\n" 60 | printf "\n" 61 | printf " JOURNALIST_API_URL: Journalist API endpoint, " 62 | printf "e.g. http://127.0.0.1:8000/api\n" 63 | printf " JOURNALIST_API_USERNAME: user to use for API requests\n" 64 | printf " JOURNALIST_API_PASSWORD: password for API user\n" 65 | printf "\n" 66 | } 67 | 68 | perform() { 69 | if [ "$#" -lt 6 ] 70 | then 71 | usage 72 | exit 1 73 | fi 74 | 75 | action=$(printf "%s" "$1" | tr '[:lower:]' '[:upper:]') 76 | # "on"=$2 77 | endpoint="$3" 78 | # "as"=$4 79 | user="$5" 80 | pass="$6" 81 | # "with"=$7 82 | payload="$8" 83 | 84 | if [ "$payload" = "" ] 85 | then 86 | json=$(curl \ 87 | -s \ 88 | -u "$user:$pass" \ 89 | -H 'Content-Type: application/json; charset=utf-8' \ 90 | -X "$action" \ 91 | "$api_url/$endpoint") 92 | else 93 | json=$(curl \ 94 | -s \ 95 | -u "$user:$pass" \ 96 | -H 'Content-Type: application/json; charset=utf-8' \ 97 | -X "$action" \ 98 | "$api_url/$endpoint" \ 99 | -d "$payload") 100 | fi 101 | 102 | if [ $? -ne 0 ] 103 | then 104 | printf "{}" 105 | return 1 106 | fi 107 | 108 | printf "%s" "$json" 109 | if printf "%s" "$json" | jq '.success' | grep "false" > /dev/null 110 | then 111 | return 2 112 | fi 113 | 114 | return 0 115 | } 116 | 117 | add() { 118 | if [ "$#" -lt 1 ] 119 | then 120 | usage 121 | exit 1 122 | fi 123 | 124 | case "$1" in 125 | "user") 126 | add_user 127 | ;; 128 | "token") 129 | add_token 130 | ;; 131 | "feed") 132 | add_feed 133 | ;; 134 | esac 135 | } 136 | 137 | add_user() { 138 | printf "Username: " 139 | read -r username 140 | printf "Password: " 141 | read -r password 142 | printf "Role (admin/[user]): " 143 | read -r role 144 | if [ "$role" = "" ] 145 | then 146 | role="user" 147 | fi 148 | 149 | perform post \ 150 | on users \ 151 | as "$JOURNALIST_API_USERNAME" "$JOURNALIST_API_PASSWORD" \ 152 | with "{ 153 | \"username\": \"$username\", 154 | \"password\": \"$password\", 155 | \"role\": \"$role\" 156 | }" 157 | exit $? 158 | } 159 | 160 | add_token() { 161 | printf "Token name: " 162 | read -r tokenname 163 | 164 | perform post \ 165 | on tokens \ 166 | as "$JOURNALIST_API_USERNAME" "$JOURNALIST_API_PASSWORD" \ 167 | with "{ 168 | \"name\": \"$tokenname\" 169 | }" 170 | exit $? 171 | } 172 | 173 | add_feed() { 174 | printf "URL: " 175 | read -r url 176 | printf "Name: " 177 | read -r feedname 178 | printf "Group: " 179 | read -r group 180 | 181 | perform post \ 182 | on feeds \ 183 | as "$JOURNALIST_API_USERNAME" "$JOURNALIST_API_PASSWORD" \ 184 | with "{ 185 | \"name\": \"$feedname\", 186 | \"url\": \"$url\", 187 | \"group\": \"$group\" 188 | }" 189 | exit $? 190 | } 191 | 192 | depcheck curl 193 | depcheck jq 194 | 195 | if [ "$#" -lt 1 ] 196 | then 197 | usage 198 | exit 1 199 | fi 200 | 201 | subcommand="$1" 202 | 203 | case "$subcommand" in 204 | "perform") 205 | perform "${@:2}" 206 | exit $? 207 | ;; 208 | "add") 209 | add "${@:2}" 210 | exit $? 211 | ;; 212 | "help") 213 | usage 214 | exit 0 215 | ;; 216 | *) 217 | usage 218 | exit 1 219 | ;; 220 | esac 221 | 222 | -------------------------------------------------------------------------------- /render.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | - name: journalist 3 | type: web 4 | env: docker 5 | repo: https://github.com/mrusme/journalist.git 6 | branch: master 7 | numInstances: 1 8 | healthCheckPath: /health 9 | envVars: 10 | - key: JOURNALIST_SERVER_BINDIP 11 | value: "0.0.0.0" 12 | - key: DATABASE_URL 13 | fromDatabase: 14 | name: journalistdb 15 | property: connectionString 16 | autoDeploy: false 17 | 18 | databases: 19 | - name: journalistdb 20 | databaseName: journalist 21 | user: journalist 22 | ipAllowList: [] 23 | postgresMajorVersion: 14 24 | 25 | -------------------------------------------------------------------------------- /rss/rss.go: -------------------------------------------------------------------------------- 1 | package rss 2 | 3 | import ( 4 | // log "github.com/sirupsen/logrus" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "encoding/json" 8 | "fmt" 9 | "time" 10 | 11 | "github.com/google/uuid" 12 | "go.uber.org/zap" 13 | 14 | "strings" 15 | 16 | "github.com/mrusme/journalist/crawler" 17 | "github.com/mrusme/journalist/ent" 18 | 19 | "github.com/araddon/dateparse" 20 | "github.com/microcosm-cc/bluemonday" 21 | "github.com/mmcdole/gofeed" 22 | ) 23 | 24 | type Client struct { 25 | parser *gofeed.Parser 26 | url string 27 | username string 28 | password string 29 | Feed *gofeed.Feed 30 | Items *[]*gofeed.Item 31 | ItemsCrawled []crawler.ItemCrawled 32 | exceptItemGUIDs []string 33 | UpdatedAt time.Time 34 | logger *zap.Logger 35 | } 36 | 37 | func NewClient( 38 | feedUrl string, 39 | username string, 40 | password string, 41 | crawl bool, 42 | exceptItemGUIDs []string, 43 | logger *zap.Logger, 44 | ) (*Client, []error) { 45 | client := new(Client) 46 | client.parser = gofeed.NewParser() 47 | client.url = feedUrl 48 | client.username = username 49 | client.password = password 50 | client.exceptItemGUIDs = exceptItemGUIDs 51 | client.logger = logger 52 | 53 | if errs := client.Sync(crawl); errs != nil { 54 | return nil, errs 55 | } 56 | 57 | return client, nil 58 | } 59 | 60 | func (c *Client) Sync(crawl bool) []error { 61 | var errs []error 62 | 63 | c.logger.Debug( 64 | "Starting RSS Sync procedure", 65 | zap.Bool("crawl", crawl), 66 | ) 67 | 68 | feedCrwl := crawler.New(c.logger) 69 | defer feedCrwl.Close() 70 | feedCrwl.SetLocation(c.url) 71 | feedCrwl.SetBasicAuth(c.username, c.password) 72 | feed, err := feedCrwl.ParseFeed() 73 | if err != nil { 74 | c.logger.Debug( 75 | "RSS Sync error occurred for feed crawling", 76 | zap.String("url", c.url), 77 | zap.Error(err), 78 | ) 79 | errs = append(errs, err) 80 | return errs 81 | } 82 | 83 | c.Feed = feed 84 | c.Items = &feed.Items 85 | c.UpdatedAt = time.Now() 86 | 87 | if crawl == true { 88 | c.logger.Debug( 89 | "RSS Sync starting crawling procedure", 90 | zap.Int("exceptItemGUIDsLength", len(c.exceptItemGUIDs)), 91 | ) 92 | crwl := crawler.New(c.logger) 93 | defer crwl.Close() 94 | 95 | for i := 0; i < len(c.Feed.Items); i++ { 96 | var foundException bool = false 97 | itemGUID := GenerateGUIDForItem(c.Feed.Items[i]) 98 | for _, exceptItemGUID := range c.exceptItemGUIDs { 99 | if exceptItemGUID == itemGUID { 100 | c.logger.Debug( 101 | "Crawler found exception, breaking", 102 | zap.String("itemGUID", exceptItemGUID), 103 | zap.String("itemLink", c.Feed.Items[i].Link), 104 | ) 105 | foundException = true 106 | break 107 | } 108 | } 109 | 110 | if foundException == true { 111 | continue 112 | } 113 | c.logger.Debug( 114 | "Crawler found no exception, continuing with item", 115 | zap.String("itemLink", c.Feed.Items[i].Link), 116 | ) 117 | crwl.Reset() 118 | crwl.SetLocation(c.Feed.Items[i].Link) 119 | crwl.SetBasicAuth(c.username, c.password) 120 | itemCrawled, err := crwl.GetReadable(false) 121 | if err != nil { 122 | c.logger.Debug( 123 | "Crawler failed to GetReadable", 124 | zap.String("itemLink", c.Feed.Items[i].Link), 125 | zap.Error(err), 126 | ) 127 | errs = append(errs, err) 128 | continue 129 | } 130 | 131 | c.ItemsCrawled = append(c.ItemsCrawled, itemCrawled) 132 | } 133 | } 134 | 135 | return errs 136 | } 137 | 138 | func (c *Client) SetFeed( 139 | feedLink string, 140 | username string, 141 | password string, 142 | dbFeedTmp *ent.FeedCreate, 143 | ) *ent.FeedCreate { 144 | // TODO: Get system timezone 145 | ltz, _ := time.LoadLocation("UTC") 146 | time.Local = ltz 147 | 148 | feedUpdated, err := dateparse.ParseLocal(c.Feed.Updated) 149 | if err != nil { 150 | feedUpdated = time.Now() 151 | } 152 | feedPublished, err := dateparse.ParseLocal(c.Feed.Published) 153 | if err != nil { 154 | feedPublished = time.Now() 155 | } 156 | 157 | dbFeedTmp = dbFeedTmp. 158 | SetURL(feedLink). 159 | SetUsername(username). 160 | SetPassword(password). 161 | SetFeedTitle(c.Feed.Title). 162 | SetFeedDescription(c.Feed.Description). 163 | SetFeedLink(c.Feed.Link). 164 | SetFeedFeedLink(c.Feed.FeedLink). 165 | SetFeedUpdated(feedUpdated). 166 | SetFeedPublished(feedPublished). 167 | SetFeedLanguage(c.Feed.Language). 168 | SetFeedCopyright(c.Feed.Copyright). 169 | SetFeedGenerator(c.Feed.Generator). 170 | SetFeedCopyright(c.Feed.Copyright). 171 | SetFeedCategories(strings.Join(c.Feed.Categories, ", ")) 172 | 173 | if c.Feed.Author != nil { 174 | dbFeedTmp = dbFeedTmp. 175 | SetFeedAuthorName(c.Feed.Author.Name). 176 | SetFeedAuthorEmail(c.Feed.Author.Email) 177 | } 178 | if c.Feed.Image != nil { 179 | dbFeedTmp = dbFeedTmp. 180 | SetFeedImageTitle(c.Feed.Image.Title). 181 | SetFeedImageURL(c.Feed.Image.URL) 182 | } 183 | 184 | return dbFeedTmp 185 | } 186 | 187 | func (c *Client) SetItem( 188 | feedID uuid.UUID, 189 | idx int, 190 | dbItemTemp *ent.ItemCreate, 191 | ) *ent.ItemCreate { 192 | var crawled crawler.ItemCrawled 193 | if len(c.ItemsCrawled) > idx { 194 | crawled = c.ItemsCrawled[idx] 195 | } 196 | 197 | item := c.Feed.Items[idx] 198 | 199 | // TODO: Get system timezone 200 | ltz, _ := time.LoadLocation("UTC") 201 | time.Local = ltz 202 | 203 | itemUpdated, err := dateparse.ParseLocal(item.Updated) 204 | if err != nil { 205 | itemUpdated = time.Now() 206 | } 207 | itemPublished, err := dateparse.ParseLocal(item.Published) 208 | if err != nil { 209 | itemPublished = time.Now() 210 | } 211 | 212 | var enclosureJson string = "" 213 | if item.Enclosures != nil { 214 | jsonbytes, err := json.Marshal(item.Enclosures) 215 | if err == nil { 216 | enclosureJson = string(jsonbytes) 217 | } 218 | } 219 | 220 | itemDescription := bluemonday. 221 | StrictPolicy(). 222 | Sanitize(item.Description) 223 | 224 | dbItemTemp = dbItemTemp. 225 | SetFeedID(feedID). 226 | SetItemGUID(GenerateGUIDForItem(item)). 227 | SetItemTitle(item.Title). 228 | SetItemDescription(itemDescription). 229 | SetItemContent(item.Content). 230 | SetItemLink(item.Link). 231 | SetItemUpdated(itemUpdated). 232 | SetItemPublished(itemPublished). 233 | SetItemCategories(strings.Join(item.Categories, ",")). 234 | SetItemEnclosures(enclosureJson). 235 | SetCrawlerTitle(crawled.Title). 236 | SetCrawlerAuthor(crawled.Author). 237 | SetCrawlerExcerpt(crawled.Excerpt). 238 | SetCrawlerSiteName(crawled.SiteName). 239 | SetCrawlerImage(crawled.Image). 240 | SetCrawlerContentHTML(crawled.ContentHtml). 241 | SetCrawlerContentText(crawled.ContentText) 242 | 243 | if item.Author != nil { 244 | dbItemTemp = dbItemTemp. 245 | SetItemAuthorName(item.Author.Name). 246 | SetItemAuthorEmail(item.Author.Email) 247 | } 248 | 249 | if item.Image != nil { 250 | dbItemTemp = dbItemTemp. 251 | SetItemImageTitle(item.Image.Title). 252 | SetItemImageURL(item.Image.URL) 253 | } 254 | 255 | return dbItemTemp 256 | } 257 | 258 | func GenerateGUID(from string) string { 259 | h := sha256.New() 260 | h.Write([]byte(from)) 261 | return hex.EncodeToString( 262 | h.Sum(nil), 263 | ) 264 | } 265 | 266 | func GenerateGUIDForItem(item *gofeed.Item) string { 267 | return GenerateGUID( 268 | fmt.Sprintf("%s%s", item.Link, item.Published), 269 | ) 270 | } 271 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | debug="$1" 3 | 4 | export JOURNALIST_API_URL="http://127.0.0.1:8000/api/v1" 5 | 6 | admin_user="admin" 7 | admin_pass="admin" 8 | 9 | user1_id="" 10 | user1_user="user1" 11 | user1_pass="p4sS!123456" 12 | 13 | user2_id="" 14 | user2_user="user2" 15 | user2_pass="p4sS!123456" 16 | 17 | feed1_url="http://lorem-rss.herokuapp.com/feed" 18 | feed2_url="https://xn--gckvb8fzb.com" 19 | 20 | failfast() { 21 | if [ "$1" -ne "0" ] 22 | then 23 | printf " FAILED: %s\n" "$2" 24 | exit "$1" 25 | else 26 | printf " SUCCESS\n" 27 | if [ "$debug" = "true" ] 28 | then 29 | printf " DEBUG: %s\n" "$2" 30 | fi 31 | fi 32 | } 33 | 34 | 35 | #------------------------------------------------------------------------------# 36 | printf "\ 37 | ## Listing all users as admin \ 38 | \n" 39 | # - - - - - - - - - - - - - - - - - - - - - - - - - - # 40 | out=$( 41 | ./redacteur perform get \ 42 | on users \ 43 | as $admin_user $admin_pass 44 | ) 45 | failfast $? "$out" 46 | 47 | 48 | 49 | #------------------------------------------------------------------------------# 50 | printf "\ 51 | ## Creating user as admin with username %s \ 52 | \n" "$user1_user" 53 | # - - - - - - - - - - - - - - - - - - - - - - - - - - # 54 | out=$( 55 | ./redacteur perform post \ 56 | on users \ 57 | as $admin_user $admin_pass \ 58 | with "{ 59 | \"username\": \"$user1_user\", 60 | \"password\": \"$user1_pass\", 61 | \"role\": \"user\" 62 | }" 63 | ) 64 | failfast $? "$out" 65 | #------------------------------------------------------------------------------# 66 | 67 | 68 | 69 | #------------------------------------------------------------------------------# 70 | printf "\ 71 | ## Listing all users as admin \ 72 | \n" 73 | # - - - - - - - - - - - - - - - - - - - - - - - - - - # 74 | out=$( 75 | ./redacteur perform get \ 76 | on users \ 77 | as $admin_user $admin_pass 78 | ) 79 | failfast $? "$out" 80 | user1_id="$(printf "%s" "$out" | jq --raw-output ".users[] | select(.username == \"$user1_user\") | .id")" 81 | #------------------------------------------------------------------------------# 82 | 83 | 84 | 85 | #------------------------------------------------------------------------------# 86 | printf "\ 87 | ## Updating %s as admin with 'admin' role \ 88 | \n" "$user1_user" 89 | # - - - - - - - - - - - - - - - - - - - - - - - - - - # 90 | out=$( 91 | ./redacteur perform put \ 92 | on users/$user1_id \ 93 | as $admin_user $admin_pass \ 94 | with "{ 95 | \"role\": \"admin\" 96 | }" 97 | ) 98 | failfast $? "$out" 99 | #------------------------------------------------------------------------------# 100 | 101 | 102 | 103 | #------------------------------------------------------------------------------# 104 | printf "\ 105 | ## Updating %s as admin with 'user' role \ 106 | \n" "$user1_user" 107 | # - - - - - - - - - - - - - - - - - - - - - - - - - - # 108 | out=$( 109 | ./redacteur perform put \ 110 | on users/$user1_id \ 111 | as $admin_user $admin_pass \ 112 | with "{ 113 | \"role\": \"user\" 114 | }" 115 | ) 116 | failfast $? "$out" 117 | #------------------------------------------------------------------------------# 118 | 119 | 120 | 121 | #------------------------------------------------------------------------------# 122 | printf "\ 123 | ## Creating user as admin with username %s \ 124 | \n" "$user2_user" 125 | # - - - - - - - - - - - - - - - - - - - - - - - - - - # 126 | out=$( 127 | ./redacteur perform post \ 128 | on users \ 129 | as $admin_user $admin_pass \ 130 | with "{ 131 | \"username\": \"$user2_user\", 132 | \"password\": \"$user2_pass\", 133 | \"role\": \"user\" 134 | }" 135 | ) 136 | failfast $? "$out" 137 | #------------------------------------------------------------------------------# 138 | 139 | 140 | 141 | #------------------------------------------------------------------------------# 142 | printf "\ 143 | ## Creating token as %s with name 'mytoken' \ 144 | \n" "$user1_user" 145 | # - - - - - - - - - - - - - - - - - - - - - - - - - - # 146 | out=$( 147 | ./redacteur perform post \ 148 | on tokens \ 149 | as $user1_user $user1_pass \ 150 | with "{ 151 | \"name\": \"mytoken\" 152 | }" 153 | ) 154 | failfast $? "$out" 155 | #------------------------------------------------------------------------------# 156 | 157 | 158 | 159 | #------------------------------------------------------------------------------# 160 | printf "\ 161 | ## Creating feed as %s with URL %s \ 162 | \n" "$user1_user" "$feed1_url" 163 | # - - - - - - - - - - - - - - - - - - - - - - - - - - # 164 | out=$( 165 | ./redacteur perform post \ 166 | on feeds \ 167 | as $user1_user $user1_pass \ 168 | with "{ 169 | \"name\": \"xn--gckvb8fzb.com\", 170 | \"url\": \"$feed1_url\", 171 | \"group\": \"Journals\" 172 | }" 173 | ) 174 | failfast $? "$out" 175 | #------------------------------------------------------------------------------# 176 | 177 | 178 | 179 | #------------------------------------------------------------------------------# 180 | printf "\ 181 | ## Creating feed as %s with URL %s \ 182 | \n" "$user2_user" "$feed1_url" 183 | # - - - - - - - - - - - - - - - - - - - - - - - - - - # 184 | out=$( 185 | ./redacteur perform post \ 186 | on feeds \ 187 | as $user2_user $user2_pass \ 188 | with "{ 189 | \"name\": \"xn--gckvb8fzb.com\", 190 | \"url\": \"$feed1_url\", 191 | \"group\": \"Journals\" 192 | }" 193 | ) 194 | failfast $? "$out" 195 | #------------------------------------------------------------------------------# 196 | 197 | -------------------------------------------------------------------------------- /views/actions.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ .Title }} 7 | 25 | 26 | 27 |
28 |
29 | {{ .Message }}
30 | 31 |
32 |
33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /views/subscriptions.list.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ escape .Title }} 5 | {{ escape .Link }} 6 | {{ escape .Description }} 7 | {{ .Generator }} 8 | {{ .Language }} 9 | {{ timestamp .LastBuildDate }} 10 | 11 | {{ range .Items }} 12 | 13 | {{ .ItemGUID }} 14 | {{ escape .ItemLink }} 15 | {{ escape .ItemTitle }} 16 | {{ escape .ItemDescription }} 17 | {{ timestamp .ItemPublished }} 18 | 19 | 21 | 22 | Mark this as read 23 | 24 | · 25 | 26 | Mark this and all older as read 27 | 28 | · 29 | 30 | Mark this and all newer as read 31 | 32 | · 33 | 34 | Mark all as read 35 | 36 | 37 |
38 |
39 | {{ .CrawlerContentHTML }} 40 | ]]> 41 |
42 |
43 | {{ end }} 44 |
45 |
46 | 47 | -------------------------------------------------------------------------------- /web/actions/actions.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/mrusme/journalist/ent" 6 | "github.com/mrusme/journalist/lib" 7 | "go.uber.org/zap" 8 | ) 9 | 10 | type handler struct { 11 | jctx *lib.JournalistContext 12 | 13 | config *lib.Config 14 | entClient *ent.Client 15 | logger *zap.Logger 16 | } 17 | 18 | func Register( 19 | jctx *lib.JournalistContext, 20 | fiberRouter *fiber.Router, 21 | ) { 22 | endpoint := new(handler) 23 | endpoint.jctx = jctx 24 | endpoint.config = endpoint.jctx.Config 25 | endpoint.entClient = endpoint.jctx.EntClient 26 | endpoint.logger = endpoint.jctx.Logger 27 | 28 | actionsRouter := (*fiberRouter).Group("/actions") 29 | actionsRouter.Get("/read/:id", endpoint.Read) 30 | actionsRouter.Get("/read_older/:id", endpoint.ReadOlder) 31 | actionsRouter.Get("/read_newer/:id", endpoint.ReadNewer) 32 | // actionsRouter.Get("/read_all/:id", endpoint.ReadAll) 33 | } 34 | 35 | func (h *handler) resp(ctx *fiber.Ctx, content fiber.Map) error { 36 | err := ctx.Render("views/actions", content) 37 | ctx.Set("Content-type", "text/html; charset=utf-8") 38 | return err 39 | } 40 | -------------------------------------------------------------------------------- /web/actions/read.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | "github.com/mrusme/journalist/ent" 8 | "github.com/mrusme/journalist/ent/item" 9 | "github.com/mrusme/journalist/ent/predicate" 10 | "github.com/mrusme/journalist/ent/subscription" 11 | "github.com/mrusme/journalist/ent/user" 12 | 13 | "context" 14 | 15 | "github.com/gofiber/fiber/v2" 16 | ) 17 | 18 | func (h *handler) Read(ctx *fiber.Ctx) error { 19 | id := ctx.Params("id") 20 | // qat := ctx.Query("qat") 21 | // group := ctx.Query("group") 22 | 23 | // sessionUsername := ctx.Locals("username").(string) 24 | sessionUserId := ctx.Locals("user_id").(string) 25 | myId, err := uuid.Parse(sessionUserId) 26 | if err != nil { 27 | h.resp(ctx, fiber.Map{ 28 | "Success": false, 29 | "Title": "Error", 30 | "Message": err.Error(), 31 | }) 32 | return err 33 | } 34 | 35 | dbUser, err := h.entClient.User. 36 | Query(). 37 | Where( 38 | user.ID(myId), 39 | ). 40 | Only(context.Background()) 41 | 42 | dbItem, err := h.entClient.Item. 43 | Query(). 44 | Where( 45 | item.ItemGUID(id), 46 | ). 47 | Only(context.Background()) 48 | if err != nil { 49 | h.resp(ctx, fiber.Map{ 50 | "Success": false, 51 | "Title": "Error", 52 | "Message": err.Error(), 53 | }) 54 | return err 55 | } 56 | 57 | err = dbUser. 58 | Update(). 59 | AddReadItemIDs(dbItem.ID). 60 | Exec(context.Background()) 61 | if err != nil { 62 | h.resp(ctx, fiber.Map{ 63 | "Success": false, 64 | "Title": "Error", 65 | "Message": err.Error(), 66 | }) 67 | return err 68 | } 69 | 70 | return h.resp(ctx, fiber.Map{ 71 | "Success": true, 72 | "Title": "Marked as read", 73 | "Message": "Item was marked as read!", 74 | }) 75 | } 76 | 77 | func (h *handler) readWithItemCondition( 78 | userId uuid.UUID, 79 | group string, 80 | itemGUID string, 81 | itemCondition func(v time.Time) predicate.Item, 82 | ) error { 83 | dbItem, err := h.entClient.Item. 84 | Query(). 85 | Where( 86 | item.ItemGUID(itemGUID), 87 | ). 88 | Only(context.Background()) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | dbTmp := h.entClient.User. 94 | Query(). 95 | Where( 96 | user.ID(userId), 97 | ). 98 | QuerySubscriptions() 99 | 100 | if group != "" { 101 | dbTmp = dbTmp. 102 | Where(subscription.Group(group)) 103 | } 104 | 105 | dbItems, err := dbTmp. 106 | QueryFeed(). 107 | QueryItems(). 108 | Where( 109 | item.Or( 110 | item.ID(dbItem.ID), 111 | itemCondition(dbItem.ItemPublished), 112 | ), 113 | ). 114 | All(context.Background()) 115 | 116 | if err != nil { 117 | return err 118 | } 119 | 120 | bulkReads := make([]*ent.ReadCreate, len(dbItems)) 121 | for i, item := range dbItems { 122 | bulkReads[i] = h.entClient.Read. 123 | Create(). 124 | SetUserID(userId). 125 | SetItemID(item.ID) 126 | } 127 | err = h.entClient.Read. 128 | CreateBulk(bulkReads...). 129 | OnConflict(). 130 | Ignore(). 131 | Exec(context.Background()) 132 | 133 | if err != nil { 134 | return err 135 | } 136 | 137 | return nil 138 | } 139 | -------------------------------------------------------------------------------- /web/actions/read_all.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "github.com/mrusme/journalist/ent" 6 | "github.com/mrusme/journalist/ent/item" 7 | "github.com/mrusme/journalist/ent/subscription" 8 | "github.com/mrusme/journalist/ent/user" 9 | 10 | "context" 11 | 12 | "github.com/gofiber/fiber/v2" 13 | ) 14 | 15 | func (h *handler) ReadAll(ctx *fiber.Ctx) error { 16 | // id := ctx.Params("id") 17 | group := ctx.Query("group") 18 | 19 | sessionUserId := ctx.Locals("user_id").(string) 20 | myId, err := uuid.Parse(sessionUserId) 21 | if err != nil { 22 | h.resp(ctx, fiber.Map{ 23 | "Success": false, 24 | "Title": "Error", 25 | "Message": err.Error(), 26 | }) 27 | return err 28 | } 29 | 30 | dbTmp := h.entClient.User. 31 | Query(). 32 | Where( 33 | user.ID(myId), 34 | ). 35 | QuerySubscriptions() 36 | 37 | if group != "" { 38 | dbTmp = dbTmp. 39 | Where(subscription.Group(group)) 40 | } 41 | 42 | dbItems, err := dbTmp. 43 | QueryFeed(). 44 | QueryItems(). 45 | Where( 46 | item.Not( 47 | item.HasReadByUsersWith(user.ID(myId)), 48 | ), 49 | ). 50 | All(context.Background()) 51 | 52 | if err != nil { 53 | h.resp(ctx, fiber.Map{ 54 | "Success": false, 55 | "Title": "Error", 56 | "Message": err.Error(), 57 | }) 58 | return err 59 | } 60 | 61 | bulkReads := make([]*ent.ReadCreate, len(dbItems)) 62 | for i, item := range dbItems { 63 | bulkReads[i] = h.entClient.Read. 64 | Create(). 65 | SetUserID(myId). 66 | SetItemID(item.ID) 67 | } 68 | err = h.entClient.Read. 69 | CreateBulk(bulkReads...). 70 | OnConflict(). 71 | Ignore(). 72 | Exec(context.Background()) 73 | 74 | if err != nil { 75 | h.resp(ctx, fiber.Map{ 76 | "Success": false, 77 | "Title": "Error", 78 | "Message": err.Error(), 79 | }) 80 | return err 81 | } 82 | 83 | return h.resp(ctx, fiber.Map{ 84 | "Success": true, 85 | "Title": "Marked as read", 86 | "Message": "Item was marked as read!", 87 | }) 88 | } 89 | -------------------------------------------------------------------------------- /web/actions/read_newer.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "github.com/mrusme/journalist/ent/item" 6 | 7 | "github.com/gofiber/fiber/v2" 8 | ) 9 | 10 | func (h *handler) ReadNewer(ctx *fiber.Ctx) error { 11 | id := ctx.Params("id") 12 | group := ctx.Query("group") 13 | 14 | sessionUserId := ctx.Locals("user_id").(string) 15 | myId, err := uuid.Parse(sessionUserId) 16 | if err != nil { 17 | h.resp(ctx, fiber.Map{ 18 | "Success": false, 19 | "Title": "Error", 20 | "Message": err.Error(), 21 | }) 22 | return err 23 | } 24 | 25 | err = h.readWithItemCondition( 26 | myId, 27 | group, 28 | id, 29 | item.ItemPublishedGT, 30 | ) 31 | if err != nil { 32 | h.resp(ctx, fiber.Map{ 33 | "Success": false, 34 | "Title": "Error", 35 | "Message": err.Error(), 36 | }) 37 | return err 38 | } 39 | 40 | return h.resp(ctx, fiber.Map{ 41 | "Success": true, 42 | "Title": "Marked as read", 43 | "Message": "Item was marked as read!", 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /web/actions/read_older.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "github.com/mrusme/journalist/ent/item" 6 | 7 | "github.com/gofiber/fiber/v2" 8 | ) 9 | 10 | func (h *handler) ReadOlder(ctx *fiber.Ctx) error { 11 | id := ctx.Params("id") 12 | group := ctx.Query("group") 13 | 14 | sessionUserId := ctx.Locals("user_id").(string) 15 | myId, err := uuid.Parse(sessionUserId) 16 | if err != nil { 17 | h.resp(ctx, fiber.Map{ 18 | "Success": false, 19 | "Title": "Error", 20 | "Message": err.Error(), 21 | }) 22 | return err 23 | } 24 | 25 | err = h.readWithItemCondition( 26 | myId, 27 | group, 28 | id, 29 | item.ItemPublishedLT, 30 | ) 31 | if err != nil { 32 | h.resp(ctx, fiber.Map{ 33 | "Success": false, 34 | "Title": "Error", 35 | "Message": err.Error(), 36 | }) 37 | return err 38 | } 39 | 40 | return h.resp(ctx, fiber.Map{ 41 | "Success": true, 42 | "Title": "Marked as read", 43 | "Message": "Item was marked as read!", 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /web/engine.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "html" 6 | "io" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "sync" 12 | "text/template" 13 | "time" 14 | 15 | "github.com/gofiber/template/utils" 16 | ) 17 | 18 | // Engine struct 19 | type Engine struct { 20 | // delimiters 21 | left string 22 | right string 23 | // views folder 24 | directory string 25 | // http.FileSystem supports embedded files 26 | fileSystem http.FileSystem 27 | // views extension 28 | extension string 29 | // layout variable name that incapsulates the template 30 | layout string 31 | // determines if the engine parsed all templates 32 | loaded bool 33 | // reload on each render 34 | reload bool 35 | // debug prints the parsed templates 36 | debug bool 37 | // lock for funcmap and templates 38 | mutex sync.RWMutex 39 | // template funcmap 40 | funcmap map[string]interface{} 41 | // templates 42 | Templates *template.Template 43 | } 44 | 45 | // New returns a HTML render engine for Fiber 46 | func New(directory, extension string) *Engine { 47 | engine := &Engine{ 48 | left: "{{", 49 | right: "}}", 50 | directory: directory, 51 | extension: extension, 52 | layout: "embed", 53 | funcmap: make(map[string]interface{}), 54 | } 55 | engine.AddFunc(engine.layout, func() error { 56 | return fmt.Errorf("layout called unexpectedly.") 57 | }) 58 | engine.AddFunc("escape", func(s string) string { 59 | return html.EscapeString(s) 60 | }) 61 | engine.AddFunc("timestamp", func(t time.Time) string { 62 | return t.Format(time.RFC822Z) 63 | }) 64 | 65 | return engine 66 | } 67 | 68 | //NewFileSystem ... 69 | func NewFileSystem(fs http.FileSystem, extension string) *Engine { 70 | engine := &Engine{ 71 | left: "{{", 72 | right: "}}", 73 | directory: "/", 74 | fileSystem: fs, 75 | extension: extension, 76 | layout: "embed", 77 | funcmap: make(map[string]interface{}), 78 | } 79 | engine.AddFunc(engine.layout, func() error { 80 | return fmt.Errorf("layout called unexpectedly.") 81 | }) 82 | engine.AddFunc("escape", func(s string) string { 83 | return html.EscapeString(s) 84 | }) 85 | engine.AddFunc("timestamp", func(t time.Time) string { 86 | return t.Format(time.RFC822Z) 87 | }) 88 | 89 | return engine 90 | } 91 | 92 | // Layout defines the variable name that will incapsulate the template 93 | func (e *Engine) Layout(key string) *Engine { 94 | e.layout = key 95 | return e 96 | } 97 | 98 | // Delims sets the action delimiters to the specified strings, to be used in 99 | // templates. An empty delimiter stands for the 100 | // corresponding default: {{ or }}. 101 | func (e *Engine) Delims(left, right string) *Engine { 102 | e.left, e.right = left, right 103 | return e 104 | } 105 | 106 | // AddFunc adds the function to the template's function map. 107 | // It is legal to overwrite elements of the default actions 108 | func (e *Engine) AddFunc(name string, fn interface{}) *Engine { 109 | e.mutex.Lock() 110 | e.funcmap[name] = fn 111 | e.mutex.Unlock() 112 | return e 113 | } 114 | 115 | // AddFuncMap adds the functions from a map to the template's function map. 116 | // It is legal to overwrite elements of the default actions 117 | func (e *Engine) AddFuncMap(m map[string]interface{}) *Engine { 118 | e.mutex.Lock() 119 | for name, fn := range m { 120 | e.funcmap[name] = fn 121 | } 122 | e.mutex.Unlock() 123 | return e 124 | } 125 | 126 | // Reload if set to true the templates are reloading on each render, 127 | // use it when you're in development and you don't want to restart 128 | // the application when you edit a template file. 129 | func (e *Engine) Reload(enabled bool) *Engine { 130 | e.reload = enabled 131 | return e 132 | } 133 | 134 | // Debug will print the parsed templates when Load is triggered. 135 | func (e *Engine) Debug(enabled bool) *Engine { 136 | e.debug = enabled 137 | return e 138 | } 139 | 140 | // Parse is deprecated, please use Load() instead 141 | func (e *Engine) Parse() error { 142 | fmt.Println("Parse() is deprecated, please use Load() instead.") 143 | return e.Load() 144 | } 145 | 146 | // Load parses the templates to the engine. 147 | func (e *Engine) Load() error { 148 | if e.loaded { 149 | return nil 150 | } 151 | // race safe 152 | e.mutex.Lock() 153 | defer e.mutex.Unlock() 154 | e.Templates = template.New(e.directory) 155 | 156 | // Set template settings 157 | e.Templates.Delims(e.left, e.right) 158 | e.Templates.Funcs(e.funcmap) 159 | 160 | walkFn := func(path string, info os.FileInfo, err error) error { 161 | // Return error if exist 162 | if err != nil { 163 | return err 164 | } 165 | // Skip file if it's a directory or has no file info 166 | if info == nil || info.IsDir() { 167 | return nil 168 | } 169 | // Skip file if it does not equal the given template extension 170 | if len(e.extension) >= len(path) || path[len(path)-len(e.extension):] != e.extension { 171 | return nil 172 | } 173 | // Get the relative file path 174 | // ./views/html/index.tmpl -> index.tmpl 175 | rel, err := filepath.Rel(e.directory, path) 176 | if err != nil { 177 | return err 178 | } 179 | // Reverse slashes '\' -> '/' and 180 | // partials\footer.tmpl -> partials/footer.tmpl 181 | name := filepath.ToSlash(rel) 182 | // Remove ext from name 'index.tmpl' -> 'index' 183 | name = strings.TrimSuffix(name, e.extension) 184 | // name = strings.Replace(name, e.extension, "", -1) 185 | // Read the file 186 | // #gosec G304 187 | buf, err := utils.ReadFile(path, e.fileSystem) 188 | if err != nil { 189 | return err 190 | } 191 | // Create new template associated with the current one 192 | // This enable use to invoke other templates {{ template .. }} 193 | _, err = e.Templates.New(name).Parse(string(buf)) 194 | if err != nil { 195 | return err 196 | } 197 | // Debugging 198 | if e.debug { 199 | fmt.Printf("views: parsed template: %s\n", name) 200 | } 201 | return err 202 | } 203 | // notify engine that we parsed all templates 204 | e.loaded = true 205 | if e.fileSystem != nil { 206 | return utils.Walk(e.fileSystem, e.directory, walkFn) 207 | } 208 | return filepath.Walk(e.directory, walkFn) 209 | } 210 | 211 | // Render will execute the template name along with the given values. 212 | func (e *Engine) Render(out io.Writer, template string, binding interface{}, layout ...string) error { 213 | if !e.loaded || e.reload { 214 | if e.reload { 215 | e.loaded = false 216 | } 217 | if err := e.Load(); err != nil { 218 | return err 219 | } 220 | } 221 | 222 | tmpl := e.Templates.Lookup(template) 223 | if tmpl == nil { 224 | return fmt.Errorf("render: template %s does not exist", template) 225 | } 226 | if len(layout) > 0 && layout[0] != "" { 227 | lay := e.Templates.Lookup(layout[0]) 228 | if lay == nil { 229 | return fmt.Errorf("render: layout %s does not exist", layout[0]) 230 | } 231 | e.mutex.Lock() 232 | defer e.mutex.Unlock() 233 | lay.Funcs(map[string]interface{}{ 234 | e.layout: func() error { 235 | return tmpl.Execute(out, binding) 236 | }, 237 | }) 238 | return lay.Execute(out, binding) 239 | } 240 | return tmpl.Execute(out, binding) 241 | } 242 | -------------------------------------------------------------------------------- /web/subscriptions/list.go: -------------------------------------------------------------------------------- 1 | package subscriptions 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/google/uuid" 8 | "github.com/mrusme/journalist/ent" 9 | "github.com/mrusme/journalist/ent/item" 10 | "github.com/mrusme/journalist/ent/subscription" 11 | "github.com/mrusme/journalist/ent/user" 12 | "github.com/mrusme/journalist/journalistd" 13 | 14 | "context" 15 | 16 | "github.com/gofiber/fiber/v2" 17 | ) 18 | 19 | func (h *handler) List(ctx *fiber.Ctx) error { 20 | qat := ctx.Query("qat") 21 | group := ctx.Query("group") 22 | sessionUsername := ctx.Locals("username").(string) 23 | sessionUserId := ctx.Locals("user_id").(string) 24 | myId, err := uuid.Parse(sessionUserId) 25 | if err != nil { 26 | ctx.SendStatus(fiber.StatusInternalServerError) 27 | return err 28 | } 29 | 30 | dbItemsTmp := h.entClient.Subscription. 31 | Query() 32 | 33 | if group == "" { 34 | dbItemsTmp = dbItemsTmp.Where( 35 | subscription.UserID(myId), 36 | ) 37 | } else { 38 | dbItemsTmp = dbItemsTmp.Where( 39 | subscription.UserID(myId), 40 | subscription.Group(group), 41 | ) 42 | } 43 | 44 | dbItems, err := dbItemsTmp. 45 | QueryFeed(). 46 | QueryItems(). 47 | Where( 48 | item.Not( 49 | item.HasReadByUsersWith( 50 | user.ID(myId), 51 | ), 52 | ), 53 | ). 54 | Order( 55 | ent.Desc(item.FieldItemPublished), 56 | ). 57 | All(context.Background()) 58 | if err != nil { 59 | ctx.SendStatus(fiber.StatusInternalServerError) 60 | return err 61 | } 62 | 63 | err = ctx.Render("views/subscriptions.list", fiber.Map{ 64 | "Config": h.config, 65 | "Token": fiber.Map{ 66 | "Type": "qat", 67 | "Token": qat, 68 | }, 69 | "Group": group, 70 | 71 | "Title": h.tmplTitle(group), 72 | "Link": h.tmplLink("qat", qat, group), 73 | "Description": h.tmplDescription(sessionUsername, group), 74 | "Generator": h.tmplGenerator(), 75 | "Language": "en-us", 76 | "LastBuildDate": time.Now(), 77 | 78 | "Items": dbItems, 79 | }) 80 | ctx.Set("Content-type", "text/xml; charset=utf-8") 81 | return err 82 | } 83 | 84 | func (h *handler) tmplTitle(group string) string { 85 | var title string = "Subscriptions" 86 | if group != "" { 87 | title = group 88 | } 89 | 90 | return title 91 | } 92 | 93 | func (h *handler) tmplDescription( 94 | username string, 95 | group string, 96 | ) string { 97 | var description string = "" 98 | 99 | if username[len(username)-1] == 's' { 100 | description = fmt.Sprintf( 101 | "%s' subscriptions", 102 | username, 103 | ) 104 | } else { 105 | description = fmt.Sprintf( 106 | "%s's subscriptions", 107 | username, 108 | ) 109 | } 110 | 111 | if group != "" { 112 | description = fmt.Sprintf( 113 | "%s in %s", 114 | description, 115 | group, 116 | ) 117 | } 118 | 119 | return description 120 | } 121 | 122 | func (h *handler) tmplLink( 123 | tokenType string, 124 | token string, 125 | group string, 126 | ) string { 127 | return fmt.Sprintf( 128 | "%s/subscriptions?group=%s&%s=%s", 129 | h.config.Server.Endpoint.Web, 130 | group, 131 | tokenType, 132 | token, 133 | ) 134 | } 135 | 136 | func (h *handler) tmplGenerator() string { 137 | return fmt.Sprintf( 138 | "Journalist %s", 139 | journalistd.Version(), 140 | ) 141 | } 142 | -------------------------------------------------------------------------------- /web/subscriptions/subscriptions.go: -------------------------------------------------------------------------------- 1 | package subscriptions 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/mrusme/journalist/ent" 6 | "github.com/mrusme/journalist/lib" 7 | "go.uber.org/zap" 8 | ) 9 | 10 | type handler struct { 11 | jctx *lib.JournalistContext 12 | 13 | config *lib.Config 14 | entClient *ent.Client 15 | logger *zap.Logger 16 | } 17 | 18 | func Register( 19 | jctx *lib.JournalistContext, 20 | fiberRouter *fiber.Router, 21 | ) { 22 | endpoint := new(handler) 23 | endpoint.jctx = jctx 24 | endpoint.config = endpoint.jctx.Config 25 | endpoint.entClient = endpoint.jctx.EntClient 26 | endpoint.logger = endpoint.jctx.Logger 27 | 28 | subscriptionsRouter := (*fiberRouter).Group("/subscriptions") 29 | subscriptionsRouter.Get("/", endpoint.List) 30 | // subscriptionsRouter.Get("/:id", endpoint.Show) 31 | // subscriptionsRouter.Post("/", endpoint.Create) 32 | // subscriptionsRouter.Put("/:id", endpoint.Update) 33 | // subscriptionsRouter.Delete("/:id", endpoint.Destroy) 34 | } 35 | -------------------------------------------------------------------------------- /web/web.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | // "log" 5 | "context" 6 | 7 | "github.com/gofiber/fiber/v2" 8 | "github.com/mrusme/journalist/ent" 9 | "github.com/mrusme/journalist/ent/token" 10 | "github.com/mrusme/journalist/ent/user" 11 | "github.com/mrusme/journalist/lib" 12 | "github.com/mrusme/journalist/web/actions" 13 | "github.com/mrusme/journalist/web/subscriptions" 14 | ) 15 | 16 | func Register( 17 | jctx *lib.JournalistContext, 18 | fiberApp *fiber.App, 19 | ) { 20 | web := fiberApp.Group("/web") 21 | web.Use(authorizer(jctx.EntClient)) 22 | 23 | actions.Register( 24 | jctx, 25 | &web, 26 | ) 27 | 28 | subscriptions.Register( 29 | jctx, 30 | &web, 31 | ) 32 | } 33 | 34 | // TODO: Move to `middlewares` 35 | func authorizer(entClient *ent.Client) fiber.Handler { 36 | return func(ctx *fiber.Ctx) error { 37 | qat := ctx.Query("qat") 38 | if qat == "" { 39 | return ctx.SendStatus(fiber.StatusUnauthorized) 40 | } 41 | 42 | u, err := entClient.User. 43 | Query(). 44 | WithTokens(). 45 | Where( 46 | user.HasTokensWith( 47 | token.Token(qat), 48 | ), 49 | ). 50 | Only(context.Background()) 51 | if err != nil { 52 | return ctx.SendStatus(fiber.StatusUnauthorized) 53 | } 54 | 55 | if u == nil { 56 | return ctx.SendStatus(fiber.StatusUnauthorized) 57 | } 58 | 59 | ctx.Locals("user_id", u.ID.String()) 60 | ctx.Locals("username", u.Username) 61 | // ctx.Locals("password", u.Password) 62 | ctx.Locals("role", u.Role) 63 | return ctx.Next() 64 | } 65 | } 66 | --------------------------------------------------------------------------------