├── test ├── data │ ├── .gitkeep │ ├── tokens.json │ ├── subscriptions.json │ ├── users.json │ ├── posts.json │ └── polls.json └── jmeter │ ├── auth.json.example │ └── README.md ├── pkg ├── backend │ ├── db │ │ ├── migration_test.go │ │ ├── router.go │ │ ├── repositories.go │ │ ├── keeper.go │ │ └── controllers.go │ ├── tokens │ │ ├── errors.go │ │ ├── service_test.go │ │ ├── jwt.go │ │ ├── repository.go │ │ └── service.go │ ├── users │ │ ├── errors.go │ │ ├── service_test.go │ │ ├── router.go │ │ ├── repository.go │ │ └── types.go │ ├── push │ │ ├── types.go │ │ ├── service.go │ │ └── repository.go │ ├── auth │ │ ├── router.go │ │ ├── errors.go │ │ ├── helpers.go │ │ └── auth.go │ ├── stats │ │ ├── router.go │ │ ├── router_test.go │ │ └── controller.go │ ├── mail │ │ ├── templates │ │ │ ├── new_passphrase.tmpl │ │ │ ├── reset_request.tmpl │ │ │ └── activation.tmpl │ │ ├── service.go │ │ ├── send.go │ │ └── template.go │ ├── common │ │ ├── context.go │ │ ├── data.go │ │ ├── response.go │ │ ├── mock_service.go │ │ ├── flush_data.go │ │ └── return_code.go │ ├── live │ │ └── router.go │ ├── polls │ │ ├── router.go │ │ ├── types.go │ │ └── repository.go │ ├── posts │ │ ├── types.go │ │ ├── router.go │ │ └── repository.go │ ├── pprof │ │ └── router.go │ ├── .pix.go │ ├── pages │ │ ├── polls.go │ │ ├── users.go │ │ └── service.go │ ├── .crypto.go │ └── requests │ │ └── repository.go ├── models │ ├── item.go │ ├── stub.go │ ├── request.go │ ├── token.go │ ├── device.go │ ├── repository.go │ ├── poll.go │ ├── post.go │ └── service.go ├── frontend │ ├── common │ │ ├── vars.go │ │ ├── upload.go │ │ ├── user.go │ │ ├── event.go │ │ └── .sse.go │ ├── tos │ │ ├── event_handlers.go │ │ ├── content.go │ │ └── render.go │ ├── settings │ │ ├── event_handlers.go │ │ ├── options.go │ │ └── texts.go │ ├── login │ │ ├── action_handlers.go │ │ ├── content.go │ │ └── render.go │ ├── reset │ │ ├── action_handlers.go │ │ ├── reset.go │ │ └── content.go │ ├── register │ │ ├── action_handlers.go │ │ └── content.go │ ├── polls │ │ ├── helpers.go │ │ └── render.go │ ├── welcome │ │ ├── content.go │ │ └── render.go │ ├── atomic │ │ ├── atoms │ │ │ ├── loader.go │ │ │ ├── snackbar.go │ │ │ ├── search_bar.go │ │ │ ├── image.go │ │ │ ├── page_heading.go │ │ │ ├── textarea.go │ │ │ ├── poll_result.go │ │ │ ├── input.go │ │ │ ├── user_nickname.go │ │ │ ├── button.go │ │ │ └── text.go │ │ ├── molecules │ │ │ ├── counter.go │ │ │ ├── poll_header.go │ │ │ ├── switch.go │ │ │ ├── littr_header.go │ │ │ ├── textbox.go │ │ │ ├── poll_footer.go │ │ │ ├── delete_dialog.go │ │ │ ├── flow_header.go │ │ │ ├── post_header.go │ │ │ ├── details.go │ │ │ ├── post_footer.go │ │ │ └── image_input.go │ │ └── organisms │ │ │ ├── modal_subscription_delete.go │ │ │ ├── modal_post_delete.go │ │ │ ├── modal_poll_delete.go │ │ │ ├── modal_user_delete.go │ │ │ ├── modal_user_logout.go │ │ │ ├── user_requests.go │ │ │ ├── single_user_profile.go │ │ │ ├── modal_user_info.go │ │ │ └── modal_app_info.go │ ├── stats │ │ ├── event_handlers.go │ │ ├── action_handlers.go │ │ └── content.go │ ├── navbars │ │ └── colors.go │ ├── flow │ │ └── uri.go │ └── post │ │ ├── content.go │ │ └── render.go ├── config │ ├── frontend.go │ └── tests.go └── helpers │ └── helpers.go ├── cmd ├── littr │ ├── app.go │ ├── main_wasm.go │ ├── main.go │ ├── errors.go │ ├── client.go │ └── app_handler.go ├── await │ ├── Makefile │ └── README.md ├── dbench │ ├── main.go │ ├── README.md │ └── random.go ├── bincod │ └── main.go ├── sse_client │ └── main.go └── chimp │ └── main.go ├── web ├── favicon.ico ├── click-to-see.gif ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── android-chrome-512x512.xcf ├── android-chrome-512x512-exported.png ├── site.webmanifest ├── manifest.webmanifest └── littr.js ├── .dockerignore ├── .env.example ├── sonar-project.properties ├── .gitignore ├── LICENSE ├── .github └── workflows │ ├── test-and-build.yml │ └── deployment.yml ├── go.mod ├── .gitlab-ci.yml └── deployments └── docker-compose-test.yml /test/data/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/data/tokens.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /test/data/subscriptions.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /pkg/backend/db/migration_test.go: -------------------------------------------------------------------------------- 1 | package db 2 | -------------------------------------------------------------------------------- /cmd/littr/app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type App interface { 4 | Run() 5 | } 6 | -------------------------------------------------------------------------------- /web/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krustowski/littr/HEAD/web/favicon.ico -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | build/* 2 | deployments/* 3 | docs/* 4 | Makefile 5 | .git/* 6 | .github/* 7 | -------------------------------------------------------------------------------- /web/click-to-see.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krustowski/littr/HEAD/web/click-to-see.gif -------------------------------------------------------------------------------- /pkg/models/item.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Item interface { 4 | GetID() string 5 | } 6 | -------------------------------------------------------------------------------- /web/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krustowski/littr/HEAD/web/favicon-16x16.png -------------------------------------------------------------------------------- /web/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krustowski/littr/HEAD/web/favicon-32x32.png -------------------------------------------------------------------------------- /web/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krustowski/littr/HEAD/web/apple-touch-icon.png -------------------------------------------------------------------------------- /web/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krustowski/littr/HEAD/web/android-chrome-192x192.png -------------------------------------------------------------------------------- /web/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krustowski/littr/HEAD/web/android-chrome-512x512.png -------------------------------------------------------------------------------- /web/android-chrome-512x512.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krustowski/littr/HEAD/web/android-chrome-512x512.xcf -------------------------------------------------------------------------------- /cmd/littr/main_wasm.go: -------------------------------------------------------------------------------- 1 | //go:build wasm 2 | 3 | package main 4 | 5 | func main() { 6 | var c = newClient() 7 | c.Run() 8 | } 9 | -------------------------------------------------------------------------------- /web/android-chrome-512x512-exported.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krustowski/littr/HEAD/web/android-chrome-512x512-exported.png -------------------------------------------------------------------------------- /test/data/users.json: -------------------------------------------------------------------------------- 1 | { 2 | "items": { 3 | "system": { 4 | "id": "system", 5 | "nickname": "system" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # 2 | # littr / environment constatns 3 | # 4 | 5 | APP_NAME=littr 6 | APP_VERSION=0.46.62 7 | ALPINE_VERSION=3.20 8 | GOLANG_VERSION=1.24 9 | -------------------------------------------------------------------------------- /pkg/backend/tokens/errors.go: -------------------------------------------------------------------------------- 1 | package tokens 2 | 3 | import "errors" 4 | 5 | var ( 6 | errNotImplemented error = errors.New("not implemented yet") 7 | ) 8 | -------------------------------------------------------------------------------- /pkg/backend/users/errors.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import "errors" 4 | 5 | var ErrUserRequestDecodingFailed = errors.New("could not decode the user request payload") 6 | -------------------------------------------------------------------------------- /test/jmeter/auth.json.example: -------------------------------------------------------------------------------- 1 | {"nickname":"user","passphrase_hex":"ee1fb0ded0e307cefe6567d143adf8aed4dcff32f733ddcbf81507e2e9d25e05dfa15316a985097d21821e7d7060506018b05d73fb24e6f9b5135a8f18891701"} 2 | -------------------------------------------------------------------------------- /pkg/frontend/common/vars.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | var ( 4 | // Those vars are used during the build --- linker (ld) bakes the values in. 5 | AppVersion string 6 | AppPepper string 7 | VapidPublicKey string 8 | ) 9 | -------------------------------------------------------------------------------- /test/data/posts.json: -------------------------------------------------------------------------------- 1 | { 2 | "items": { 3 | "2000": { 4 | "id": "2000", 5 | "nickname": "system", 6 | "content": "welcome to littr, this is a very first flow post on this instance" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /cmd/littr/main.go: -------------------------------------------------------------------------------- 1 | //go:build !wasm 2 | 3 | package main 4 | 5 | import "runtime" 6 | 7 | func main() { 8 | var c = newClient() 9 | c.Run() 10 | 11 | if runtime.GOOS != "wasm" { 12 | var s = newServer() 13 | s.Run() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /cmd/await/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: fmt 2 | fmt: 3 | @gofmt -w -s . 4 | 5 | .PHONY: run 6 | run: fmt 7 | @echo -e "Building await demo..." 8 | @GOOS=js GOARCH=wasm go build -o web/app.wasm main.go 9 | @echo -e "Running await demo..." 10 | @go run main.go 11 | 12 | -------------------------------------------------------------------------------- /pkg/backend/push/types.go: -------------------------------------------------------------------------------- 1 | package push 2 | 3 | type NotificationRequest struct { 4 | PostID string `json:"post_id" example:"123456789000"` 5 | } 6 | 7 | type SubscriptionUpdateRequest struct { 8 | Tags []string `json:"tags" example:"reply,mention"` 9 | } 10 | -------------------------------------------------------------------------------- /web/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /cmd/littr/errors.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "errors" 4 | 5 | var ( 6 | errMissingSecretOrToken = errors.New("server secret string or data dump token must not be blank") 7 | errServerShutdownFailed = errors.New("HTTP server shutdown failed, trying force shutdown... ") 8 | ) 9 | -------------------------------------------------------------------------------- /pkg/frontend/tos/event_handlers.go: -------------------------------------------------------------------------------- 1 | package tos 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | ) 6 | 7 | func (c *Content) onClickDismiss(ctx app.Context, e app.Event) { 8 | c.toastShow = false 9 | c.toast.TText = "" 10 | //c.buttonDisabled = false 11 | } 12 | -------------------------------------------------------------------------------- /web/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"littr PWA","short_name":"littr","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/svg"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} 2 | -------------------------------------------------------------------------------- /pkg/frontend/settings/event_handlers.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | ) 6 | 7 | func (c *Content) onAvatarChange(ctx app.Context, e app.Event) { 8 | ctx.NewActionWithValue("avatar-change", e.Get("target").Get("files").Index(0)) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/backend/auth/router.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | chi "github.com/go-chi/chi/v5" 5 | ) 6 | 7 | func NewAuthRouter(authController *authController) chi.Router { 8 | r := chi.NewRouter() 9 | 10 | r.Post("/", authController.Auth) 11 | r.Post("/logout", authController.Logout) 12 | 13 | return r 14 | } 15 | -------------------------------------------------------------------------------- /pkg/backend/db/router.go: -------------------------------------------------------------------------------- 1 | // The very core database/cache data operations package. 2 | package db 3 | 4 | import ( 5 | chi "github.com/go-chi/chi/v5" 6 | ) 7 | 8 | func NewDumpRouter(controller *dumpController) chi.Router { 9 | r := chi.NewRouter() 10 | 11 | r.Get("/", controller.DumpAll) 12 | 13 | return r 14 | } 15 | -------------------------------------------------------------------------------- /pkg/frontend/login/action_handlers.go: -------------------------------------------------------------------------------- 1 | package login 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | ) 6 | 7 | func (c *Content) handleDismiss(ctx app.Context, a app.Action) { 8 | ctx.Dispatch(func(ctx app.Context) { 9 | c.toast.TText = "" 10 | c.loginButtonDisabled = false 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /pkg/frontend/reset/action_handlers.go: -------------------------------------------------------------------------------- 1 | package reset 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | ) 6 | 7 | func (c *Content) handleDismiss(ctx app.Context, a app.Action) { 8 | ctx.Dispatch(func(ctx app.Context) { 9 | c.toast.TText = "" 10 | c.buttonsDisabled = false 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /pkg/frontend/register/action_handlers.go: -------------------------------------------------------------------------------- 1 | package register 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | ) 6 | 7 | func (c *Content) handleDismiss(ctx app.Context, a app.Action) { 8 | ctx.Dispatch(func(ctx app.Context) { 9 | c.toast.TText = "" 10 | c.registerButtonDisabled = false 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /pkg/models/stub.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // Stub is a stuffing structure type mainly used to patch the Swagger documentation notation for empty structs. 4 | // The example usage could be a following one: 5 | // 6 | // @Success 200 {object} common.APIResponse{data=models.Stub} 7 | type Stub struct{} 8 | 9 | type Empty []struct{} 10 | -------------------------------------------------------------------------------- /pkg/frontend/polls/helpers.go: -------------------------------------------------------------------------------- 1 | package polls 2 | 3 | // contains checks if a string is present in a slice 4 | // https://freshman.tech/snippets/go/check-if-slice-contains-element/ 5 | func contains(s []string, str string) bool { 6 | for _, v := range s { 7 | if v == str { 8 | return true 9 | } 10 | } 11 | return false 12 | } 13 | -------------------------------------------------------------------------------- /pkg/frontend/tos/content.go: -------------------------------------------------------------------------------- 1 | // The very ToS (terms of service) view logic package. 2 | package tos 3 | 4 | import ( 5 | "go.vxn.dev/littr/pkg/frontend/common" 6 | 7 | "github.com/maxence-charriere/go-app/v10/pkg/app" 8 | ) 9 | 10 | type Content struct { 11 | app.Compo 12 | 13 | toast common.Toast 14 | toastShow bool 15 | } 16 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=krusty_littr_06804abd-07d8-42bf-af2b-93196e0c8d28 2 | sonar.projectVersion=0.46.62 3 | sonar.qualitygate.wait=true 4 | sonar.go.coverage.reportPaths=coverage.profile 5 | 6 | sonar.sources=. 7 | sonar.exclusions=**/*_test.go 8 | 9 | sonar.tests=. 10 | sonar.test.inclusions=**/*_test.go 11 | 12 | -------------------------------------------------------------------------------- /pkg/frontend/welcome/content.go: -------------------------------------------------------------------------------- 1 | // The welcome view logic package. 2 | package welcome 3 | 4 | import ( 5 | "github.com/maxence-charriere/go-app/v10/pkg/app" 6 | ) 7 | 8 | type Content struct { 9 | app.Compo 10 | } 11 | 12 | /*func (c *Content) OnMount(ctx app.Context) { 13 | } 14 | 15 | func (c *Content) OnNav(ctx app.Context) { 16 | }*/ 17 | -------------------------------------------------------------------------------- /pkg/backend/stats/router.go: -------------------------------------------------------------------------------- 1 | // The very app statistics routes and controllers logic package for the backend. 2 | package stats 3 | 4 | import ( 5 | chi "github.com/go-chi/chi/v5" 6 | ) 7 | 8 | func NewStatRouter(statController *StatController) chi.Router { 9 | r := chi.NewRouter() 10 | 11 | r.Get("/", statController.GetAll) 12 | 13 | return r 14 | } 15 | -------------------------------------------------------------------------------- /pkg/backend/db/repositories.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "go.vxn.dev/littr/pkg/models" 5 | ) 6 | 7 | // 8 | // Repositories 9 | // 10 | 11 | type Repositories struct { 12 | PollRepository models.PollRepositoryInterface 13 | PostRepository models.PostRepositoryInterface 14 | UserRepository models.UserRepositoryInterface 15 | } 16 | 17 | var Storage *Repositories 18 | -------------------------------------------------------------------------------- /pkg/backend/mail/templates/new_passphrase.tmpl: -------------------------------------------------------------------------------- 1 | Dear {{ .Nickname }}, 2 | 3 | The requested passphrase regeneration process has been successful. Please use the generated string below to log-in again. 4 | 5 | New passphrase: {{ .Passphrase }} 6 | 7 | Please do not forget to change the passphrase right after logging-in in the settings. 8 | 9 | Thank you. 10 | 11 | littr 12 | https://{{ .MainURL }} 13 | -------------------------------------------------------------------------------- /pkg/config/frontend.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | const ( 4 | // NicknameLengthMax is the maximum nickname length to be allowed to register. 5 | MaxNicknameLength int = 12 6 | 7 | // MaxPostLength is the maximal length of the fully shown post in the flow. Posts longer than that are to be shorten/hidden. 8 | MaxPostLength int = 500 9 | 10 | // Max retry count for the minimal SSE client. 11 | MaxSseRetryCount int = 50 12 | ) 13 | -------------------------------------------------------------------------------- /pkg/backend/mail/service.go: -------------------------------------------------------------------------------- 1 | // Common mailing (and templating) package. 2 | package mail 3 | 4 | import ( 5 | "go.vxn.dev/littr/pkg/models" 6 | ) 7 | 8 | type MessagePayload struct { 9 | Email string 10 | Type string 11 | UUID string 12 | Passphrase string 13 | Nickname string 14 | } 15 | 16 | type mailService struct{} 17 | 18 | func NewMailService() models.MailServiceInterface { 19 | return &mailService{} 20 | } 21 | -------------------------------------------------------------------------------- /pkg/backend/common/context.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import "context" 4 | 5 | // Used in context as a custom type. 6 | type UserNickname string 7 | 8 | const ( 9 | ContextUserKeyName UserNickname = "nickname" 10 | DefaultCallerID string = "system" 11 | ) 12 | 13 | func GetCallerID(ctx context.Context) string { 14 | callerID, ok := ctx.Value(ContextUserKeyName).(string) 15 | if !ok || callerID == "" { 16 | return DefaultCallerID 17 | } 18 | 19 | return callerID 20 | } 21 | -------------------------------------------------------------------------------- /pkg/backend/live/router.go: -------------------------------------------------------------------------------- 1 | // The package to provide a server-side instance for the SSE message streaming. 2 | package live 3 | 4 | import ( 5 | chi "github.com/go-chi/chi/v5" 6 | ) 7 | 8 | func NewLiveRouter() chi.Router { 9 | r := chi.NewRouter() 10 | 11 | // Mount the Streamer to /live API route. Wrap the SSE handler in the CORS wrapper. 12 | //r.Mount("/", cors(Streamer)) 13 | r.Mount("/", Streamer) 14 | 15 | // Run the keepalive pacemaker. 16 | go beat() 17 | 18 | return r 19 | } 20 | -------------------------------------------------------------------------------- /test/data/polls.json: -------------------------------------------------------------------------------- 1 | { 2 | "items": { 3 | "test": { 4 | "id": "test", 5 | "question": "lmao what", 6 | "option_one": { 7 | "content": "broo", 8 | "counter": 67 9 | }, 10 | "option_two": { 11 | "content": "stfu", 12 | "counter": 7 13 | }, 14 | "option_three": { 15 | "content": "idk", 16 | "counter": 33 17 | }, 18 | "voted": [ "generic" ], 19 | "author": "generic" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /pkg/backend/auth/errors.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import "errors" 4 | 5 | var ( 6 | errAuthFailed = errors.New("wrong credentials entered, or such user does not exist") 7 | errInvalidInput = errors.New("ivalid input: cannot assert type AuthUser") 8 | errNotActivated = errors.New("user has not been activated yet, check your mail inbox") 9 | errTokenDeletion = errors.New("could not delete associated token") 10 | ) 11 | 12 | var ( 13 | msgSessionTerminated = "session terminated, void cookies provided" 14 | ) 15 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/atoms/loader.go: -------------------------------------------------------------------------------- 1 | package atoms 2 | 3 | import "github.com/maxence-charriere/go-app/v10/pkg/app" 4 | 5 | type Loader struct { 6 | app.Compo 7 | 8 | ID string 9 | 10 | ShowLoader bool 11 | } 12 | 13 | func (l *Loader) Render() app.UI { 14 | return app.Div().ID(l.ID).Body( 15 | app.If(l.ShowLoader, func() app.UI { 16 | return app.Div().Body( 17 | app.Div().Class("small-space"), 18 | app.Progress().Class("circle center large primary-border active"), 19 | ) 20 | }), 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /.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 | #littr 18 | .env 19 | /run_data/ 20 | .scannerwork/ 21 | *.log 22 | /.run_data/* 23 | /.tmp/ 24 | /littr 25 | /bin/* 26 | 27 | /cmd/await/web/* 28 | /test/jmeter/auth.json 29 | /deployments/*override* 30 | -------------------------------------------------------------------------------- /pkg/backend/auth/helpers.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "time" 5 | 6 | "go.vxn.dev/littr/pkg/backend/db" 7 | "go.vxn.dev/littr/pkg/models" 8 | ) 9 | 10 | func noteUsersActivity(callerID string, cache db.Cacher) bool { 11 | // check if caller exists 12 | callerUser, found := cache.Load(callerID) 13 | if !found { 14 | return false 15 | } 16 | 17 | caller, ok := callerUser.(models.User) 18 | if !ok { 19 | return false 20 | } 21 | 22 | // update user's activity timestamp 23 | caller.LastActiveTime = time.Now() 24 | 25 | return cache.Store(callerID, caller) 26 | } 27 | -------------------------------------------------------------------------------- /pkg/frontend/register/content.go: -------------------------------------------------------------------------------- 1 | // The register view and view-controllers logic package. 2 | package register 3 | 4 | import ( 5 | "go.vxn.dev/littr/pkg/frontend/common" 6 | 7 | "github.com/maxence-charriere/go-app/v10/pkg/app" 8 | ) 9 | 10 | type Content struct { 11 | app.Compo 12 | 13 | toast common.Toast 14 | 15 | nickname string 16 | passphrase string 17 | passphraseAgain string 18 | email string 19 | 20 | registerButtonDisabled bool 21 | } 22 | 23 | func (c *Content) OnMount(ctx app.Context) { 24 | ctx.Handle("dismiss", c.handleDismiss) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/backend/polls/router.go: -------------------------------------------------------------------------------- 1 | // Polls routes and controllers logic package for the backend. 2 | package polls 3 | 4 | import ( 5 | chi "github.com/go-chi/chi/v5" 6 | ) 7 | 8 | func NewPollRouter(pollController *PollController) chi.Router { 9 | r := chi.NewRouter() 10 | 11 | // Route routes. 12 | r.Get("/", pollController.GetAll) 13 | r.Post("/", pollController.Create) 14 | 15 | // Operations on an existing resource. 16 | r.Get("/{pollID}", pollController.GetByID) 17 | r.Patch("/{pollID}", pollController.Update) 18 | r.Delete("/{pollID}", pollController.Delete) 19 | 20 | return r 21 | } 22 | -------------------------------------------------------------------------------- /pkg/backend/common/data.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "encoding/json" 5 | //"errors" 6 | "io" 7 | "net/http" 8 | //"go.vxn.dev/littr/pkg/backend/db" 9 | //"go.vxn.dev/swis/v5/pkg/core" 10 | ) 11 | 12 | // UnmarshalRequestData is a helper function that combines reading the request body and data structure unmarshalling from a JSON stream. 13 | func UnmarshalRequestData[T any](r *http.Request, model *T) error { 14 | reqBody, err := io.ReadAll(r.Body) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | err = json.Unmarshal(reqBody, model) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /pkg/backend/mail/templates/reset_request.tmpl: -------------------------------------------------------------------------------- 1 | Dear {{ .Nickname }}, 2 | 3 | We received a request to reset the passphrase for your account that is associated with this e-mail address: {{ .Email }} 4 | 5 | To reset your passphrase, please click on the link below: 6 | 7 | Reset Passphrase Link: {{ .ResetLink }} 8 | 9 | You can insert the generated UUID in the reset form too: 10 | 11 | UUID: {{ .UUID }} 12 | 13 | If you did not request this passphrase reset, please ignore this email. Your passphrase will remain unchanged. For the security reasons, this link will expire in 24 hours. 14 | 15 | Thank you. 16 | 17 | littr 18 | https://{{ .MainURL }} 19 | -------------------------------------------------------------------------------- /pkg/backend/mail/templates/activation.tmpl: -------------------------------------------------------------------------------- 1 | Dear {{ .Nickname }}, 2 | 3 | Thank you for your registration. To be fully prepared to enjoy the littr experience, you need to verify your e-mail address. If you are reading this text, it is very likely that the verification will be successful. Please verify your account by clicking on the link below: 4 | 5 | Activation link: {{ .ActivationLink }} 6 | 7 | If you have not requested such operation or have not registered yourself at all, please ignore this e-mail. The request and associated user account will be deleted after 24 hours of inactivity. 8 | 9 | Thank you. 10 | 11 | littr 12 | https://{{ .MainURL }} 13 | -------------------------------------------------------------------------------- /pkg/models/request.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Request struct { 8 | // Unique UUID. 9 | ID string `json:"id"` 10 | 11 | // User's name to easily fetch user's data from the database. 12 | Nickname string `json:"nickname"` 13 | 14 | // Requesting user's e-mail address. 15 | Email string `json:"email"` 16 | 17 | // Timestamp of the request generation, should expire in 24 hours after creation. 18 | CreatedAt time.Time `json:"created_at"` 19 | 20 | // Type is a helper field to differentiate the request's processor target. 21 | Type string `json:"type"` 22 | } 23 | 24 | func (r Request) GetID() string { 25 | return r.ID 26 | } 27 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/atoms/snackbar.go: -------------------------------------------------------------------------------- 1 | package atoms 2 | 3 | import "github.com/maxence-charriere/go-app/v10/pkg/app" 4 | 5 | type Snackbar struct { 6 | app.Compo 7 | 8 | Class string 9 | ID string 10 | IDLink string 11 | Position string 12 | Text string 13 | 14 | Styles map[string]string 15 | } 16 | 17 | func (s *Snackbar) Render() app.UI { 18 | return app.A().ID(s.IDLink).Href("").Body( 19 | app.If(s.Text != "", func() app.UI { 20 | sb := app.Div() 21 | 22 | sb.Class(s.Position) 23 | 24 | for key, val := range s.Styles { 25 | sb.Style(key, val) 26 | } 27 | 28 | return sb.ID(s.ID).Class(s.Class).Body() 29 | }), 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /pkg/backend/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | type AuthUser struct { 4 | // Nickname is the user's very username. 5 | Nickname string `json:"nickname" example:"alice"` 6 | 7 | // PassphrasePlain is the plain-text format of the passphrase. 8 | PassphrasePlain string `json:"passphrase_plain" example:"s3creTpauWussw0rt"` 9 | 10 | // PassphraseHex is a hexadecimal representation of a passphrase (a SHA-512 checksum). 11 | // Use 'echo $PASS | sha512sum' for example to get the hex format. 12 | PassphraseHex string `json:"passphrase_hex" example:"fb43b35a752b0e8045e2dd1b1e292983b9cbf4672a51e30caaa3f9b06c5a3b74d5096bc8092c9e90a2e047c1eab29eceb50c09d6c51e6995c1674beb3b06535e" swaggerignore:"true"` 13 | } 14 | -------------------------------------------------------------------------------- /pkg/models/token.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Token is a model structure which is to hold refresh token's properties. 8 | type Token struct { 9 | // Unique hash = sha512 sum of refresh token's data. 10 | Hash string `json:"hash"` 11 | 12 | // User's name to easily fetch user's data from the database. 13 | Nickname string `json:"nickname"` 14 | 15 | // Timestamp of the refresh token's generation, should expire in 4 weeks after the initialization. 16 | CreatedAt time.Time `json:"created_at"` 17 | 18 | // Time to live, period of validity since the token creation. 19 | TTL time.Duration `json:"ttl"` 20 | } 21 | 22 | func (t Token) GetID() string { 23 | return t.Hash 24 | } 25 | -------------------------------------------------------------------------------- /pkg/backend/posts/types.go: -------------------------------------------------------------------------------- 1 | package posts 2 | 3 | type PostCreateRequest struct { 4 | Type string `json:"type" example:"post" enums:"post,poll,img"` 5 | ReplyToID string `json:"reply_to_id" example:"1234567890000"` 6 | Content string `json:"content" example:"a very random post's content"` 7 | FigureName string `json:"figure_name" example:"example.jpg"` 8 | FigureData []byte `json:"figure_data" swaggertype:"string" format:"base64" example:"base64 encoded data"` 9 | } 10 | 11 | type PostPagingRequest struct { 12 | PageNo int 13 | PagingSize int 14 | HideReplies bool 15 | SinglePost bool 16 | SinglePostID string 17 | Hashtag string 18 | SingleUser bool 19 | SingleUserID string 20 | } 21 | -------------------------------------------------------------------------------- /cmd/dbench/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | // 8 | // MAIN 9 | // 10 | 11 | func main() { 12 | // Should be initialized automatically. 13 | if defaultTestConfiguration == nil { 14 | return 15 | } 16 | 17 | // Ensure this procedure can be executed just once. 18 | var once sync.Once 19 | 20 | once.Do(func() { 21 | var wgMain sync.WaitGroup 22 | 23 | // Set the workers execution, and start them. 24 | wgMain.Add(1) 25 | go TestHandler(defaultTestConfiguration, &wgMain) 26 | 27 | // Wait for all workers to finish (or hit the deadline). 28 | wgMain.Wait() 29 | 30 | // Fetch and print the test results. 31 | ResultHandler(defaultTestConfiguration) 32 | }) 33 | return 34 | } 35 | -------------------------------------------------------------------------------- /test/jmeter/README.md: -------------------------------------------------------------------------------- 1 | # Apache JMeter 2 | 3 | ## installation (linux amd64) 4 | 5 | + go to [Download JMeter](https://jmeter.apache.org/download_jmeter.cgi), download binary in `.tgz`, open sha512 link there, and to verify checksums by running something like this: 6 | 7 | ``` 8 | sha512sum Downloads/apache-jmeter-5.6.3.tgz | grep 5978a1a35edb5a7d428e270564ff49d2b1b257a65e17a759d259a9283fc17093e522fe46f474a043864aea6910683486340706d745fcdf3db1505fd71e689083 9 | ``` 10 | 11 | + unpack and install it somewhere (e.g. to your HOME directory, say, `~/jmeter`) 12 | 13 | ## usage 14 | 15 | + fill your credentials in `auth.json` 16 | + and run the infinite loop (CTRL+C to stop): 17 | 18 | ``` 19 | jmeter -n -t ./litter_load_plan.jmx 20 | ``` 21 | -------------------------------------------------------------------------------- /pkg/frontend/stats/event_handlers.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | ) 6 | 7 | func (c *Content) onClickUserFlow(ctx app.Context, e app.Event) { 8 | key := ctx.JSSrc().Get("id").String() 9 | //c.buttonDisabled = true 10 | 11 | ctx.Navigate("/flow/users/" + key) 12 | } 13 | 14 | func (c *Content) onDismissToast(ctx app.Context, e app.Event) { 15 | c.toast.TText = "" 16 | } 17 | 18 | func (c *Content) onSearch(ctx app.Context, e app.Event) { 19 | val := ctx.JSSrc().Get("value").String() 20 | 21 | //if c.searchString == "" { 22 | //if val == "" { 23 | // return 24 | //} 25 | 26 | if len(val) > 20 { 27 | return 28 | } 29 | 30 | ctx.NewActionWithValue("search", val) 31 | } 32 | -------------------------------------------------------------------------------- /web/littr.js: -------------------------------------------------------------------------------- 1 | // LIT library 2 | ;(function () { 3 | 'use strict' 4 | 5 | // LIT object 6 | window.LIT = {} 7 | window.LIT.event = null 8 | window.LIT.version = 'LittrJS v0.8.0' 9 | 10 | // onload event listener 11 | addEventListener('load', event => { 12 | console.log(LIT.version) 13 | }) 14 | })() 15 | 16 | // Add Umami analytics - https://umami.is 17 | const host = window.location.hostname; 18 | if (host != "localhost") { 19 | window.onload = () => { 20 | var x = document.createElement('script') 21 | x.setAttribute('src', 'https://umami.vxn.dev/script.js') 22 | x.setAttribute('data-website-id', '23275649-ae96-43b8-9362-93af740f6560') 23 | document.body.appendChild(x) 24 | } 25 | }; 26 | 27 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/molecules/counter.go: -------------------------------------------------------------------------------- 1 | package molecules 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | ) 6 | 7 | type Counter struct { 8 | app.Compo 9 | 10 | Count int64 11 | 12 | ID string 13 | Title string 14 | Icon string 15 | OnClickActionName string 16 | } 17 | 18 | func (c *Counter) onClick(ctx app.Context, e app.Event) { 19 | key := e.JSValue().Get("id") 20 | 21 | ctx.NewActionWithValue(c.OnClickActionName, key) 22 | } 23 | 24 | func (c *Counter) Render() app.UI { 25 | return app.Div().Body( 26 | app.B().Title(c.Title).Text(c.Count).Class("small-padding"), 27 | app.Span().Title(c.Title).Class("bold").OnClick(c.onClick).ID(c.ID).Body( 28 | app.I().Text(c.Icon), 29 | ), 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /cmd/dbench/README.md: -------------------------------------------------------------------------------- 1 | # dbench 2 | 3 | An experimental client package within the littr project repository. Its purpose is to implement new sync/concurrent configurations for the structures with shared access, and to properly evaluate the benchmarks. 4 | 5 | Another pkg's purpose is to experiment with the concurrency in general, to build multi-threaded applications and to learn new concepts of the Go language. 6 | 7 | ## how to run (local Go runtime) 8 | 9 | The simpliest (maybe ever) way to run this code is to chenge the directory (`PWD`/`CWD`) to `cmd/dbench/`, and to execute a simple `go run` procedure. 10 | 11 | ``` 12 | cd cmd/dbench 13 | go run ./... 14 | ``` 15 | 16 | ## race conditions detector 17 | 18 | ``` 19 | go run -race ./... 20 | ``` 21 | 22 | + https://go.dev/blog/race-detector 23 | 24 | -------------------------------------------------------------------------------- /pkg/backend/posts/router.go: -------------------------------------------------------------------------------- 1 | // Posts routes and controllers logic package for the backend. 2 | package posts 3 | 4 | import ( 5 | chi "github.com/go-chi/chi/v5" 6 | ) 7 | 8 | func NewPostRouter(postController *PostController) chi.Router { 9 | r := chi.NewRouter() 10 | 11 | r.Get("/", postController.GetAll) 12 | r.Post("/", postController.Create) 13 | 14 | // single-post view request 15 | r.Get("/{postID}", postController.GetByID) 16 | 17 | // user flow page request -> backend/users/controllers.go 18 | /*r.Route("/user", func(r chi.Router) { 19 | r.Get("/{nick}", getUserPosts) 20 | })*/ 21 | 22 | r.Patch("/{postID}/star", postController.UpdateReactions) 23 | r.Delete("/{postID}", postController.Delete) 24 | 25 | r.Get("/hashtags/{hashtag}", postController.GetByHashtag) 26 | 27 | return r 28 | } 29 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/atoms/search_bar.go: -------------------------------------------------------------------------------- 1 | package atoms 2 | 3 | import "github.com/maxence-charriere/go-app/v10/pkg/app" 4 | 5 | type SearchBar struct { 6 | app.Compo 7 | 8 | ID string 9 | 10 | OnSearchActionName string 11 | OnSearch app.EventHandler 12 | } 13 | 14 | func (s *SearchBar) onSearch(ctx app.Context, e app.Event) { 15 | if s.OnSearch != nil { 16 | s.OnSearch(ctx, e) 17 | return 18 | } 19 | 20 | ctx.NewActionWithValue(s.OnSearchActionName, s.ID) 21 | } 22 | 23 | func (s *SearchBar) Render() app.UI { 24 | return app.Div().Class("field prefix round fill thicc").Body( 25 | app.I().Class("front").Text("search"), 26 | //app.Input().Type("search").OnChange(c.ValueTo(&c.searchString)).OnSearch(c.onSearch), 27 | 28 | app.Input().ID(s.ID).Type("text").OnChange(s.onSearch).OnSearch(s.onSearch), 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/frontend/navbars/colors.go: -------------------------------------------------------------------------------- 1 | package navbars 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/maxence-charriere/go-app/v10/pkg/app" 7 | 8 | "go.vxn.dev/littr/pkg/models" 9 | ) 10 | 11 | func (h *Header) ensureUIColors() { 12 | body := app.Window().Get("document").Call("querySelector", "body") 13 | currentClass := body.Get("className").String() 14 | 15 | var newClass string 16 | 17 | // Check dark/light mode 18 | switch h.user.UIMode { 19 | case false: 20 | newClass = "dark" 21 | 22 | case true: 23 | newClass = "light" 24 | } 25 | 26 | // Check UI theme 27 | switch h.user.UITheme { 28 | case models.ThemeOrang: 29 | newClass += "-orang" 30 | 31 | default: 32 | newClass += "-blu" 33 | } 34 | 35 | if strings.Contains(currentClass, newClass) { 36 | return 37 | } 38 | 39 | body.Set("className", newClass) 40 | } 41 | -------------------------------------------------------------------------------- /cmd/await/README.md: -------------------------------------------------------------------------------- 1 | # await fetch() implementation using go-app's JS wrapper 2 | 3 | This is just an attempt on how to make the WASM client binary even more lighter with the gzip compression. 4 | 5 | The implementation also handles errors via the `catch(fn)` function with a callback. 6 | 7 | Main source of inspiration: https://github.com/maxence-charriere/go-app/issues/995#issuecomment-2394535140 8 | 9 | 10 | ## how to use 11 | 12 | In shell run these: 13 | 14 | ``` 15 | cd cmd/await 16 | mkdir -p web 17 | 18 | # to make it run, execute these 19 | GOOS=js GOARCH=wasm go build -o web/app.wasm main.go 20 | go run main.go 21 | 22 | # ...or just in the appropriate folder 23 | make run 24 | ``` 25 | 26 | Then: 27 | 28 | + open your web browser and navigate to [http://localhost:8081/](http://localhost:8081/) 29 | + press F12 to see the console log 30 | + press the button in the page to see the effect 31 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/atoms/image.go: -------------------------------------------------------------------------------- 1 | package atoms 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | ) 6 | 7 | type Image struct { 8 | app.Compo 9 | 10 | ID string 11 | Title string 12 | Class string 13 | Src string 14 | 15 | Attr map[string]string 16 | Styles map[string]string 17 | 18 | OnClick app.EventHandler 19 | OnClickActionName string 20 | } 21 | 22 | func (i *Image) onClick(ctx app.Context, e app.Event) { 23 | if i.OnClick != nil { 24 | i.OnClick(ctx, e) 25 | return 26 | } 27 | 28 | ctx.NewActionWithValue(i.OnClickActionName, i.ID) 29 | } 30 | 31 | func (i *Image) Render() app.UI { 32 | img := app.Img() 33 | 34 | for key, val := range i.Attr { 35 | img.Attr(key, val) 36 | } 37 | 38 | for key, val := range i.Styles { 39 | img.Style(key, val) 40 | } 41 | 42 | return img.ID(i.ID).Title(i.Title).Class(i.Class).Src(i.Src).OnClick(i.onClick) 43 | } 44 | -------------------------------------------------------------------------------- /pkg/helpers/helpers.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "math/rand" 5 | ) 6 | 7 | // contains checks if a string is present in a slice. 8 | // https://freshman.tech/snippets/go/check-if-slice-contains-element/ 9 | func Contains(s []string, str string) bool { 10 | for _, v := range s { 11 | if v == str { 12 | return true 13 | } 14 | } 15 | return false 16 | } 17 | 18 | // https://stackoverflow.com/a/34816623 19 | func Reverse(ss []string) { 20 | last := len(ss) - 1 21 | for i := 0; i < len(ss)/2; i++ { 22 | ss[i], ss[last-i] = ss[last-i], ss[i] 23 | } 24 | } 25 | 26 | var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") 27 | 28 | // https://stackoverflow.com/a/31832326 29 | // https://stackoverflow.com/a/22892986 30 | func RandSeq(n int) string { 31 | b := make([]rune, n) 32 | for i := range b { 33 | b[i] = letters[rand.Intn(len(letters))] 34 | } 35 | return string(b) 36 | } 37 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/molecules/poll_header.go: -------------------------------------------------------------------------------- 1 | package molecules 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | "go.vxn.dev/littr/pkg/frontend/atomic/atoms" 6 | "go.vxn.dev/littr/pkg/models" 7 | ) 8 | 9 | type PollHeader struct { 10 | app.Compo 11 | 12 | Poll models.Poll 13 | 14 | ButtonsDisabled bool 15 | 16 | OnClickLinkActionName string 17 | } 18 | 19 | func (p *PollHeader) Render() app.UI { 20 | return app.Div().Class("row top-padding bottom-padding").Body( 21 | 22 | app.P().Class("max").Body( 23 | app.Span().Text(p.Poll.Question).Class("primary-text space bold"), 24 | ), 25 | 26 | &atoms.Button{ 27 | ID: p.Poll.ID, 28 | Title: "link to this poll", 29 | Class: "transparent circle", 30 | Icon: "link", 31 | OnClickActionName: p.OnClickLinkActionName, 32 | Disabled: p.ButtonsDisabled, 33 | }, 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /pkg/frontend/common/upload.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/maxence-charriere/go-app/v10/pkg/app" 7 | ) 8 | 9 | func ReadFile(file app.Value) (data []byte, err error) { 10 | done := make(chan bool) 11 | 12 | // https://developer.mozilla.org/en-US/docs/Web/API/FileReader 13 | reader := app.Window().Get("FileReader").New() 14 | reader.Set("onloadend", app.FuncOf(func(this app.Value, args []app.Value) interface{} { 15 | done <- true 16 | return nil 17 | })) 18 | reader.Call("readAsArrayBuffer", file) 19 | <-done 20 | 21 | readerError := reader.Get("error") 22 | if !readerError.IsNull() { 23 | err = fmt.Errorf("file reader error : %s", readerError.Get("message").String()) 24 | } else { 25 | uint8Array := app.Window().Get("Uint8Array").New(reader.Get("result")) 26 | data = make([]byte, uint8Array.Length()) 27 | app.CopyBytesToGo(data, uint8Array) 28 | } 29 | return data, err 30 | } 31 | -------------------------------------------------------------------------------- /pkg/backend/common/response.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "log" 7 | "net/http" 8 | //"go.vxn.dev/littr/pkg/models" 9 | ) 10 | 11 | // new generic API response schema idea 12 | type APIResponse struct { 13 | // Common fields for all responses 14 | Message string `json:"message" example:"a generic success info, or a processing problem/error description"` 15 | Timestamp int64 `json:"timestamp" example:"1734778064068087800"` 16 | 17 | // Data field for any payload 18 | Data interface{} `json:"data"` 19 | } 20 | 21 | func WriteResponse(w http.ResponseWriter, resp interface{}, code int) error { 22 | jsonData, err := json.Marshal(resp) 23 | if err != nil { 24 | log.Println(err.Error()) 25 | return err 26 | } 27 | 28 | w.Header().Add("Content-Type", "application/json") 29 | w.WriteHeader(code) 30 | 31 | if _, err := io.Writer.Write(w, jsonData); err != nil { 32 | return err 33 | } 34 | 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /pkg/backend/common/mock_service.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "context" 5 | 6 | "go.vxn.dev/littr/pkg/models" 7 | 8 | gomail "github.com/wneessen/go-mail" 9 | ) 10 | 11 | type MockMailService struct{} 12 | 13 | func (m *MockMailService) ComposeMail(payload interface{}) (*gomail.Msg, error) { 14 | return &gomail.Msg{}, nil 15 | } 16 | 17 | func (m *MockMailService) SendMail(msg *gomail.Msg) error { 18 | return nil 19 | } 20 | 21 | // Implementation verification for compiler. 22 | var _ models.MailServiceInterface = (*MockMailService)(nil) 23 | 24 | type MockPagingService struct{} 25 | 26 | func (m *MockPagingService) GetOne(ctx context.Context, opts interface{}, data ...interface{}) (interface{}, error) { 27 | return nil, nil 28 | } 29 | 30 | func (m *MockPagingService) GetMany(ctx context.Context, opts any) (any, error) { 31 | return nil, nil 32 | } 33 | 34 | // Implementation verification for compiler. 35 | var _ models.PagingServiceInterface = (*MockPagingService)(nil) 36 | -------------------------------------------------------------------------------- /pkg/backend/pprof/router.go: -------------------------------------------------------------------------------- 1 | // Runtime profiling metapackage. 2 | package pprof 3 | 4 | import ( 5 | prf "net/http/pprof" 6 | 7 | "github.com/go-chi/chi/v5" 8 | "github.com/go-chi/chi/v5/middleware" 9 | ) 10 | 11 | // Sources: 12 | // 13 | // https://pkg.go.dev/net/http/pprof 14 | // https://github.com/go-chi/chi/blob/master/middleware/profiler.go 15 | 16 | // The pprof profiler common router. 17 | func NewRouter() chi.Router { 18 | r := chi.NewRouter() 19 | 20 | // Do not cache the profiles. 21 | r.Use(middleware.NoCache) 22 | 23 | r.Get("/", prf.Index) 24 | r.Get("/cmdline", prf.Cmdline) 25 | r.Get("/profile", prf.Profile) 26 | r.Get("/trace", prf.Trace) 27 | 28 | r.Mount("/allocs", prf.Handler("allocs")) 29 | r.Mount("/block", prf.Handler("block")) 30 | r.Mount("/goroutine", prf.Handler("goroutine")) 31 | r.Mount("/heap", prf.Handler("heap")) 32 | r.Mount("/mutex", prf.Handler("mutex")) 33 | r.Mount("/threadcreate", prf.Handler("threadcreate")) 34 | 35 | return r 36 | } 37 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/atoms/page_heading.go: -------------------------------------------------------------------------------- 1 | package atoms 2 | 3 | import "github.com/maxence-charriere/go-app/v10/pkg/app" 4 | 5 | type PageHeading struct { 6 | app.Compo 7 | 8 | Level int 9 | Title string 10 | Class string 11 | } 12 | 13 | func (p *PageHeading) composeClass() string { 14 | if p.Class != "" { 15 | return p.Class 16 | } 17 | 18 | return "max padding" 19 | } 20 | 21 | func (p *PageHeading) composeHeading() app.UI { 22 | switch p.Level { 23 | case 1: 24 | return app.H1().Text(p.Title) 25 | case 2: 26 | return app.H2().Text(p.Title) 27 | case 3: 28 | return app.H3().Text(p.Title) 29 | case 4: 30 | return app.H4().Text(p.Title) 31 | case 6: 32 | return app.H6().Text(p.Title) 33 | default: 34 | return app.H5().Text(p.Title) 35 | } 36 | } 37 | 38 | func (p *PageHeading) Render() app.UI { 39 | return app.Div().Class("row").Body( 40 | app.Div().Class(p.composeClass()).Body( 41 | p.composeHeading(), 42 | ), 43 | app.Div().Class("space"), 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/frontend/stats/action_handlers.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/maxence-charriere/go-app/v10/pkg/app" 7 | ) 8 | 9 | func (c *Content) handleSearch(ctx app.Context, a app.Action) { 10 | matchedList := []string{} 11 | 12 | val, ok := a.Value.(string) 13 | if !ok { 14 | return 15 | } 16 | 17 | ctx.Async(func() { 18 | users := c.userStats 19 | 20 | // iterate over calculated stats' "rows" and find matchings 21 | for key, user := range users { 22 | //user := users[key] 23 | user.Searched = false 24 | 25 | // use lowecase to search across UNICODE letters 26 | lval := strings.ToLower(val) 27 | lkey := strings.ToLower(key) 28 | 29 | if strings.Contains(lkey, lval) { 30 | user.Searched = true 31 | 32 | //matchedList = append(matchedList, key) 33 | } 34 | 35 | users[key] = user 36 | } 37 | 38 | ctx.Dispatch(func(ctx app.Context) { 39 | c.userStats = users 40 | c.nicknames = matchedList 41 | 42 | c.loaderShow = false 43 | }) 44 | }) 45 | 46 | } 47 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/molecules/switch.go: -------------------------------------------------------------------------------- 1 | package molecules 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | "go.vxn.dev/littr/pkg/frontend/atomic/atoms" 6 | ) 7 | 8 | type Switch struct { 9 | app.Compo 10 | 11 | Icon string 12 | ID string 13 | Text string 14 | 15 | Checked bool 16 | Disabled bool 17 | 18 | OnChangeActionName string 19 | } 20 | 21 | func (s *Switch) Render() app.UI { 22 | return app.Div().Class("field middle-align").Body( 23 | app.Div().Class("row").Body( 24 | app.Div().Class("max").Body( 25 | app.Span().Text(s.Text), 26 | ), 27 | app.Label().Class("switch icon").Body( 28 | &atoms.Input{ 29 | ID: s.ID, 30 | Type: "checkbox", 31 | Checked: s.Checked, 32 | Disabled: s.Disabled, 33 | OnChangeType: atoms.InputOnChangeEventHandler, 34 | OnChangeActionName: s.OnChangeActionName, 35 | }, 36 | app.Span().Body( 37 | app.I().Text(s.Icon), 38 | ), 39 | ), 40 | ), 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/atoms/textarea.go: -------------------------------------------------------------------------------- 1 | package atoms 2 | 3 | import "github.com/maxence-charriere/go-app/v10/pkg/app" 4 | 5 | type Textarea struct { 6 | app.Compo 7 | 8 | ID string 9 | Class string 10 | Content string 11 | Name string 12 | LabelText string 13 | 14 | ContentPointer *string 15 | 16 | OnBlurActionName string 17 | } 18 | 19 | func (t *Textarea) onBlur(ctx app.Context, e app.Event) { 20 | ctx.NewActionWithValue(t.OnBlurActionName, e.Get("id").String()) 21 | } 22 | 23 | func (t *Textarea) OnMount(ctx app.Context) { 24 | if t.ContentPointer == nil { 25 | t.ContentPointer = new(string) 26 | } 27 | 28 | if t.Content == "" { 29 | t.Content = *t.ContentPointer 30 | } 31 | } 32 | 33 | func (t *Textarea) Render() app.UI { 34 | return app.Div().Class(t.Class).Style("border-radius", "8px").Body( 35 | app.Textarea().Class("active").Name(t.Name).Text(t.Content).OnChange(t.ValueTo(t.ContentPointer)).AutoFocus(true).ID(t.ID).OnBlur(t.onBlur), 36 | app.Label().Text(t.LabelText).Class("active primary-text"), 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/backend/stats/router_test.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "net/http" 7 | "testing" 8 | 9 | "go.vxn.dev/littr/pkg/backend/common" 10 | "go.vxn.dev/littr/pkg/config" 11 | 12 | chi "github.com/go-chi/chi/v5" 13 | ) 14 | 15 | var getStatsMock = func(w http.ResponseWriter, r *http.Request) { 16 | l := common.NewLogger(r, "stats") 17 | 18 | pl := struct{}{} 19 | 20 | l.Msg("ok, returning the application and users stats").Status(http.StatusOK).Log().Payload(pl).Write(w) 21 | } 22 | 23 | func TestStatsRouter(t *testing.T) { 24 | r := chi.NewRouter() 25 | 26 | // For the Streamer configuration check pkg/backend/live/streamer.go 27 | r.Get("/api/v1/stats", getStatsMock) 28 | 29 | // Fetch test net listener and test HTTP server configuration. 30 | listener := config.PrepareTestListener(t) 31 | defer func() { 32 | if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { 33 | t.Error(err) 34 | } 35 | }() 36 | 37 | ts := config.PrepareTestServer(t, listener, r) 38 | ts.Start() 39 | defer ts.Close() 40 | } 41 | -------------------------------------------------------------------------------- /pkg/frontend/reset/reset.go: -------------------------------------------------------------------------------- 1 | package reset 2 | 3 | import ( 4 | "fmt" 5 | 6 | "go.vxn.dev/littr/pkg/frontend/common" 7 | ) 8 | 9 | func (c *Content) handleResetRequest(email, uuid string) error { 10 | if email == "" && uuid == "" { 11 | return fmt.Errorf(common.ERR_RESET_INVALID_INPUT_DATA) 12 | } 13 | 14 | path := "request" 15 | 16 | if uuid != "" { 17 | path = "reset" 18 | } 19 | 20 | payload := struct { 21 | Email string `json:"email"` 22 | UUID string `json:"uuid"` 23 | }{ 24 | Email: email, 25 | UUID: uuid, 26 | } 27 | 28 | input := &common.CallInput{ 29 | Method: "POST", 30 | Url: "/api/v1/users/passphrase/" + path, 31 | Data: payload, 32 | CallerID: "", 33 | PageNo: 0, 34 | HideReplies: false, 35 | } 36 | 37 | output := &common.Response{} 38 | 39 | if ok := common.FetchData(input, output); !ok { 40 | return fmt.Errorf(common.ERR_CANNOT_REACH_BE) 41 | } 42 | 43 | if output.Code != 200 && output.Code != 201 { 44 | return fmt.Errorf("%s", output.Message) 45 | } 46 | 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/atoms/poll_result.go: -------------------------------------------------------------------------------- 1 | package atoms 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/maxence-charriere/go-app/v10/pkg/app" 8 | 9 | "go.vxn.dev/littr/pkg/models" 10 | ) 11 | 12 | type PollResult struct { 13 | app.Compo 14 | 15 | OptionShare int64 16 | Option models.PollOption 17 | 18 | OptlLevel int 19 | } 20 | 21 | func (p *PollResult) composeClass() string { 22 | return fmt.Sprintf("bold progress left poll-opt%d small-padding thicc", p.OptlLevel) 23 | } 24 | 25 | func (p *PollResult) Render() app.UI { 26 | return app.Div().Class("thicc").Body( 27 | app.Div().Class(p.composeClass()).Style("clip-path", "polygon(0% 0%, 0% 98%, "+strconv.FormatInt(p.OptionShare, 10)+"% 98%, "+strconv.FormatInt(p.OptionShare, 10)+"% 0%);"), 28 | 29 | //app.Progress().Value(strconv.Itoa(optionOneShare)).Max(100).Class("deep-orange-text padding medium bold left"), 30 | //app.Div().Class("progress left light-green"), 31 | 32 | app.Div().Class("bold").Body( 33 | app.Span().Text(p.Option.Content+" ("+strconv.FormatInt(p.OptionShare, 10)+"%)"), 34 | ), 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/molecules/littr_header.go: -------------------------------------------------------------------------------- 1 | package molecules 2 | 3 | import "github.com/maxence-charriere/go-app/v10/pkg/app" 4 | 5 | type LittrHeader struct { 6 | app.Compo 7 | 8 | HeaderString string 9 | 10 | OnClickHeadlineActionName string 11 | } 12 | 13 | func (l *LittrHeader) onClick(ctx app.Context, e app.Event) { 14 | id := e.JSValue().Get("id").String() 15 | 16 | ctx.NewActionWithValue(l.OnClickHeadlineActionName, id) 17 | } 18 | 19 | func (l *LittrHeader) Render() app.UI { 20 | // littr header 21 | return app.H4().Title("system info (click to open)").Class("center-align primary-text").OnClick(l.onClick).ID("top-header").Body( 22 | app.Span().Body( 23 | app.Text(l.HeaderString), 24 | 25 | app.If(app.Getenv("APP_ENVIRONMENT") != "prod", func() app.UI { 26 | return app.Span().Class("col").Body( 27 | app.Sup().Body( 28 | app.If(app.Getenv("APP_ENVIRONMENT") == "stage", func() app.UI { 29 | return app.Text(" (stage) ") 30 | }).Else(func() app.UI { 31 | return app.Text(" (dev) ") 32 | }), 33 | ), 34 | ) 35 | }), 36 | ), 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/models/device.go: -------------------------------------------------------------------------------- 1 | // The very models-related package containing all the in-database-saved types and structures. 2 | package models 3 | 4 | import ( 5 | "time" 6 | ) 7 | 8 | type Devices []Device 9 | 10 | // SubscriptionDevice 11 | type Device struct { 12 | // Unique identification of the app on the current device. 13 | // https://go-app.dev/reference#Context 14 | UUID string `json:"uuid"` 15 | 16 | // Timestamp of the subscription creation. 17 | TimeCreated time.Time `json:"time_created"` 18 | 19 | // Timestamp of the last notification sent through this device. 20 | TimeLastUsed time.Time `json:"time_last_used"` 21 | 22 | // List of labels for such device. 23 | Tags []string `json:"tags,omitempty"` 24 | 25 | // The very subscription struct/details. 26 | Subscription Subscription `json:"subscription"` 27 | } 28 | 29 | type Subscription struct { 30 | Endpoint string `json:"endpoint"` 31 | Keys Keys `json:"keys"` 32 | } 33 | 34 | type Keys struct { 35 | Auth string `json:"auth"` 36 | P256dh string `json:"p256dh"` 37 | } 38 | 39 | func (dd Devices) GetID() string { 40 | return "" 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 krusty 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/organisms/modal_subscription_delete.go: -------------------------------------------------------------------------------- 1 | package organisms 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | 6 | "go.vxn.dev/littr/pkg/frontend/atomic/molecules" 7 | ) 8 | 9 | type ModalSubscriptionDelete struct { 10 | app.Compo 11 | 12 | ModalShow bool 13 | ModalButtonsDisabled bool 14 | 15 | OnClickDismissActionName string 16 | OnClickDeleteActionName string 17 | } 18 | 19 | func (m *ModalSubscriptionDelete) Render() app.UI { 20 | return app.Div().Body( 21 | app.If(m.ModalShow, func() app.UI { 22 | return &molecules.DeleteDialog{ 23 | ID: "delete-modal", 24 | Title: "subscription deletion", 25 | // 26 | TextBoxClass: "row amber-border white-text border warn thicc", 27 | TextBoxIcon: "warning", 28 | TextBoxIconClass: "amber-text", 29 | TextBoxText: "Are you sure you want to delete this subscription?", 30 | // 31 | ModalButtonsDisabled: m.ModalButtonsDisabled, 32 | OnClickDismissActionName: m.OnClickDismissActionName, 33 | OnClickDeleteActionName: m.OnClickDeleteActionName, 34 | } 35 | }), 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/organisms/modal_post_delete.go: -------------------------------------------------------------------------------- 1 | package organisms 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | 6 | "go.vxn.dev/littr/pkg/frontend/atomic/molecules" 7 | ) 8 | 9 | type ModalPostDelete struct { 10 | app.Compo 11 | 12 | PostID string 13 | 14 | ModalButtonsDisabled bool 15 | ModalShow bool 16 | 17 | OnClickDismissActionName string 18 | OnClickDeleteActionName string 19 | } 20 | 21 | func (m *ModalPostDelete) Render() app.UI { 22 | return app.Div().Body( 23 | app.If(m.ModalShow, func() app.UI { 24 | return &molecules.DeleteDialog{ 25 | ID: "delete-modal", 26 | Title: "post deletion", 27 | DeleteButtonID: m.PostID, 28 | // 29 | TextBoxClass: "row amber-border white-text border warn thicc", 30 | TextBoxIcon: "warning", 31 | TextBoxIconClass: "amber-text", 32 | TextBoxText: "Are you sure you want to delete your post?", 33 | // 34 | ModalButtonsDisabled: m.ModalButtonsDisabled, 35 | OnClickDismissActionName: m.OnClickDismissActionName, 36 | OnClickDeleteActionName: m.OnClickDeleteActionName, 37 | } 38 | }), 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /pkg/backend/polls/types.go: -------------------------------------------------------------------------------- 1 | package polls 2 | 3 | type PollCreateRequest struct { 4 | // Question is to describe the main purpose of such poll. 5 | Question string `json:"question" example:"which one is your favourite?"` 6 | 7 | // OptionOne is the answer numero uno. 8 | OptionOne string `json:"option_one" example:"apple"` 9 | 10 | // OptionTwo is the answer numero dos. 11 | OptionTwo string `json:"option_two" example:"banana"` 12 | 13 | // OptionThree is the answer numero tres. 14 | OptionThree string `json:"option_three" example:"cashew"` 15 | } 16 | 17 | type PollPagingRequest struct { 18 | PageNo int 19 | PagingSize int 20 | } 21 | 22 | type PollUpdateRequest struct { 23 | // The poll's ID is specified using an URL param. 24 | // The purpose of this field is to transfer the ID from the controller to the service in a more smooth way. 25 | ID string `json:"-" example:"1234567890000" swaggerignore:"true"` 26 | 27 | // These fields are to be filled in the request body data. 28 | OptionOneCount int64 `json:"option_one_count" example:"3"` 29 | OptionTwoCount int64 `json:"option_two_count" example:"2"` 30 | OptionThreeCount int64 `json:"option_three_count" example:"6"` 31 | } 32 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/organisms/modal_poll_delete.go: -------------------------------------------------------------------------------- 1 | package organisms 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | 6 | "go.vxn.dev/littr/pkg/frontend/atomic/molecules" 7 | ) 8 | 9 | type ModalPollDelete struct { 10 | app.Compo 11 | 12 | PollID string 13 | 14 | ModalButtonsDisabled bool 15 | ModalShow bool 16 | 17 | OnClickDismissActionName string 18 | OnClickDeleteActionName string 19 | } 20 | 21 | func (m *ModalPollDelete) Render() app.UI { 22 | return app.Div().Body( 23 | // poll deletion modal 24 | app.If(m.ModalShow, func() app.UI { 25 | return &molecules.DeleteDialog{ 26 | ID: "delete-modal", 27 | Title: "poll deletion", 28 | DeleteButtonID: m.PollID, 29 | // 30 | TextBoxClass: "row amber-border white-text border warn thicc", 31 | TextBoxIcon: "warning", 32 | TextBoxIconClass: "amber-text", 33 | TextBoxText: "Are you sure you want to delete your poll?", 34 | // 35 | ModalButtonsDisabled: m.ModalButtonsDisabled, 36 | OnClickDismissActionName: m.OnClickDismissActionName, 37 | OnClickDeleteActionName: m.OnClickDeleteActionName, 38 | } 39 | }), 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /pkg/backend/common/flush_data.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "go.vxn.dev/littr/pkg/models" 5 | ) 6 | 7 | // Helper function to flush sensitive user data in the export for response. 8 | func FlushUserData(users *map[string]models.User, callerID string) *map[string]models.User { 9 | if users == nil || callerID == "" { 10 | return nil 11 | } 12 | 13 | // Flush unwanted properties. 14 | for key, user := range *users { 15 | user.Passphrase = "" 16 | user.PassphraseHex = "" 17 | 18 | // These are kept for callerID. 19 | if user.Nickname != callerID { 20 | user.Email = "" 21 | user.FlowList = nil 22 | user.ShadeList = nil 23 | 24 | // Flush user's options, keep the private state only. 25 | options := map[string]bool{} 26 | options["private"] = user.Options["private"] 27 | user.Options = options 28 | 29 | // Return the caller's status in counterpart account's req. list only callerID's state if present. 30 | if value, found := user.RequestList[callerID]; found { 31 | user.RequestList = make(map[string]bool) 32 | user.RequestList[callerID] = value 33 | } else { 34 | user.RequestList = nil 35 | } 36 | } 37 | 38 | (*users)[key] = user 39 | } 40 | return users 41 | } 42 | -------------------------------------------------------------------------------- /pkg/backend/mail/send.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "strconv" 7 | 8 | gomail "github.com/wneessen/go-mail" 9 | ) 10 | 11 | var ( 12 | ErrIncompleteMailServerConfiguration = errors.New("mail server is not configured properly, check the settings") 13 | ) 14 | 15 | var ( 16 | mailHelo = os.Getenv("MAIL_HELO") 17 | mailHost = os.Getenv("MAIL_HOST") 18 | mailPort = os.Getenv("MAIL_PORT") 19 | mailSaslUser = os.Getenv("MAIL_SASL_USR") 20 | mailSaslPass = os.Getenv("MAIL_SASL_PWD") 21 | ) 22 | 23 | func (s *mailService) SendMail(msg *gomail.Msg) error { 24 | port, err := strconv.Atoi(mailPort) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | if mailHost == "" || mailSaslUser == "" || mailSaslPass == "" || mailHelo == "" { 30 | return ErrIncompleteMailServerConfiguration 31 | } 32 | 33 | c, err := gomail.NewClient(mailHost, gomail.WithPort(port), gomail.WithSMTPAuth(gomail.SMTPAuthPlain), 34 | gomail.WithUsername(mailSaslUser), gomail.WithPassword(mailSaslPass), gomail.WithHELO(mailHelo)) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | //c.SetTLSPolicy(mail.TLSOpportunistic) 40 | 41 | if err := c.DialAndSend(msg); err != nil { 42 | return err 43 | } 44 | 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/organisms/modal_user_delete.go: -------------------------------------------------------------------------------- 1 | package organisms 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | 6 | "go.vxn.dev/littr/pkg/frontend/atomic/molecules" 7 | ) 8 | 9 | type ModalUserDelete struct { 10 | app.Compo 11 | 12 | LoggedUserNickname string 13 | 14 | ModalShow bool 15 | ModalButtonsDisabled bool 16 | 17 | OnClickDismissActionName string 18 | OnClickDeleteAccountActionName string 19 | } 20 | 21 | func (m *ModalUserDelete) Render() app.UI { 22 | // Account deletion modal. 23 | return app.Div().Body( 24 | app.If(m.ModalShow, func() app.UI { 25 | return &molecules.DeleteDialog{ 26 | ID: "delete-modal", 27 | Title: "account deletion", 28 | DeleteButtonID: m.LoggedUserNickname, 29 | // 30 | TextBoxClass: "row amber-border white-text border danger thicc", 31 | TextBoxIcon: "warning", 32 | TextBoxIconClass: "red-text", 33 | TextBoxText: "Are you sure you want to delete your account and all posted items?", 34 | // 35 | ModalButtonsDisabled: m.ModalButtonsDisabled, 36 | OnClickDismissActionName: m.OnClickDismissActionName, 37 | OnClickDeleteActionName: m.OnClickDeleteAccountActionName, 38 | } 39 | }), 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /pkg/models/repository.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // 4 | // Repository interfaces 5 | // 6 | 7 | type PollRepositoryInterface interface { 8 | GetAll() (*map[string]Poll, error) 9 | GetByID(pollID string) (*Poll, error) 10 | Save(poll *Poll) error 11 | Delete(pollID string) error 12 | } 13 | 14 | type PostRepositoryInterface interface { 15 | GetAll() (*map[string]Post, error) 16 | GetByID(postID string) (*Post, error) 17 | Save(post *Post) error 18 | Delete(postID string) error 19 | } 20 | 21 | type RequestRepositoryInterface interface { 22 | GetByID(reqID string) (*Request, error) 23 | Save(req *Request) error 24 | Delete(reqID string) error 25 | } 26 | 27 | type SubscriptionRepositoryInterface interface { 28 | //GetAll() *map[string][]Device 29 | GetByUserID(userID string) (*[]Device, error) 30 | Save(userID string, sub *[]Device) error 31 | Delete(userID string) error 32 | } 33 | 34 | type TokenRepositoryInterface interface { 35 | GetAll() (*map[string]Token, error) 36 | GetByID(tokenID string) (*Token, error) 37 | Save(token *Token) error 38 | Delete(tokenID string) error 39 | } 40 | 41 | type UserRepositoryInterface interface { 42 | GetAll() (*map[string]User, error) 43 | GetByID(userID string) (*User, error) 44 | Save(user *User) error 45 | Delete(userID string) error 46 | } 47 | -------------------------------------------------------------------------------- /pkg/frontend/reset/content.go: -------------------------------------------------------------------------------- 1 | // The reset view and view-controllers logic package. 2 | package reset 3 | 4 | import ( 5 | "strings" 6 | 7 | "go.vxn.dev/littr/pkg/frontend/common" 8 | 9 | "github.com/maxence-charriere/go-app/v10/pkg/app" 10 | ) 11 | 12 | type Content struct { 13 | app.Compo 14 | 15 | email string 16 | uuid string 17 | 18 | showUUIDPage bool 19 | 20 | toast common.Toast 21 | 22 | buttonsDisabled bool 23 | } 24 | 25 | func (c *Content) OnMount(ctx app.Context) { 26 | ctx.Handle("dismiss", c.handleDismiss) 27 | //c.keyDownEventListener = app.Window().AddEventListener("keydown", c.onKeyDown) 28 | 29 | url := strings.Split(ctx.Page().URL().Path, "/") 30 | 31 | toast := common.Toast{AppContext: &ctx} 32 | 33 | // autosend the UUID to the backend if present in URL 34 | if len(url) > 2 && url[2] != "" { 35 | uuid := url[2] 36 | c.showUUIDPage = true 37 | 38 | if err := c.handleResetRequest("", uuid); err != nil { 39 | toast.Text(err.Error()).Type(common.TTYPE_ERR).Dispatch() 40 | 41 | ctx.Dispatch(func(ctx app.Context) { 42 | c.buttonsDisabled = false 43 | }) 44 | return 45 | } 46 | 47 | toast.Text(common.MSG_RESET_PASSPHRASE_SUCCESS).Type(common.TTYPE_SUCCESS).Dispatch() 48 | 49 | ctx.Dispatch(func(ctx app.Context) { 50 | c.buttonsDisabled = false 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/atoms/input.go: -------------------------------------------------------------------------------- 1 | package atoms 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | ) 6 | 7 | type onChangeType byte 8 | 9 | const ( 10 | InputOnChangeEventHandler onChangeType = iota 11 | InputOnChangeValueTo 12 | ) 13 | 14 | type Input struct { 15 | app.Compo 16 | 17 | ID string 18 | Type string 19 | Class string 20 | 21 | Content string 22 | Value *string 23 | 24 | MaxLength int 25 | 26 | AutoComplete bool 27 | Checked bool 28 | Disabled bool 29 | 30 | Attr map[string]string 31 | 32 | OnChangeType onChangeType 33 | OnChangeActionName string 34 | } 35 | 36 | func (i *Input) onChange(ctx app.Context, e app.Event) { 37 | if i.ID == "" || i.OnChangeActionName == "" { 38 | return 39 | } 40 | 41 | ctx.NewActionWithValue(i.OnChangeActionName, i.ID) 42 | } 43 | 44 | func (i *Input) Render() app.UI { 45 | ipt := app.Input() 46 | 47 | for key, val := range i.Attr { 48 | ipt.Attr(key, val) 49 | } 50 | 51 | // OnChange() 52 | // OnChange(compo.ValueTo(compo.Content)) 53 | switch i.OnChangeType { 54 | case InputOnChangeEventHandler: 55 | ipt.OnChange(i.onChange) 56 | case InputOnChangeValueTo: 57 | ipt.OnChange(i.ValueTo(i.Value)) 58 | } 59 | 60 | return ipt.Class(i.Class).Type(i.Type).ID(i.ID).Checked(i.Checked).Disabled(i.Disabled).AutoComplete(i.AutoComplete).MaxLength(i.MaxLength).Value(i.Content) 61 | } 62 | -------------------------------------------------------------------------------- /pkg/backend/users/service_test.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "go.vxn.dev/littr/pkg/backend/common" 8 | "go.vxn.dev/littr/pkg/models" 9 | ) 10 | 11 | type mockNickname string 12 | 13 | // 14 | // Tests 15 | // 16 | 17 | func newTestContext() context.Context { 18 | return context.WithValue(context.Background(), mockNickname("nickname"), "lawrents") 19 | } 20 | 21 | func newTestService(t *testing.T) models.UserServiceInterface { 22 | service := NewUserService(&common.MockMailService{}, &common.MockPagingService{}, &common.MockPollRepository{}, &common.MockPostRepository{}, &common.MockRequestRepository{}, &common.MockTokenRepository{}, &common.MockUserRepository{}) 23 | if service == nil { 24 | t.Fatal("nil UserService") 25 | } 26 | 27 | return service 28 | } 29 | 30 | func TestUsers_UserServiceCreate(t *testing.T) { 31 | ctx := newTestContext() 32 | service := newTestService(t) 33 | 34 | req := &UserCreateRequest{ 35 | Email: "alice@example.com", 36 | Nickname: "alice", 37 | PassphrasePlain: "bobdod", 38 | } 39 | 40 | if err := service.Create(ctx, req); err != nil { 41 | t.Error(err) 42 | } 43 | } 44 | 45 | func TestUsers_UserServiceActivate(t *testing.T) { 46 | ctx := newTestContext() 47 | service := newTestService(t) 48 | 49 | if err := service.Activate(ctx, common.MockUserNickname); err != nil { 50 | t.Error(err) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /pkg/backend/mail/template.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "text/template" 7 | ) 8 | 9 | type TemplatePayload struct { 10 | // All templates. 11 | MainURL string 12 | Nickname string 13 | TemplateSrc string 14 | 15 | // Reset template. 16 | Email string 17 | ResetLink string 18 | UUID string 19 | 20 | // Activation template. 21 | ActivationLink string 22 | 23 | // Passphrase template. 24 | Passphrase string 25 | } 26 | 27 | var fileAsString = func(templateName string) string { 28 | // Parse the custom Service Worker template string for the app handler. 29 | //tpl, err := os.ReadFile("pkg/backend/mail/templates/activation.tmpl") 30 | tpl, err := os.ReadFile(templateName) 31 | if err != nil { 32 | return "" 33 | } 34 | 35 | return string(tpl) 36 | } 37 | 38 | // bakeTemplate parses the given template with the associated payload and writes the output to the pointer address. 39 | func bakeTemplate(payload *TemplatePayload, output *string) error { 40 | // Ensure new template loaded. 41 | tmpl := template.Must(template.New(payload.TemplateSrc).Parse(fileAsString(payload.TemplateSrc))) 42 | 43 | var buf bytes.Buffer 44 | 45 | // Bake the template into buffer. 46 | err := tmpl.Execute(&buf, *payload) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | // Save the output string to the output's address. 52 | *output = buf.String() 53 | 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /pkg/frontend/flow/uri.go: -------------------------------------------------------------------------------- 1 | package flow 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | 7 | "github.com/maxence-charriere/go-app/v10/pkg/app" 8 | ) 9 | 10 | type URIParts struct { 11 | SinglePost bool 12 | SinglePostID string 13 | UserFlow bool 14 | UserFlowNick string 15 | Hashtag string 16 | } 17 | 18 | func (c *Content) parseFlowURI(ctx app.Context) URIParts { 19 | parts := URIParts{ 20 | SinglePost: false, 21 | SinglePostID: "", 22 | UserFlow: false, 23 | UserFlowNick: "", 24 | Hashtag: "", 25 | } 26 | 27 | // Split the URI by '/'. 28 | url := strings.Split(ctx.Page().URL().Path, "/") 29 | 30 | // Into at least 4 fields. ( '' / 'flow' / 'posts' / '{ID}' ) 31 | if len(url) > 3 && url[3] != "" { 32 | switch url[2] { 33 | case "posts": 34 | parts.SinglePost = true 35 | parts.SinglePostID = url[3] 36 | 37 | case "users": 38 | parts.UserFlow = true 39 | parts.UserFlowNick = url[3] 40 | 41 | case "hashtags": 42 | parts.Hashtag = url[3] 43 | } 44 | } 45 | 46 | isPost := true 47 | if _, err := strconv.Atoi(parts.SinglePostID); parts.SinglePostID != "" && err != nil { 48 | // prolly not a post ID, but an user's nickname 49 | isPost = false 50 | } 51 | 52 | ctx.Dispatch(func(ctx app.Context) { 53 | c.isPost = isPost 54 | c.userFlowNick = parts.UserFlowNick 55 | c.singlePostID = parts.SinglePostID 56 | c.hashtag = parts.Hashtag 57 | }) 58 | 59 | return parts 60 | } 61 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/molecules/textbox.go: -------------------------------------------------------------------------------- 1 | package molecules 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/maxence-charriere/go-app/v10/pkg/app" 7 | 8 | "go.vxn.dev/littr/pkg/frontend/atomic/atoms" 9 | ) 10 | 11 | type TextBox struct { 12 | app.Compo 13 | 14 | Class string 15 | Icon string 16 | IconClass string 17 | Text string 18 | 19 | MarkupText string 20 | 21 | FormatArgs []interface{} 22 | 23 | MakeSummary bool 24 | ShowLoader bool 25 | 26 | Button app.UI 27 | } 28 | 29 | func (t *TextBox) composeContentComponent() app.UI { 30 | if t.ShowLoader { 31 | return app.Progress().Class("circle blue-border active") 32 | } 33 | 34 | if len(t.FormatArgs) > 0 { 35 | t.MarkupText = fmt.Sprintf(t.MarkupText, t.FormatArgs...) 36 | } 37 | 38 | if t.MakeSummary { 39 | return &Details{ 40 | Limit: 40, 41 | Text: t.Text, 42 | FormattedText: t.MarkupText, 43 | } 44 | } 45 | 46 | if t.MarkupText != "" { 47 | return &atoms.Text{ 48 | FormattedText: t.MarkupText, 49 | } 50 | } 51 | 52 | return &atoms.Text{ 53 | PlainText: t.Text, 54 | } 55 | } 56 | 57 | func (t *TextBox) Render() app.UI { 58 | return app.Article().Class(t.Class).Body( 59 | app.If(t.Icon != "", func() app.UI { 60 | return app.I().Text(t.Icon).Class(t.IconClass) 61 | }), 62 | 63 | t.composeContentComponent(), 64 | 65 | app.If(t.Button != nil, func() app.UI { 66 | return t.Button 67 | }), 68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/atoms/user_nickname.go: -------------------------------------------------------------------------------- 1 | package atoms 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | ) 6 | 7 | type UserNickname struct { 8 | app.Compo 9 | 10 | Class string 11 | Icon string 12 | Nickname string 13 | SpanID string 14 | Title string 15 | Text string 16 | OnClickActionName string 17 | OnMouseEnterActionName string 18 | OnMouseLeaveActionName string 19 | } 20 | 21 | func (u *UserNickname) onClick(ctx app.Context, e app.Event) { 22 | ctx.NewActionWithValue(u.OnClickActionName, u.Nickname) 23 | } 24 | 25 | func (u *UserNickname) onMouseEnter(ctx app.Context, e app.Event) { 26 | ctx.NewActionWithValue(u.OnMouseEnterActionName, u.SpanID) 27 | } 28 | 29 | func (u *UserNickname) onMouseLeave(ctx app.Context, e app.Event) { 30 | ctx.NewActionWithValue(u.OnMouseLeaveActionName, u.SpanID) 31 | } 32 | 33 | func (u *UserNickname) Render() app.UI { 34 | return app.P().Class("max").Body( 35 | app.A().ID(u.Nickname).Title(u.Title).Class(u.Class).OnClick(u.onClick).Body( 36 | app.Span().ID(u.SpanID).Class(u.Class).Text(u.Nickname).OnMouseEnter(u.onMouseEnter).OnMouseLeave(u.onMouseLeave), 37 | ), 38 | 39 | // Append an icon if defined. 40 | app.If(u.Icon != "", func() app.UI { 41 | return app.Span().Class("bold max").Body( 42 | app.I().Class("small-padding").Text(u.Icon), 43 | ) 44 | }), 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /cmd/bincod/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/gob" 6 | "fmt" 7 | "log" 8 | "os" 9 | ) 10 | 11 | type Example struct { 12 | One int64 13 | Two string 14 | Three float64 15 | } 16 | 17 | func (e Example) MarshalBinary() []byte { 18 | var buf bytes.Buffer 19 | 20 | fmt.Fprintln(&buf, e.One, e.Two, e.Three) 21 | 22 | return buf.Bytes() 23 | } 24 | 25 | func (e *Example) UnmarshalBinary(data *[]byte) error { 26 | buf := bytes.NewBuffer(*data) 27 | 28 | _, err := fmt.Fscanln(buf, e.One, e.Two, e.Three) 29 | 30 | return err 31 | } 32 | 33 | func main() { 34 | var ( 35 | ex = []Example{ 36 | { 37 | One: 99785521455, 38 | Two: "test string that is a bit long than you would normally, casually expected in such scenarios", 39 | Three: 3.1415, 40 | }, 41 | { 42 | One: 778, 43 | Two: "ok stop", 44 | Three: 999.99999, 45 | }, 46 | } 47 | wbuf bytes.Buffer 48 | ) 49 | 50 | enc := gob.NewEncoder(&wbuf) 51 | if err := enc.Encode(ex); err != nil { 52 | log.Fatal("encode: ", err) 53 | } 54 | 55 | os.WriteFile("example.bin", wbuf.Bytes(), 0600) 56 | 57 | // 58 | // 59 | // 60 | 61 | rb, err := os.ReadFile("example.bin") 62 | if err != nil { 63 | log.Fatal("read: ", err) 64 | } 65 | 66 | rbuf := bytes.NewReader(rb) 67 | 68 | dec := gob.NewDecoder(rbuf) 69 | 70 | var dex []Example 71 | if err := dec.Decode(&dex); err != nil { 72 | log.Fatal("decode: ", err) 73 | } 74 | 75 | fmt.Println(dex) 76 | } 77 | -------------------------------------------------------------------------------- /pkg/backend/tokens/service_test.go: -------------------------------------------------------------------------------- 1 | package tokens 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "go.vxn.dev/littr/pkg/backend/common" 9 | "go.vxn.dev/littr/pkg/models" 10 | ) 11 | 12 | const ( 13 | nicknameToFind string = "mocker99" 14 | ) 15 | 16 | // 17 | // Tests 18 | // 19 | 20 | func newTestContext() context.Context { 21 | return context.WithValue(context.Background(), common.ContextUserKeyName, "lawrents") 22 | } 23 | 24 | func newTestService(t *testing.T) models.TokenServiceInterface { 25 | service := NewTokenService(&common.MockTokenRepository{}) 26 | if service == nil { 27 | t.Fatal("nil TokenService") 28 | } 29 | 30 | return service 31 | } 32 | 33 | func TestTokens_TokenServiceCreate(t *testing.T) { 34 | service := newTestService(t) 35 | ctx := newTestContext() 36 | 37 | tokens, err := service.Create(ctx, &models.User{Nickname: nicknameToFind}) 38 | if err != nil { 39 | t.Fatal(err.Error()) 40 | } 41 | 42 | if len(tokens) != 2 { 43 | t.Fatal("too few tokens received") 44 | } 45 | } 46 | 47 | func TestTokens_TokenServiceFindByID(t *testing.T) { 48 | service := newTestService(t) 49 | ctx := newTestContext() 50 | 51 | token, err := service.FindByID(ctx, nicknameToFind) 52 | if err != nil { 53 | if errors.Is(err, errNotImplemented) { 54 | t.Skip("skipping unimplemented method") 55 | } 56 | 57 | t.Fatal(err.Error()) 58 | } 59 | 60 | if token.Nickname != nicknameToFind { 61 | t.Errorf("wrong nickname returned: %s", token.Nickname) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /pkg/backend/users/router.go: -------------------------------------------------------------------------------- 1 | // Users routes and controllers logic package for the backend. 2 | package users 3 | 4 | import ( 5 | chi "github.com/go-chi/chi/v5" 6 | ) 7 | 8 | func NewUserRouter(userController *UserController) chi.Router { 9 | r := chi.NewRouter() 10 | 11 | // Basic routes handlers. 12 | r.Get("/", userController.GetAll) 13 | r.Post("/", userController.Create) 14 | 15 | // Handler for the user activation. 16 | r.Post("/activation", userController.Activate) 17 | 18 | // Passphrase-related routes handlers. 19 | r.Post("/passphrase/request", userController.PassphraseResetRequest) 20 | r.Post("/passphrase/reset", userController.PassphraseReset) 21 | 22 | // User getter handlers. 23 | r.Get("/{userID}", userController.GetByID) 24 | //r.Get("/caller", userController.GetByID) 25 | 26 | // User modification/deletion handlers. 27 | r.Delete("/{userID}", userController.Delete) 28 | 29 | // User's settings modification routes handlers. 30 | r.Post("/{userID}/avatar", userController.UploadAvatar) 31 | r.Get("/{userID}/posts", userController.GetPosts) 32 | 33 | r.Patch("/{userID}/lists", userController.UpdateLists) 34 | r.Patch("/{userID}/options", userController.UpdateOptions) 35 | r.Patch("/{userID}/passphrase", userController.UpdatePassphrase) 36 | 37 | r.Post("/{userID}/subscriptions", userController.Subscribe) 38 | r.Patch("/{userID}/subscriptions/{uuid}", userController.UpdateSubscription) 39 | r.Delete("/{userID}/subscriptions/{uuid}", userController.Unsubscribe) 40 | 41 | return r 42 | } 43 | -------------------------------------------------------------------------------- /pkg/config/tests.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | // Create a custom network TCP connection listener. 12 | func PrepareTestListener(t *testing.T) net.Listener { 13 | if t == nil { 14 | return nil 15 | } 16 | 17 | return PrepareTestListenerWithPort(t, defaultTestServerPort) 18 | } 19 | 20 | func PrepareTestListenerWithPort(t *testing.T, port string) net.Listener { 21 | if t == nil { 22 | return nil 23 | } 24 | 25 | if port == "" { 26 | t.Errorf("listener's port not specified") 27 | } 28 | 29 | listener, err := net.Listen("tcp", ":"+port) 30 | if err != nil { 31 | // Cannot listen on such address = a permission issue or already used 32 | t.Error(err) 33 | return nil 34 | } 35 | 36 | return listener 37 | } 38 | 39 | // Create a custom HTTP server configuration suitable to serve with the SSE streamer. 40 | func PrepareTestServer(t *testing.T, listener net.Listener, handler http.Handler) *httptest.Server { 41 | if t == nil || listener == nil || handler == nil { 42 | return nil 43 | } 44 | 45 | // Common HTTP server config. 46 | serverConfig := &http.Server{ 47 | Addr: listener.Addr().String(), 48 | //ReadTimeout: 0 * time.Second, 49 | WriteTimeout: 0 * time.Second, 50 | Handler: handler, 51 | } 52 | 53 | // Test HTTP server config. 54 | testServer := &httptest.Server{ 55 | Listener: listener, 56 | EnableHTTP2: false, 57 | Config: serverConfig, 58 | } 59 | 60 | return testServer 61 | } 62 | -------------------------------------------------------------------------------- /pkg/models/poll.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Poll struct { 8 | // ID is an unique poll's identifier. 9 | ID string `json:"id"` 10 | 11 | // Question is to describe the main purpose of such poll. 12 | Question string `json:"question"` 13 | 14 | // OptionOne is the answer numero uno. 15 | OptionOne PollOption `json:"option_one"` 16 | 17 | // OptionTwo is the answer numero dos. 18 | OptionTwo PollOption `json:"option_two"` 19 | 20 | // OptionThree is the answer numero tres. 21 | OptionThree PollOption `json:"option_three"` 22 | 23 | // VodeList is the list of user nicknames voted on such poll already. 24 | Voted []string `json:"voted_list"` 25 | 26 | // Timestamp is an UNIX timestamp indication the poll's creation time; should be identical to the upstream post's Timestamp. 27 | Timestamp time.Time `json:"timestamp"` 28 | 29 | // Author is the back key to the user originally posting that poll. 30 | Author string `json:"author"` 31 | 32 | // ReactionCount counts the number of item's reactions. 33 | ReactionCount int64 `json:"reaction_count"` 34 | 35 | // Experimental fields. 36 | Hidden bool `json:"hidden"` 37 | Private bool `json:"private"` 38 | Tags []string `json:"tags"` 39 | } 40 | 41 | type PollOption struct { 42 | // Content describes the very content of such poll's option/answer. 43 | Content string `json:"content"` 44 | 45 | // Counter hold a number of votes being committed to such option. 46 | Counter int64 `json:"counter"` 47 | } 48 | 49 | func (p Poll) GetID() string { 50 | return p.ID 51 | } 52 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/molecules/poll_footer.go: -------------------------------------------------------------------------------- 1 | package molecules 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | 6 | "go.vxn.dev/littr/pkg/frontend/atomic/atoms" 7 | "go.vxn.dev/littr/pkg/models" 8 | ) 9 | 10 | type PollFooter struct { 11 | app.Compo 12 | 13 | Poll models.Poll 14 | 15 | LoggedUserNickname string 16 | PollTimestamp string 17 | 18 | ButtonsDisabled bool 19 | 20 | OnClickDeleteActionName string 21 | } 22 | 23 | func (p *PollFooter) Render() app.UI { 24 | // bottom row of the poll 25 | return app.Div().Class("row").Body( 26 | app.Div().Class("max").Body( 27 | //app.Text(poll.Timestamp.Format("Jan 02, 2006; 15:04:05")), 28 | app.Text(p.PollTimestamp), 29 | ), 30 | app.If(p.Poll.Author == p.LoggedUserNickname, func() app.UI { 31 | return app.Div().Body( 32 | app.B().Title("vote count").Text(len(p.Poll.Voted)), 33 | 34 | &atoms.Button{ 35 | ID: p.Poll.ID, 36 | Title: "delete this poll", 37 | Class: "transparent circle", 38 | Icon: "delete", 39 | OnClickActionName: p.OnClickDeleteActionName, 40 | Disabled: p.ButtonsDisabled, 41 | }, 42 | ) 43 | }).Else(func() app.UI { 44 | return app.Div().Body( 45 | app.B().Title("vote count").Text(len(p.Poll.Voted)), 46 | 47 | &atoms.Button{ 48 | ID: p.Poll.ID, 49 | Title: "voting enabled", 50 | Class: "transparent circle", 51 | Icon: "how_to_vote", 52 | OnClickActionName: "", 53 | Disabled: true, 54 | }, 55 | ) 56 | }), 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /pkg/backend/.pix.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | func PixHandler(w http.ResponseWriter, r *http.Request) { 4 | resp := response{} 5 | 6 | // prepare the Logger instance 7 | l := Logger{ 8 | CallerID: r.Header.Get("X-API-Caller-ID"), 9 | IPAddress: r.Header.Get("X-Real-IP"), 10 | Method: r.Method, 11 | Route: r.URL.String(), 12 | WorkerName: "pix", 13 | Version: r.Header.Get("X-App-Version"), 14 | } 15 | 16 | request := struct { 17 | PostID string `json:"post_id"` 18 | Content string `json:"content"` 19 | }{} 20 | 21 | reqBody, err := io.ReadAll(r.Body) 22 | if err != nil { 23 | resp.Message = "backend error: cannot read input stream: " + err.Error() 24 | resp.Code = http.StatusInternalServerError 25 | 26 | l.Println(resp.Message, resp.Code) 27 | resp.Write(w) 28 | return 29 | } 30 | 31 | data := config.Decrypt([]byte(os.Getenv("APP_PEPPER")), reqBody) 32 | 33 | err = json.Unmarshal(data, &request) 34 | if err != nil { 35 | resp.Message = "backend error: cannot unmarshall fetched data: " + err.Error() 36 | resp.Code = http.StatusInternalServerError 37 | 38 | l.Println(resp.Message, resp.Code) 39 | resp.Write(w) 40 | return 41 | } 42 | 43 | postContent := "/opt/pix/thumb_" + request.Content 44 | 45 | var buffer []byte 46 | 47 | if buffer, err = os.ReadFile(postContent); err != nil { 48 | resp.Message = err.Error() 49 | resp.Code = http.StatusInternalServerError 50 | 51 | l.Println(resp.Message, resp.Code) 52 | resp.Write(w) 53 | return 54 | } 55 | 56 | //compBuff, _ := compressImage(buffer) 57 | 58 | //resp.Data = compBuff 59 | resp.Data = buffer 60 | resp.WritePix(w) 61 | 62 | return 63 | } 64 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/molecules/delete_dialog.go: -------------------------------------------------------------------------------- 1 | package molecules 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | 6 | "go.vxn.dev/littr/pkg/frontend/atomic/atoms" 7 | ) 8 | 9 | type DeleteDialog struct { 10 | app.Compo 11 | 12 | ID string 13 | Title string 14 | 15 | TextBoxClass string 16 | TextBoxIcon string 17 | TextBoxIconClass string 18 | TextBoxText string 19 | 20 | DeleteButtonID string 21 | 22 | ModalButtonsDisabled bool 23 | 24 | OnClickDismissActionName string 25 | OnClickDeleteActionName string 26 | } 27 | 28 | func (d *DeleteDialog) Render() app.UI { 29 | return app.Dialog().ID(d.ID).Class("grey10 white-text active thicc").Body( 30 | &atoms.PageHeading{ 31 | Class: "center", 32 | Title: d.Title, 33 | }, 34 | app.Div().Class("space"), 35 | 36 | &TextBox{ 37 | Class: d.TextBoxClass, 38 | IconClass: d.TextBoxIconClass, 39 | Icon: d.TextBoxIcon, 40 | Text: d.TextBoxText, 41 | }, 42 | app.Div().Class("space"), 43 | 44 | app.Div().Class("row").Body( 45 | &atoms.Button{ 46 | Class: "max bold black white-text thicc", 47 | Icon: "close", 48 | Text: "Cancel", 49 | OnClickActionName: d.OnClickDismissActionName, 50 | Disabled: d.ModalButtonsDisabled, 51 | }, 52 | &atoms.Button{ 53 | ID: d.DeleteButtonID, 54 | Class: "max bold red10 white-text thicc", 55 | Icon: "delete", 56 | Text: "Delete", 57 | OnClickActionName: d.OnClickDeleteActionName, 58 | Disabled: d.ModalButtonsDisabled, 59 | }, 60 | ), 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /pkg/backend/db/keeper.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import "sync" 4 | 5 | type DatabaseKeeper interface { 6 | ReadLock() 7 | ReadUnlock() 8 | Lock() 9 | Unlock() 10 | ReleaseLock() 11 | 12 | RunMigrations() (report string, err error) 13 | 14 | DumpAll() (report string, err error) 15 | LoadAll() (report string, err error) 16 | 17 | Database() map[string]Cacher 18 | } 19 | 20 | type defaultDatabaseKeeper struct { 21 | // Protect the stack as a whole with a proper mutex. 22 | mu *sync.RWMutex 23 | 24 | readonly bool 25 | 26 | caches []Cacher 27 | } 28 | 29 | func NewDatabase() DatabaseKeeper { 30 | var ( 31 | caches []Cacher 32 | mu sync.RWMutex 33 | ) 34 | 35 | names := []string{ 36 | "FlowCache", 37 | "PollCache", 38 | "RequestCache", 39 | "TokenCache", 40 | "UserCache", 41 | } 42 | 43 | for _, name := range names { 44 | caches = append(caches, NewDefaultCache(name)) 45 | } 46 | 47 | return &defaultDatabaseKeeper{ 48 | mu: &mu, 49 | caches: caches, 50 | } 51 | } 52 | 53 | func (d *defaultDatabaseKeeper) ReadLock() { 54 | d.mu.RLock() 55 | } 56 | 57 | func (d *defaultDatabaseKeeper) ReadUnlock() { 58 | d.mu.RUnlock() 59 | } 60 | 61 | func (d *defaultDatabaseKeeper) Lock() { 62 | d.mu.Lock() 63 | } 64 | 65 | func (d *defaultDatabaseKeeper) Unlock() { 66 | d.mu.Unlock() 67 | } 68 | 69 | func (d *defaultDatabaseKeeper) ReleaseLock() { 70 | d.mu.Unlock() 71 | d.readonly = true 72 | } 73 | 74 | func (d *defaultDatabaseKeeper) Database() map[string]Cacher { 75 | m := make(map[string]Cacher) 76 | 77 | for _, cache := range d.caches { 78 | if name := cache.GetName(); name == "" { 79 | continue 80 | } else { 81 | m[name] = cache 82 | } 83 | } 84 | 85 | return m 86 | } 87 | -------------------------------------------------------------------------------- /pkg/frontend/tos/render.go: -------------------------------------------------------------------------------- 1 | package tos 2 | 3 | import ( 4 | "go.vxn.dev/littr/pkg/frontend/common" 5 | 6 | "github.com/maxence-charriere/go-app/v10/pkg/app" 7 | ) 8 | 9 | func (c *Content) Render() app.UI { 10 | return app.Main().Class("responsive").Body( 11 | app.Div().Class("row").Body( 12 | app.Div().Class("max padding").Body( 13 | app.H5().Text("littr ToS (terms of service)"), 14 | //app.P().Text("let us be serious for a sec nocap"), 15 | ), 16 | ), 17 | 18 | // snackbar 19 | app.A().Href(c.toast.TLink).OnClick(c.onClickDismiss).Body( 20 | app.If(c.toast.TText != "", func() app.UI { 21 | return app.Div().Class("snackbar white-text top active "+common.ToastColor(c.toast.TType)).Body( 22 | app.I().Text("error"), 23 | app.Span().Text(c.toast.TText), 24 | ) 25 | }), 26 | ), 27 | 28 | app.Div().Class("padding responsive").Body( 29 | app.Ol().Class("extra-line large-text padding").Body( 30 | app.Li().Text("don't comment on things you got no context to"), 31 | app.Li().Text("you don't have to comment on every post available"), 32 | app.Li().Text("don't annoy other fellow flowers"), 33 | app.Li().Text("don't be rude"), 34 | app.Li().Text("don't make me tap the sign"), 35 | app.Li().Text("enjoy the ride"), 36 | ), 37 | ), 38 | 39 | app.Div().Class("large-space"), 40 | app.Div().Class("label padding responsive").Body( 41 | app.Article().Class("bottom-align medium transparent padding").Body( 42 | app.Img().Src("https://i.kym-cdn.com/photos/images/original/001/970/928/ce5.jpg").Class("no-padding absolute center middle").Style("max-width", "90%"), 43 | ), 44 | ), 45 | app.Div().Class("large-space"), 46 | app.Div().Class("large-space"), 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /cmd/dbench/random.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | "unsafe" 7 | ) 8 | 9 | // 10 | // RandomString 11 | // https://stackoverflow.com/a/31832326 12 | // 13 | 14 | const ( 15 | letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 16 | 17 | letterIdxBits = 6 // 6 bits to represent a letter index 18 | letterIdxMask = 1<= 0; { 32 | if remain == 0 { 33 | cache, remain = src.Int63(), letterIdxMax 34 | } 35 | if idx := int(cache & letterIdxMask); idx < len(letterBytes) { 36 | b[i] = letterBytes[idx] 37 | i-- 38 | } 39 | cache >>= letterIdxBits 40 | remain-- 41 | } 42 | 43 | return *(*string)(unsafe.Pointer(&b)) 44 | } 45 | 46 | // 47 | // RandomString + RandomStringWithCharset 48 | // https://www.calhoun.io/creating-random-strings-in-go/ 49 | // 50 | 51 | var seededRand *rand.Rand = rand.New( 52 | rand.NewSource(time.Now().UnixNano())) 53 | 54 | func RandomString(length int) string { 55 | return RandomStringWithCharset(length, letterBytes) 56 | } 57 | 58 | func RandomStringWithCharset(length int, charset string) string { 59 | b := make([]byte, length) 60 | for i := range b { 61 | b[i] = charset[seededRand.Intn(len(charset))] 62 | } 63 | return string(b) 64 | } 65 | -------------------------------------------------------------------------------- /cmd/sse_client/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "os" 10 | "os/signal" 11 | "syscall" 12 | 13 | "go.vxn.dev/littr/pkg/config" 14 | 15 | "github.com/tmaxmax/go-sse" 16 | ) 17 | 18 | var URL = func() string { 19 | if os.Getenv("SSE_CLIENT_URL") != "" { 20 | return os.Getenv("SSE_CLIENT_URL") 21 | } 22 | 23 | return "http://localhost:" + config.ServerPort 24 | }() 25 | 26 | func main() { 27 | var sub string 28 | flag.StringVar(&sub, "sub", "all", "The topics to subscribe to. Valid values are: all, numbers, metrics") 29 | flag.Parse() 30 | 31 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 32 | defer cancel() 33 | 34 | r, _ := http.NewRequestWithContext(ctx, http.MethodGet, getRequestURL(sub), http.NoBody) 35 | conn := sse.NewConnection(r) 36 | 37 | conn.SubscribeToAll(func(event sse.Event) { 38 | switch event.Type { 39 | case "keepalive", "ops": 40 | fmt.Printf("%s: %s\n", event.Type, event.Data) 41 | case "server-stop": 42 | fmt.Println("server closed!") 43 | cancel() 44 | default: // no event name 45 | fmt.Printf("%s: %s\n", event.Type, event.Data) 46 | } 47 | }) 48 | 49 | fmt.Println("starting SSE client, listening towards: " + URL) 50 | 51 | if err := conn.Connect(); err != nil { 52 | fmt.Fprintln(os.Stderr, err) 53 | } 54 | } 55 | 56 | func getRequestURL(sub string) string { 57 | q := url.Values{} 58 | switch sub { 59 | case "all": 60 | q.Add("topic", "numbers") 61 | q.Add("topic", "metrics") 62 | case "numbers", "metrics": 63 | q.Set("topic", sub) 64 | default: 65 | panic(fmt.Errorf("unexpected subscription topic %q", sub)) 66 | } 67 | 68 | return URL + "/api/v1/live?" + q.Encode() 69 | } 70 | -------------------------------------------------------------------------------- /.github/workflows/test-and-build.yml: -------------------------------------------------------------------------------- 1 | name: littr CI/CD test and build pipeline 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - 'v0.*' 8 | - 'v1.*' 9 | 10 | 11 | jobs: 12 | # unit: 13 | # runs-on: ${{ secrets.RUNNER_LABELS }} 14 | # steps: 15 | # - uses: actions/checkout@v4 16 | # - name: Run unit/integration tests. 17 | # env: 18 | # HOSTNAME: ${{ vars.HOSTNAME }} 19 | # run: make unit 20 | 21 | build: 22 | runs-on: ${{ vars.RUNNER_LABELS_BUILD }} 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Build new swapi image (with staging). 26 | env: 27 | APP_PEPPER: ${{ secrets.APP_PEPPER }} 28 | APP_URLS_TRAEFIK: ${{ secrets.APP_URLS_TRAEFIK }} 29 | DOCKER_CONTAINER_NAME: ${{ secrets.DOCKER_CONTAINER_NAME }} 30 | DOCKER_NETWORK_NAME: ${{ secrets.DOCKER_NETWORK_NAME }} 31 | REGISTRY: ${{ secrets.REGISTRY }} 32 | REGISTRY_USER: ${{ secrets.REGISTRY_USER }} 33 | REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} 34 | VAPID_PUB_KEY: ${{ secrets.VAPID_PUB_KEY }} 35 | run: make build 36 | 37 | push: 38 | runs-on: ${{ vars.RUNNER_LABELS_BUILD }} 39 | needs: [ build ] 40 | steps: 41 | - uses: actions/checkout@v4 42 | - name: Push the image to registry. 43 | env: 44 | REGISTRY: ${{ secrets.REGISTRY }} 45 | REGISTRY_USER: ${{ secrets.REGISTRY_USER }} 46 | REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} 47 | run: make push_to_registry 48 | 49 | generate_docs: 50 | runs-on: ${{ vars.RUNNER_LABELS_BUILD }} 51 | needs: [ build ] 52 | steps: 53 | - uses: actions/checkout@v4 54 | - name: (re)Generate interface API swagger docs. 55 | run: make docs 56 | 57 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/molecules/flow_header.go: -------------------------------------------------------------------------------- 1 | package molecules 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | 6 | "go.vxn.dev/littr/pkg/frontend/atomic/atoms" 7 | "go.vxn.dev/littr/pkg/models" 8 | ) 9 | 10 | type FlowHeader struct { 11 | app.Compo 12 | 13 | SingleUser models.User 14 | 15 | SinglePostID string 16 | Hashtag string 17 | 18 | ButtonsDisabled bool 19 | RefreshClicked bool 20 | } 21 | 22 | func (h *FlowHeader) Render() app.UI { 23 | var heading = func() string { 24 | if h.Hashtag != "" && len(h.Hashtag) < 20 { 25 | return "hashtag #" + h.Hashtag 26 | } 27 | if h.Hashtag != "" && len(h.Hashtag) >= 20 { 28 | return "hashtag" 29 | } 30 | if h.SinglePostID != "" { 31 | return "original post and replies" 32 | } 33 | 34 | return "flow" 35 | } 36 | 37 | return app.Div().Class("row").Body( 38 | app.Div().Class("max padding").Body( 39 | app.If(h.SingleUser.Nickname != "", func() app.UI { 40 | return app.H5().Body( 41 | app.Text(h.SingleUser.Nickname+"'s flow"), 42 | 43 | app.If(h.SingleUser.Private, func() app.UI { 44 | return app.Span().Class("bold").Body( 45 | app.I().Text("lock"), 46 | ) 47 | }), 48 | ) 49 | }).Else(func() app.UI { 50 | return app.H5().Text(heading()) 51 | }), 52 | ), 53 | 54 | app.Div().Class("small-padding").Body( 55 | &atoms.Button{ 56 | ID: "refresh-button", 57 | Title: "refresh flow [R]", 58 | Class: "primary-container white-text bold thicc", 59 | Icon: "refresh", 60 | Text: "Refresh", 61 | OnClickActionName: "refresh", 62 | Disabled: h.ButtonsDisabled, 63 | ShowProgress: h.RefreshClicked, 64 | }, 65 | ), 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /cmd/chimp/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "sort" 9 | "strings" 10 | ) 11 | 12 | func main() { 13 | findCmd := exec.Command("find", ".", "-wholename", "./pkg/frontend/*/*.go", "-print") 14 | 15 | var out strings.Builder 16 | findCmd.Stdout = &out 17 | 18 | if err := findCmd.Run(); err != nil { 19 | fmt.Println(err.Error()) 20 | return 21 | } 22 | 23 | // Show the stdout. 24 | //fmt.Printf("%s", out.String()) 25 | 26 | var imports = make(map[string]string) 27 | 28 | var importStarted bool 29 | 30 | // Range over split output by newline to operate on each file. 31 | for _, file := range strings.Split(out.String(), "\n") { 32 | f, err := os.Open(file) 33 | if err != nil { 34 | fmt.Println(err.Error()) 35 | continue 36 | } 37 | 38 | rdr := bufio.NewReader(f) 39 | 40 | for { 41 | line, err := rdr.ReadSlice(byte('\n')) 42 | if err != nil { 43 | fmt.Println(err.Error()) 44 | } 45 | 46 | if len(line) == 0 { 47 | continue 48 | } 49 | 50 | if strings.Contains(string(line), "//") { 51 | continue 52 | } 53 | 54 | if strings.Contains(string(line), "import (") { 55 | importStarted = true 56 | continue 57 | } 58 | 59 | if strings.Contains(string(line), ")") { 60 | importStarted = false 61 | break 62 | } 63 | 64 | if importStarted { 65 | imp := strings.Trim(strings.TrimSpace(string(line)), "\"") 66 | imports[imp] = "" 67 | } 68 | } 69 | } 70 | 71 | // 72 | // Results 73 | // 74 | 75 | fmt.Printf("imports: %d\n", len(imports)) 76 | 77 | var keys []string 78 | 79 | for key := range imports { 80 | keys = append(keys, key) 81 | } 82 | 83 | sort.Strings(keys) 84 | oneport := strings.Join(keys, "\n") 85 | 86 | fmt.Printf("%s", oneport) 87 | } 88 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.vxn.dev/littr 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.7 6 | 7 | require ( 8 | github.com/SherClockHolmes/webpush-go v1.4.0 9 | github.com/dsoprea/go-exif/v3 v3.0.1 10 | github.com/go-chi/chi/v5 v5.2.1 11 | github.com/go-chi/httprate v0.15.0 12 | github.com/golang-jwt/jwt v3.2.2+incompatible 13 | github.com/google/uuid v1.6.0 14 | github.com/maxence-charriere/go-app/v10 v10.1.3 15 | github.com/prometheus/client_golang v1.22.0 16 | github.com/sizeofint/webpanimation v0.0.0-20210809145948-1d2b32119882 17 | github.com/tmaxmax/go-sse v0.10.0 18 | github.com/wneessen/go-mail v0.6.2 19 | golang.org/x/image v0.27.0 20 | ) 21 | 22 | require ( 23 | github.com/beorn7/perks v1.0.1 // indirect 24 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 25 | github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect 26 | github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect 27 | github.com/go-errors/errors v1.5.1 // indirect 28 | github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 29 | github.com/golang/geo v0.0.0-20250509130527-0a13e5a5d53d // indirect 30 | github.com/klauspost/compress v1.18.0 // indirect 31 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 32 | github.com/kr/text v0.2.0 // indirect 33 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 34 | github.com/prometheus/client_model v0.6.2 // indirect 35 | github.com/prometheus/common v0.63.0 // indirect 36 | github.com/prometheus/procfs v0.16.1 // indirect 37 | github.com/zeebo/xxh3 v1.0.2 // indirect 38 | golang.org/x/crypto v0.38.0 // indirect 39 | golang.org/x/net v0.40.0 // indirect 40 | golang.org/x/sys v0.33.0 // indirect 41 | golang.org/x/text v0.25.0 // indirect 42 | google.golang.org/protobuf v1.36.6 // indirect 43 | gopkg.in/yaml.v2 v2.4.0 // indirect 44 | ) 45 | -------------------------------------------------------------------------------- /.github/workflows/deployment.yml: -------------------------------------------------------------------------------- 1 | name: littr CI/CD deployment pipeline 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | prod_deploy: 8 | runs-on: ${{ vars.RUNNER_LABELS }} 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Deploy prod docker container, recreate container with fresh image. 12 | env: 13 | API_TOKEN: ${{ secrets.API_TOKEN }} 14 | APP_ENVIRONMENT: ${{ vars.APP_ENVIRONMENT }} 15 | APP_PEPPER: ${{ secrets.APP_PEPPER }} 16 | APP_URLS_TRAEFIK: ${{ vars.APP_URLS_TRAEFIK }} 17 | DOCKER_CONTAINER_NAME: ${{ vars.DOCKER_CONTAINER_NAME }} 18 | DOCKER_EXTERNAL_PORT: ${{ vars.DOCKER_EXTERNAL_PORT }} 19 | DOCKER_NETWORK_NAME: ${{ vars.DOCKER_NETWORK_NAME }} 20 | DOCKER_SWAGGER_CONTAINER_NAME: ${{ vars.DOCKER_SWAGGER_CONTAINER_NAME }} 21 | DOCKER_SWAGGER_EXTERNAL_PORT: ${{ vars.DOCKER_SWAGGER_EXTERNAL_PORT }} 22 | DOCKER_VOLUME_DATA_NAME: ${{ vars.DOCKER_VOLUME_DATA_NAME }} 23 | DOCKER_VOLUME_PIX_NAME: ${{ vars.DOCKER_VOLUME_PIX_NAME }} 24 | LOKI_LABELS: ${{ vars.LOKI_LABELS }} 25 | LOKI_URL: ${{ secrets.LOKI_URL }} 26 | MAIL_HELO: ${{ secrets.MAIL_HELO }} 27 | MAIL_HOST: ${{ secrets.MAIL_HOST }} 28 | MAIL_PORT: ${{ secrets.MAIL_PORT }} 29 | MAIL_SASL_USR: ${{ secrets.MAIL_SASL_USR }} 30 | MAIL_SASL_PWD: ${{ secrets.MAIL_SASL_PWD }} 31 | REGISTRATION_ENABLED: ${{ vars.REGISTRATION_ENABLED }} 32 | REGISTRY: ${{ secrets.REGISTRY }} 33 | REGISTRY_USER: ${{ secrets.REGISTRY_USER }} 34 | REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} 35 | VAPID_PUB_KEY: ${{ secrets.VAPID_PUB_KEY }} 36 | VAPID_PRIV_KEY: ${{ secrets.VAPID_PRIV_KEY }} 37 | VAPID_SUBSCRIBER: ${{ secrets.VAPID_SUBSCRIBER }} 38 | run: make run 39 | 40 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/molecules/post_header.go: -------------------------------------------------------------------------------- 1 | package molecules 2 | 3 | import ( 4 | "go.vxn.dev/littr/pkg/frontend/atomic/atoms" 5 | 6 | "github.com/maxence-charriere/go-app/v10/pkg/app" 7 | ) 8 | 9 | type PostHeader struct { 10 | app.Compo 11 | 12 | PostAuthor string 13 | PostAvatarURL string 14 | PostID string 15 | 16 | ButtonsDisabled bool 17 | 18 | OnClickLinkActionName string 19 | OnClickUserActionName string 20 | OnMouseEnterActionName string 21 | OnMouseLeaveActionName string 22 | } 23 | 24 | func (p *PostHeader) Render() app.UI { 25 | if p.PostAuthor == "system" { 26 | return app.Div().Class("space") 27 | } 28 | 29 | // post header (author avatar + name + link button) 30 | return app.Div().Class("row top-padding bottom-padding").Body( 31 | &atoms.Image{ 32 | ID: p.PostAuthor, 33 | Title: "user's avatar", 34 | Class: "responsive max left", 35 | Src: p.PostAvatarURL, 36 | Styles: map[string]string{"max-width": "60px", "border-radius": "50%"}, 37 | OnClickActionName: p.OnClickUserActionName, 38 | }, 39 | 40 | &atoms.UserNickname{ 41 | SpanID: "user-flow-link-" + p.PostID, 42 | Title: "user's flow link", 43 | Class: "large-text bold primary-text", 44 | Nickname: p.PostAuthor, 45 | OnClickActionName: p.OnClickLinkActionName, 46 | OnMouseEnterActionName: p.OnMouseEnterActionName, 47 | OnMouseLeaveActionName: p.OnMouseLeaveActionName, 48 | }, 49 | 50 | &atoms.Button{ 51 | ID: p.PostID, 52 | Title: "link to this post", 53 | Class: "transparent circle", 54 | Icon: "link", 55 | OnClickActionName: p.OnClickLinkActionName, 56 | Disabled: p.ButtonsDisabled, 57 | }, 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /pkg/backend/tokens/jwt.go: -------------------------------------------------------------------------------- 1 | package tokens 2 | 3 | // https://pascalallen.medium.com/jwt-authentication-with-go-242215a9b4f8 4 | 5 | import ( 6 | "github.com/golang-jwt/jwt" 7 | ) 8 | 9 | // UserClaims is a generic structure for a personal user's (access) token. 10 | type UserClaims struct { 11 | Nickname string `json:"nickname"` 12 | jwt.StandardClaims 13 | } 14 | 15 | // NewAccessToken generates a new signed access token. 16 | func NewAccessToken(claims UserClaims, secret string) (string, error) { 17 | accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 18 | 19 | return accessToken.SignedString([]byte(secret)) 20 | } 21 | 22 | // NewRefreshToken generates a new signed refresh token. 23 | func NewRefreshToken(claims jwt.StandardClaims, secret string) (string, error) { 24 | refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 25 | 26 | return refreshToken.SignedString([]byte(secret)) 27 | } 28 | 29 | // ParseAccessToken decodes the accessCookie value to get the UserClaims payload. 30 | func ParseAccessToken(accessToken string, secret string) *UserClaims { 31 | parsedAccessToken, _ := jwt.ParseWithClaims(accessToken, &UserClaims{}, func(token *jwt.Token) (interface{}, error) { 32 | return []byte(secret), nil 33 | }) 34 | 35 | // Assert type pointer to UserClaims. 36 | return parsedAccessToken.Claims.(*UserClaims) 37 | } 38 | 39 | // ParseRefreshToken decodes the refreshCookie value to get the StandardClaims payload. 40 | func ParseRefreshToken(refreshToken string, secret string) *jwt.StandardClaims { 41 | parsedRefreshToken, _ := jwt.ParseWithClaims(refreshToken, &jwt.StandardClaims{}, func(token *jwt.Token) (interface{}, error) { 42 | return []byte(secret), nil 43 | }) 44 | 45 | if parsedRefreshToken == nil { 46 | return nil 47 | } 48 | 49 | // Assert type pointer to StandardClaims. 50 | return parsedRefreshToken.Claims.(*jwt.StandardClaims) 51 | } 52 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | # littr Gitlab CI configuration file 2 | 3 | workflow: 4 | rules: 5 | - if: '$CI_COMMIT_TAG' 6 | when: always 7 | - if: $DOCKER_IMAGE_TAG != null 8 | when: always 9 | - when: never 10 | 11 | stages: 12 | - test 13 | - build 14 | - deploy 15 | 16 | 17 | # 18 | # stage test 19 | # 20 | 21 | combined-coverage-sonarqube-check: 22 | stage: test 23 | allow_failure: true 24 | script: 25 | - go clean -testcache 26 | - go test -v -coverprofile ./coverage.profile ./... && go tool cover -func ./coverage.profile 27 | - make sonar_check 28 | 29 | test-local: 30 | stage: test 31 | environment: 32 | name: test 33 | variables: 34 | APP_ENVIRONMENT: 'test' 35 | script: 36 | - make test_local 37 | 38 | 39 | # 40 | # stage build 41 | # 42 | 43 | build-image: 44 | stage: build 45 | needs: 46 | - test-local 47 | rules: 48 | - if: $APP_PEPPER != null 49 | - if: $APP_VERSION != null 50 | - if: $VAPID_PUBLIC_KEY != null 51 | script: 52 | - make build 53 | - make push_to_registry 54 | 55 | 56 | # 57 | # stage deploy 58 | # 59 | 60 | .deploy_common: 61 | stage: deploy 62 | when: manual 63 | needs: 64 | - build-image 65 | before_script: 66 | - eval $(ssh-agent -s) 67 | - chmod 400 "$DEPLOY_SSH_KEY" 68 | - ssh-add "$DEPLOY_SSH_KEY" 69 | script: 70 | - export DOCKER_HOST=ssh://$DEPLOY_USER@$DEPLOY_TARGET 71 | - make run 72 | 73 | deploy-stage: 74 | environment: 75 | name: stage 76 | variables: 77 | APP_ENVIRONMENT: 'stage' 78 | extends: .deploy_common 79 | 80 | deploy-prod: 81 | environment: 82 | name: prod 83 | variables: 84 | APP_ENVIRONMENT: 'prod' 85 | extends: .deploy_common 86 | 87 | deploy-demo: 88 | environment: 89 | name: demo 90 | variables: 91 | APP_ENVIRONMENT: 'demo' 92 | extends: .deploy_common 93 | 94 | -------------------------------------------------------------------------------- /pkg/backend/common/return_code.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // Common helper function to decide the HTTP error according to the error contents. 8 | var DecideStatusFromError = func(err error) int { 9 | // HTTP 200 condition. 10 | if err == nil { 11 | return http.StatusOK 12 | } 13 | 14 | // HTTP 400 conditions 15 | if err.Error() == ERR_REQUEST_EMAIL_BLANK || 16 | err.Error() == ERR_REQUEST_UUID_BLANK || 17 | err.Error() == ERR_INPUT_DATA_FAIL || 18 | err.Error() == ERR_PASSPHRASE_REQ_INCOMPLETE || 19 | err.Error() == ERR_REQUEST_UUID_EXPIRED || 20 | err.Error() == ERR_REQUEST_UUID_BLANK || 21 | err.Error() == ERR_REQUEST_UUID_INVALID || 22 | err.Error() == ERR_RESTRICTED_NICKNAME || 23 | err.Error() == ERR_USER_NICKNAME_TAKEN || 24 | err.Error() == ERR_NICKNAME_CHARSET_MISMATCH || 25 | err.Error() == ERR_NICKNAME_TOO_LONG_SHORT || 26 | err.Error() == ERR_WRONG_EMAIL_FORMAT || 27 | err.Error() == ERR_INPUT_DATA_FAIL || 28 | err.Error() == ERR_IMG_UNKNOWN_TYPE { 29 | return http.StatusBadRequest 30 | } 31 | 32 | // HTTP 403 conditions. 33 | if err.Error() == ERR_POLL_SELF_VOTE || 34 | err.Error() == ERR_USER_SHADED || 35 | err.Error() == ERR_USER_DELETE_FOREIGN || 36 | err.Error() == ERR_USER_PASSPHRASE_FOREIGN || 37 | err.Error() == ERR_REGISTRATION_DISABLED || 38 | err.Error() == ERR_POLL_EXISTING_VOTE || 39 | err.Error() == ERR_POLL_INVALID_VOTE_COUNT { 40 | return http.StatusForbidden 41 | } 42 | 43 | // HTTP 404 conditions. 44 | if err.Error() == ERR_POLL_NOT_FOUND || 45 | err.Error() == ERR_NO_EMAIL_MATCH || 46 | err.Error() == ERR_USER_NOT_FOUND { 47 | return http.StatusNotFound 48 | } 49 | 50 | // HTTP 409 condition 51 | if err.Error() == ERR_EMAIL_ALREADY_USED || 52 | err.Error() == ERR_PASSPHRASE_CURRENT_WRONG { 53 | return http.StatusConflict 54 | } 55 | 56 | // HTTP 500 as default. 57 | return http.StatusInternalServerError 58 | } 59 | -------------------------------------------------------------------------------- /cmd/littr/client.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | fe "go.vxn.dev/littr/pkg/frontend" 5 | 6 | "github.com/maxence-charriere/go-app/v10/pkg/app" 7 | ) 8 | 9 | type client struct{} 10 | 11 | func newClient() *client { 12 | return &client{} 13 | } 14 | 15 | func (c *client) Run() { 16 | app.Route("/", func() app.Composer { 17 | return &fe.WelcomeView{} 18 | }) 19 | app.RouteWithRegexp("^/activation/[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}$", func() app.Composer { 20 | return &fe.LoginView{} 21 | }) 22 | app.RouteWithRegexp("^/(flow|flow/posts/[0-9]+|flow/hashtags/[a-zA-Z]+|flow/users/[a-zA-Z0-9]+)$", func() app.Composer { 23 | return &fe.FlowView{} 24 | }) 25 | app.Route("/login", func() app.Composer { 26 | return &fe.LoginView{} 27 | }) 28 | app.Route("/logout", func() app.Composer { 29 | return &fe.LoginView{} 30 | }) 31 | app.Route("/polls", func() app.Composer { 32 | return &fe.PollsView{} 33 | }) 34 | app.RouteWithRegexp("^/polls/[0-9a-zA-Z]+$", func() app.Composer { 35 | return &fe.PollsView{} 36 | }) 37 | app.Route("/post", func() app.Composer { 38 | return &fe.PostView{} 39 | }) 40 | app.Route("/register", func() app.Composer { 41 | return &fe.RegisterView{} 42 | }) 43 | app.Route("/reset", func() app.Composer { 44 | return &fe.ResetView{} 45 | }) 46 | app.RouteWithRegexp("^/reset/[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}$", func() app.Composer { 47 | return &fe.ResetView{} 48 | }) 49 | app.Route("/settings", func() app.Composer { 50 | return &fe.SettingsView{} 51 | }) 52 | app.Route("/stats", func() app.Composer { 53 | return &fe.StatsView{} 54 | }) 55 | app.RouteWithRegexp("^/success/[a-zA-Z]+$", func() app.Composer { 56 | return &fe.LoginView{} 57 | }) 58 | app.Route("/tos", func() app.Composer { 59 | return &fe.ToSView{} 60 | }) 61 | app.Route("/users", func() app.Composer { 62 | return &fe.UsersView{} 63 | }) 64 | 65 | app.RunWhenOnBrowser() 66 | } 67 | -------------------------------------------------------------------------------- /pkg/backend/posts/repository.go: -------------------------------------------------------------------------------- 1 | package posts 2 | 3 | import ( 4 | "fmt" 5 | 6 | "go.vxn.dev/littr/pkg/backend/db" 7 | "go.vxn.dev/littr/pkg/models" 8 | ) 9 | 10 | // The implementation of pkg/models.PostRepositoryInterface. 11 | type PostRepository struct { 12 | cache db.Cacher 13 | } 14 | 15 | func NewPostRepository(cache db.Cacher) *PostRepository { 16 | if cache == nil { 17 | return nil 18 | } 19 | 20 | return &PostRepository{ 21 | cache: cache, 22 | } 23 | } 24 | 25 | func (r *PostRepository) GetAll() (*map[string]models.Post, error) { 26 | rawPosts, count := r.cache.Range() 27 | if count == 0 { 28 | return nil, fmt.Errorf("no items found") 29 | } 30 | 31 | posts := make(map[string]models.Post) 32 | 33 | // Assert types to fetched interface map. 34 | for key, rawPost := range *rawPosts { 35 | post, ok := rawPost.(models.Post) 36 | if !ok { 37 | return nil, fmt.Errorf("post's data corrupted") 38 | } 39 | 40 | posts[key] = post 41 | } 42 | 43 | return &posts, nil 44 | } 45 | 46 | func (r *PostRepository) GetByID(postID string) (*models.Post, error) { 47 | // Fetch the post from the cache. 48 | rawPost, found := r.cache.Load(postID) 49 | if !found { 50 | return nil, fmt.Errorf("requested post not found") 51 | } 52 | 53 | // Assert the type 54 | post, ok := rawPost.(models.Post) 55 | if !ok { 56 | return nil, fmt.Errorf("post's data corrupted") 57 | } 58 | 59 | return &post, nil 60 | } 61 | 62 | func (r *PostRepository) Save(post *models.Post) error { 63 | // Store the post using its key in the cache. 64 | saved := r.cache.Store(post.ID, *post) 65 | if !saved { 66 | return fmt.Errorf("an error occurred while saving a post") 67 | } 68 | 69 | return nil 70 | } 71 | 72 | func (r *PostRepository) Delete(postID string) error { 73 | // Simple post's deleting. 74 | deleted := r.cache.Delete(postID) 75 | if !deleted { 76 | return fmt.Errorf("post data could not be purged from the database") 77 | } 78 | 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /pkg/models/post.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | type Post struct { 10 | // ID is an unique post's identificator. 11 | ID string `json:"id"` 12 | 13 | // Type describes the post's type --- post, poll, reply, img. 14 | Type string `json:"type"` 15 | 16 | // Nickname is a name of the post's author's name. 17 | Nickname string `json:"nickname"` 18 | 19 | // Content contains the very post's data to be shown as a text typed in by the author when created. 20 | Content string `json:"content"` 21 | 22 | // Figure hold the filename of the uploaded figure to post with some provided text. 23 | Figure string `json:"figure"` 24 | 25 | // Timestamp is an UNIX timestamp, indicates the creation time. 26 | Timestamp time.Time `json:"timestamp"` 27 | 28 | // PollID is an identification of the Poll structure/object. 29 | PollID string `json:"poll_id"` 30 | 31 | // ReplyToID is a reference key to another post, that is being replied to. 32 | ReplyToID string `json:"reply_to_id"` 33 | 34 | // ReactionCount counts the number of item's reactions. 35 | ReactionCount int64 `json:"reaction_count"` 36 | 37 | // ReplyCount hold the count of replies for such post. 38 | ReplyCount int64 `json:"reply_count"` 39 | 40 | // Data is a helper field for the actual figure upload. 41 | Data []byte `json:"data" swaggerignore:"true"` 42 | } 43 | 44 | func (p Post) MarshalBinary() []byte { 45 | var buf bytes.Buffer 46 | 47 | fmt.Fprintln(&buf, p.ID, p.Type, p.Nickname, p.Content, p.Figure, p.Timestamp, p.PollID, p.ReplyToID, p.ReactionCount, p.ReplyCount, string(p.Data)) 48 | 49 | return buf.Bytes() 50 | } 51 | 52 | func (p *Post) UnmarshalBinary(data *[]byte) error { 53 | buf := bytes.NewBuffer(*data) 54 | 55 | _, err := fmt.Fscanln(buf, p.ID, p.Type, p.Nickname, p.Content, p.Figure, p.Timestamp, p.PollID, p.ReplyToID, p.ReactionCount, p.ReplyCount, p.Data) 56 | 57 | return err 58 | } 59 | 60 | func (p Post) GetID() string { 61 | return p.ID 62 | } 63 | -------------------------------------------------------------------------------- /pkg/backend/polls/repository.go: -------------------------------------------------------------------------------- 1 | package polls 2 | 3 | import ( 4 | "fmt" 5 | 6 | "go.vxn.dev/littr/pkg/backend/common" 7 | "go.vxn.dev/littr/pkg/backend/db" 8 | "go.vxn.dev/littr/pkg/models" 9 | ) 10 | 11 | // The implementation of pkg/models.PollRepositoryInterface. 12 | type PollRepository struct { 13 | cache db.Cacher 14 | } 15 | 16 | func NewPollRepository(cache db.Cacher) models.PollRepositoryInterface { 17 | if cache == nil { 18 | return nil 19 | } 20 | 21 | return &PollRepository{ 22 | cache: cache, 23 | } 24 | } 25 | 26 | func (r *PollRepository) GetAll() (*map[string]models.Poll, error) { 27 | rawPolls, count := r.cache.Range() 28 | if count == 0 { 29 | return nil, fmt.Errorf("no items found") 30 | } 31 | 32 | polls := make(map[string]models.Poll) 33 | 34 | // Assert types to fetched interface map. 35 | for key, rawPoll := range *rawPolls { 36 | poll, ok := rawPoll.(models.Poll) 37 | if !ok { 38 | return nil, fmt.Errorf("poll's data corrupted") 39 | } 40 | 41 | polls[key] = poll 42 | } 43 | 44 | return &polls, nil 45 | } 46 | 47 | func (r *PollRepository) GetByID(pollID string) (*models.Poll, error) { 48 | // Fetch the poll from the cache. 49 | rawPoll, found := r.cache.Load(pollID) 50 | if !found { 51 | return nil, fmt.Errorf(common.ERR_POLL_NOT_FOUND) 52 | } 53 | 54 | // Assert the type 55 | poll, ok := rawPoll.(models.Poll) 56 | if !ok { 57 | return nil, fmt.Errorf("poll's data corrupted") 58 | } 59 | 60 | return &poll, nil 61 | } 62 | 63 | func (r *PollRepository) Save(poll *models.Poll) error { 64 | // Store the poll using its key in the cache. 65 | saved := r.cache.Store(poll.ID, *poll) 66 | if !saved { 67 | return fmt.Errorf("an error occurred while saving a poll") 68 | } 69 | 70 | return nil 71 | } 72 | 73 | func (r *PollRepository) Delete(pollID string) error { 74 | // Simple poll's deleting. 75 | deleted := r.cache.Delete(pollID) 76 | if !deleted { 77 | return fmt.Errorf("poll data could not be purged from the database") 78 | } 79 | 80 | return nil 81 | } 82 | -------------------------------------------------------------------------------- /pkg/backend/users/repository.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "fmt" 5 | 6 | "go.vxn.dev/littr/pkg/backend/common" 7 | "go.vxn.dev/littr/pkg/backend/db" 8 | "go.vxn.dev/littr/pkg/models" 9 | ) 10 | 11 | // The implementation of pkg/models.UserRepositoryInterface. 12 | type UserRepository struct { 13 | cache db.Cacher 14 | } 15 | 16 | func NewUserRepository(cache db.Cacher) models.UserRepositoryInterface { 17 | if cache == nil { 18 | return nil 19 | } 20 | 21 | return &UserRepository{ 22 | cache: cache, 23 | } 24 | } 25 | 26 | func (r *UserRepository) GetAll() (*map[string]models.User, error) { 27 | rawUsers, count := r.cache.Range() 28 | if count == 0 { 29 | return nil, fmt.Errorf("no items found") 30 | } 31 | 32 | users := make(map[string]models.User) 33 | 34 | // Assert types to fetched interface map. 35 | for key, rawUser := range *rawUsers { 36 | user, ok := rawUser.(models.User) 37 | if !ok { 38 | return nil, fmt.Errorf("user's data corrupted") 39 | } 40 | 41 | users[key] = user 42 | } 43 | 44 | return &users, nil 45 | } 46 | 47 | func (r *UserRepository) GetByID(userID string) (*models.User, error) { 48 | // Fetch the user from the cache. 49 | rawUser, found := r.cache.Load(userID) 50 | if !found { 51 | return nil, fmt.Errorf(common.ERR_USER_NOT_FOUND) 52 | } 53 | 54 | // Assert the type 55 | user, ok := rawUser.(models.User) 56 | if !ok { 57 | return nil, fmt.Errorf(common.ERR_USER_DATA_CORRUPTED) 58 | } 59 | 60 | return &user, nil 61 | } 62 | 63 | func (r *UserRepository) Save(user *models.User) error { 64 | // Store the user using its key in the cache. 65 | saved := r.cache.Store(user.Nickname, *user) 66 | if !saved { 67 | return fmt.Errorf("an error occurred while saving a user") 68 | } 69 | 70 | return nil 71 | } 72 | 73 | func (r *UserRepository) Delete(userID string) error { 74 | // Simple user's deleting. 75 | deleted := r.cache.Delete(userID) 76 | if !deleted { 77 | return fmt.Errorf("user data could not be purged from the database") 78 | } 79 | 80 | return nil 81 | } 82 | -------------------------------------------------------------------------------- /pkg/backend/db/controllers.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "net/http" 5 | 6 | "go.vxn.dev/littr/pkg/backend/common" 7 | "go.vxn.dev/littr/pkg/config" 8 | ) 9 | 10 | type dumpController struct { 11 | db DatabaseKeeper 12 | } 13 | 14 | func NewDumpController(db DatabaseKeeper) *dumpController { 15 | return &dumpController{ 16 | db: db, 17 | } 18 | } 19 | 20 | // dumpHandler is the dv package controller function to process system data dump request. 21 | // 22 | // @Summary Perform system data dump 23 | // @Description This function call is used primarily by the healthcheck function inside the Docker compose stack to periodically dump running data into the JSON files. 24 | // @Tags dump 25 | // @Produce json 26 | // @Param X-Dump-Token header string true "A special app's dump token." 27 | // @Success 200 {object} common.APIResponse{data=models.Stub} "The dumping process was successful." 28 | // @Failure 400 {object} common.APIResponse{data=models.Stub} "Invalid input data (e.g. a blank token)." 29 | // @Failure 403 {object} common.APIResponse{data=models.Stub} "User unauthorized (e.g. invalid token)." 30 | // @Failure 429 {object} common.APIResponse{data=models.Stub} "Too many requests, try again later." 31 | // @Router /dump [get] 32 | func (c *dumpController) DumpAll(w http.ResponseWriter, r *http.Request) { 33 | l := common.NewLogger(r, "dumpController") 34 | 35 | // check the incoming API token 36 | token := r.Header.Get(common.HDR_DUMP_TOKEN) 37 | if token == "" { 38 | l.Msg(common.ERR_API_TOKEN_BLANK).Status(http.StatusBadRequest).Log().Payload(nil).Write(w) 39 | return 40 | } 41 | 42 | // validate the incoming token 43 | if token != config.DataDumpToken { 44 | l.Msg(common.ERR_API_TOKEN_INVALID).Status(http.StatusForbidden).Log().Payload(nil).Write(w) 45 | return 46 | } 47 | 48 | //go DumpAll() 49 | report, err := c.db.DumpAll() 50 | if err != nil { 51 | l.Error(err).Status(http.StatusInternalServerError).Log().Write(w) 52 | } else { 53 | l.Msg(report).Status(http.StatusOK).Log().Write(w) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/molecules/details.go: -------------------------------------------------------------------------------- 1 | package molecules 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "github.com/maxence-charriere/go-app/v10/pkg/app" 8 | "go.vxn.dev/littr/pkg/frontend/atomic/atoms" 9 | ) 10 | 11 | type Details struct { 12 | app.Compo 13 | 14 | Limit int 15 | 16 | Text string 17 | FormattedText string 18 | 19 | SpanID string 20 | OnClickSpanActionName string 21 | } 22 | 23 | func (d *Details) onClickText(ctx app.Context, e app.Event) { 24 | if d.SpanID == "" { 25 | return 26 | } 27 | 28 | ctx.NewActionWithValue(d.OnClickSpanActionName, d.SpanID) 29 | } 30 | 31 | func (d *Details) stripMarkup() string { 32 | // Match tags and extract content only 33 | tagRegex := regexp.MustCompile(`#(\w+)( [^$]*?)?#(.*?)##(\w+)#`) 34 | plainText := tagRegex.ReplaceAllString(d.FormattedText, "$3") // Keep only inner content 35 | 36 | // Trim excessive spaces 37 | return strings.TrimSpace(plainText) 38 | } 39 | 40 | func (d *Details) Render() app.UI { 41 | // Limited text summary. 42 | summaryBody := func() app.UI { 43 | if d.FormattedText != "" { 44 | if len(d.FormattedText) < d.Limit { 45 | return app.Text(d.FormattedText) 46 | } 47 | return app.Text(d.stripMarkup()[:d.Limit] + "...") 48 | } 49 | 50 | if len(d.Text) < d.Limit { 51 | return app.Text(d.Text) 52 | } 53 | 54 | return app.Text(d.Text[:d.Limit] + "...") 55 | } 56 | 57 | // Full text span body. 58 | spanBody := func() app.UI { 59 | if d.FormattedText != "" { 60 | return &atoms.Text{ 61 | FormattedText: d.FormattedText, 62 | } 63 | } 64 | 65 | return app.Text(d.Text) 66 | } 67 | 68 | return app.Details().Class("max").Body( 69 | app.Summary().Style("word-break", "break-word").Style("hyphens", "auto").Class("italic").Body( 70 | summaryBody(), 71 | app.I().Text("arrow_drop_down"), 72 | ), 73 | app.Div().Class("space"), 74 | app.Span().ID(d.SpanID).Class("").Style("word-break", "break-word").Style("hyphens", "auto").Style("white-space", "pre-line").OnClick(d.onClickText).Body(spanBody()), 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /pkg/backend/push/service.go: -------------------------------------------------------------------------------- 1 | package push 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/maxence-charriere/go-app/v10/pkg/app" 9 | "go.vxn.dev/littr/pkg/backend/common" 10 | "go.vxn.dev/littr/pkg/models" 11 | ) 12 | 13 | type notificationService struct { 14 | postRepository models.PostRepositoryInterface 15 | userRepository models.UserRepositoryInterface 16 | } 17 | 18 | func NewNotificationService( 19 | postRepository models.PostRepositoryInterface, 20 | userRepository models.UserRepositoryInterface, 21 | ) models.NotificationServiceInterface { 22 | 23 | if postRepository == nil || userRepository == nil { 24 | return nil 25 | } 26 | 27 | return ¬ificationService{ 28 | postRepository: postRepository, 29 | userRepository: userRepository, 30 | } 31 | } 32 | 33 | func (s *notificationService) SendNotification(ctx context.Context, postID string) error { 34 | // Fetch the callerID from the given context. 35 | callerID := common.GetCallerID(ctx) 36 | 37 | if postID == "" { 38 | return fmt.Errorf(common.ERR_POSTID_BLANK) 39 | } 40 | 41 | post, err := s.postRepository.GetByID(postID) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | user, err := s.userRepository.GetByID(post.Nickname) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | // Do not notify the same person --- OK condition. 52 | if post.Nickname == callerID { 53 | return nil 54 | } 55 | 56 | // Do not notify such user --- notifications disabled --- OK condition. 57 | if len(user.Devices) == 0 { 58 | return nil 59 | } 60 | 61 | // Compose the body of this notification. 62 | body, _ := json.Marshal(app.Notification{ 63 | Title: "littr reply", 64 | Icon: "/web/apple-touch-icon.png", 65 | Body: callerID + " replied to your post", 66 | Path: "/flow/posts/" + post.ID, 67 | }) 68 | 69 | opts := &NotificationOpts{ 70 | Receiver: post.Nickname, 71 | Devices: &user.Devices, 72 | Body: &body, 73 | Repo: s.userRepository, 74 | } 75 | 76 | // Send the webpush notification(s). 77 | SendNotificationToDevices(opts) 78 | 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /pkg/backend/stats/controller.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | import ( 4 | "net/http" 5 | 6 | "go.vxn.dev/littr/pkg/backend/common" 7 | "go.vxn.dev/littr/pkg/models" 8 | ) 9 | 10 | type StatController struct { 11 | statService models.StatServiceInterface 12 | } 13 | 14 | func NewStatController(statService models.StatServiceInterface) *StatController { 15 | if statService == nil { 16 | return nil 17 | } 18 | 19 | return &StatController{ 20 | statService: statService, 21 | } 22 | } 23 | 24 | // GetAll acts like a handler for stats page. 25 | // 26 | // @Summary Get stats 27 | // @Description This function call retrieves the system and users' statistics. 28 | // @Tags stats 29 | // @Produce json 30 | // @Success 200 {object} common.APIResponse{data=stats.GetAll.responseData} "Stats were calculated and are returned." 31 | // @Failure 400 {object} common.APIResponse{data=models.Stub} "Invalid called ID." 32 | // @Failure 500 {object} common.APIResponse{data=models.Stub} "A serious problem occurred while stats were being calculated." 33 | // @Router /stats [get] 34 | func (c *StatController) GetAll(w http.ResponseWriter, r *http.Request) { 35 | l := common.NewLogger(r, "stats") 36 | 37 | type responseData struct { 38 | FlowStats map[string]int64 `json:"flow_stats" example:"online:3,users:5"` 39 | UserStats map[string]models.UserStat `json:"user_stats"` 40 | Users map[string]models.User `json:"users"` 41 | } 42 | 43 | // Skip blank callerID. 44 | if l.CallerID() == "" { 45 | l.Msg(common.ERR_CALLER_BLANK).Status(http.StatusBadRequest).Log().Payload(nil).Write(w) 46 | return 47 | } 48 | 49 | flowStats, userStats, users, err := c.statService.Calculate(r.Context()) 50 | if err != nil { 51 | l.Msg(err.Error()).Status(http.StatusInternalServerError).Error(err).Log().Payload(nil).Write(w) 52 | return 53 | } 54 | 55 | pl := &responseData{ 56 | FlowStats: *flowStats, 57 | UserStats: *userStats, 58 | Users: *common.FlushUserData(users, l.CallerID()), 59 | } 60 | 61 | l.Msg("ok, dumping user and system stats").Status(http.StatusOK).Log().Payload(pl).Write(w) 62 | } 63 | -------------------------------------------------------------------------------- /pkg/backend/pages/polls.go: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "sort" 5 | 6 | "go.vxn.dev/littr/pkg/models" 7 | ) 8 | 9 | func onePagePolls(opts *PageOptions, data []interface{}) PagePointers { 10 | var ( 11 | allPolls *map[string]models.Poll 12 | polls = []models.Poll{} 13 | part []models.Poll 14 | ) 15 | 16 | defer func() { 17 | polls = []models.Poll{} 18 | part = []models.Poll{} 19 | }() 20 | 21 | for _, iface := range data { 22 | var ok bool 23 | 24 | allPolls, ok = iface.(*map[string]models.Poll) 25 | if ok { 26 | break 27 | } 28 | } 29 | 30 | if allPolls == nil { 31 | return PagePointers{} 32 | } 33 | 34 | // filter out all posts for such callerID 35 | for key, poll := range *allPolls { 36 | // check and correct the corresponding item's key 37 | if key != poll.ID { 38 | poll.ID = key 39 | } 40 | 41 | // check the caller's flow list, skip on unfollowed, or unknown user 42 | /*if value, found := flowList[poll.Author]; !found || !value { 43 | continue 44 | }*/ 45 | 46 | if poll.Private || poll.Hidden { 47 | continue 48 | } 49 | 50 | polls = append(polls, poll) 51 | } 52 | 53 | // order polls by timestamp DESC 54 | sort.SliceStable(polls, func(i, j int) bool { 55 | return polls[i].Timestamp.After(polls[j].Timestamp) 56 | }) 57 | 58 | // cut the PAGE_SIZE*2 number of posts only 59 | pageNo := opts.PageNo 60 | //start := (PAGE_SIZE * 2) * pageNo 61 | start := (PAGE_SIZE) * pageNo 62 | //end := (PAGE_SIZE * 2) * (pageNo + 1) 63 | end := (PAGE_SIZE) * (pageNo + 1) 64 | 65 | if len(polls) > start { 66 | // only valid for the very first page 67 | //part = posts[0:(pageSize * 2)] 68 | 69 | if len(polls) <= end { 70 | // the very last page 71 | part = polls[start:] 72 | } else { 73 | // the middle page 74 | part = polls[start:end] 75 | } 76 | } else { 77 | // the very single page 78 | part = polls 79 | } 80 | 81 | pExport := make(map[string]models.Poll) 82 | //uExport := make(map[string]models.User) 83 | 84 | for _, poll := range part { 85 | pExport[poll.ID] = poll 86 | } 87 | 88 | return PagePointers{Polls: &pExport} 89 | } 90 | -------------------------------------------------------------------------------- /pkg/backend/.crypto.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "io" 8 | "log" 9 | ) 10 | 11 | var ( 12 | EncryptionEnabled bool = false 13 | ) 14 | 15 | func Encrypt(key, text []byte) []byte { 16 | if !EncryptionEnabled { 17 | return text 18 | } 19 | 20 | // generate a new aes cipher using our 32 byte long key 21 | c, err := aes.NewCipher(key) 22 | // if there are any errors, handle them 23 | if err != nil { 24 | log.Println(err.Error()) 25 | } 26 | 27 | // gcm or Galois/Counter Mode, is a mode of operation 28 | // for symmetric key cryptographic block ciphers 29 | // - https://en.wikipedia.org/wiki/Galois/Counter_Mode 30 | gcm, err := cipher.NewGCM(c) 31 | // if any error generating new GCM 32 | // handle them 33 | if err != nil { 34 | log.Println(err.Error()) 35 | } 36 | 37 | // creates a new byte array the size of the nonce 38 | // which must be passed to Seal 39 | nonce := make([]byte, gcm.NonceSize()) 40 | // populates our nonce with a cryptographically secure 41 | // random sequence 42 | if _, err = io.ReadFull(rand.Reader, nonce); err != nil { 43 | log.Println(err.Error()) 44 | } 45 | 46 | // here we encrypt our text using the Seal function 47 | // Seal encrypts and authenticates plaintext, authenticates the 48 | // additional data and appends the result to dst, returning the updated 49 | // slice. The nonce must be NonceSize() bytes long and unique for all 50 | // time, for a given key. 51 | return gcm.Seal(nonce, nonce, text, nil) 52 | } 53 | 54 | func Decrypt(key, text []byte) []byte { 55 | if !EncryptionEnabled { 56 | return text 57 | } 58 | 59 | c, err := aes.NewCipher(key) 60 | if err != nil { 61 | log.Println(err.Error()) 62 | } 63 | 64 | gcm, err := cipher.NewGCM(c) 65 | if err != nil { 66 | log.Println(err.Error()) 67 | } 68 | 69 | nonceSize := gcm.NonceSize() 70 | if len(text) < nonceSize { 71 | log.Println(err.Error()) 72 | } 73 | 74 | nonce, text := text[:nonceSize], text[nonceSize:] 75 | plaintext, err := gcm.Open(nil, nonce, text, nil) 76 | if err != nil { 77 | log.Println(err.Error()) 78 | } 79 | 80 | return plaintext 81 | } 82 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/organisms/modal_user_logout.go: -------------------------------------------------------------------------------- 1 | package organisms 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | 6 | "go.vxn.dev/littr/pkg/frontend/atomic/atoms" 7 | "go.vxn.dev/littr/pkg/models" 8 | ) 9 | 10 | type ModalUserLogout struct { 11 | app.Compo 12 | 13 | User models.User 14 | 15 | ShowModal bool 16 | 17 | OnClickDismissActionName string 18 | OnClickLogoutActionName string 19 | OnClickFlowActionName string 20 | } 21 | 22 | func (m *ModalUserLogout) Render() app.UI { 23 | return app.Div().Body( 24 | app.If(m.ShowModal, func() app.UI { 25 | return app.Dialog().ID("logout-modal").Class("grey10 white-text active thicc").Body( 26 | &atoms.PageHeading{ 27 | Title: "user", 28 | Class: "max center-align", 29 | }, 30 | 31 | // User's avatar and nickname. 32 | app.Div().Class("row border thicc").Body( 33 | &atoms.Image{ 34 | ID: m.User.Nickname, 35 | Title: "user's flow link", 36 | Src: m.User.AvatarURL, 37 | Class: "responsive padding max", 38 | OnClickActionName: m.OnClickFlowActionName, 39 | Styles: map[string]string{"max-height": "100%", "max-width": "10rem", "border-radius": "50%"}, 40 | }, 41 | 42 | &atoms.Button{ 43 | ID: m.User.Nickname, 44 | Class: "max bold primary-container white-text right-margin thicc", 45 | Icon: "tsunami", 46 | Text: "Flow", 47 | OnClickActionName: m.OnClickFlowActionName, 48 | }, 49 | ), 50 | app.Div().Class("space"), 51 | 52 | app.Div().Class("row").Body( 53 | &atoms.Button{ 54 | Class: "max bold black white-text thicc", 55 | Icon: "close", 56 | Text: "Close", 57 | OnClickActionName: m.OnClickDismissActionName, 58 | }, 59 | 60 | &atoms.Button{ 61 | Class: "max primary-container white-text thicc", 62 | Icon: "logout", 63 | Text: "Log out", 64 | OnClickActionName: m.OnClickLogoutActionName, 65 | }, 66 | ), 67 | ) 68 | }), 69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /pkg/frontend/stats/content.go: -------------------------------------------------------------------------------- 1 | // The stats (app's statistics) view and view-controllers logic package. 2 | package stats 3 | 4 | import ( 5 | "go.vxn.dev/littr/pkg/frontend/common" 6 | "go.vxn.dev/littr/pkg/models" 7 | 8 | "github.com/maxence-charriere/go-app/v10/pkg/app" 9 | ) 10 | 11 | type Content struct { 12 | app.Compo 13 | 14 | flowStats map[string]int 15 | userStats map[string]models.UserStat 16 | 17 | nicknames []string 18 | 19 | users map[string]models.User 20 | 21 | //searchString string 22 | 23 | toast common.Toast 24 | 25 | loaderShow bool 26 | } 27 | 28 | func (c *Content) OnMount(ctx app.Context) { 29 | ctx.Handle("search", c.handleSearch) 30 | 31 | c.loaderShow = true 32 | } 33 | 34 | func (c *Content) OnNav(ctx app.Context) { 35 | if app.IsServer { 36 | return 37 | } 38 | 39 | toast := common.Toast{AppContext: &ctx} 40 | 41 | ctx.Async(func() { 42 | input := &common.CallInput{ 43 | Method: "GET", 44 | Url: "/api/v1/stats", 45 | Data: nil, 46 | CallerID: "", 47 | PageNo: 0, 48 | HideReplies: false, 49 | } 50 | 51 | type dataModel struct { 52 | FlowStats map[string]int `json:"flow_stats"` 53 | UserStats map[string]models.UserStat `json:"user_stats"` 54 | Users map[string]models.User `json:"users"` 55 | } 56 | 57 | output := &common.Response{Data: &dataModel{}} 58 | 59 | // fetch the stats 60 | if ok := common.FetchData(input, output); !ok { 61 | toast.Text(common.ERR_CANNOT_REACH_BE).Type(common.TTYPE_ERR).Dispatch() 62 | return 63 | } 64 | 65 | if output.Code == 401 { 66 | ctx.NewAction("logout") 67 | 68 | //toast.Text(common.ERR_LOGIN_AGAIN).Type(common.TTYPE_INFO).Link("/logout").Dispatch() 69 | return 70 | } 71 | 72 | if output.Code != 200 { 73 | toast.Text(output.Message).Type(common.TTYPE_ERR).Dispatch() 74 | return 75 | } 76 | 77 | data, ok := output.Data.(*dataModel) 78 | if !ok { 79 | toast.Text(common.ERR_CANNOT_GET_DATA).Type(common.TTYPE_ERR).Dispatch() 80 | return 81 | } 82 | 83 | ctx.Dispatch(func(ctx app.Context) { 84 | c.users = data.Users 85 | c.flowStats = data.FlowStats 86 | c.userStats = data.UserStats 87 | 88 | c.loaderShow = false 89 | }) 90 | }) 91 | } 92 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/atoms/button.go: -------------------------------------------------------------------------------- 1 | package atoms 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/maxence-charriere/go-app/v10/pkg/app" 7 | ) 8 | 9 | type Button struct { 10 | app.Compo 11 | 12 | BadgeText string 13 | Class string 14 | Color string 15 | ColorText string 16 | Icon string 17 | ID string 18 | Name string 19 | Text string 20 | Title string 21 | OnClickActionName string 22 | 23 | TabIndex int 24 | 25 | Aria map[string]string 26 | Attr map[string]string 27 | DataSet map[string]string 28 | 29 | Disabled bool 30 | ShowProgress bool 31 | 32 | OnClick app.EventHandler 33 | } 34 | 35 | func (b *Button) onClick(ctx app.Context, e app.Event) { 36 | if b.OnClick != nil { 37 | b.OnClick(ctx, e) 38 | return 39 | } 40 | 41 | ctx.Dispatch(func(ctx app.Context) { 42 | b.Disabled = true 43 | b.ShowProgress = true 44 | }) 45 | 46 | ctx.Defer(func(ctx app.Context) { 47 | ctx.NewActionWithValue(b.OnClickActionName, b.ID) 48 | }) 49 | } 50 | 51 | func (b *Button) composeClass() string { 52 | if b.Class != "" { 53 | return b.Class 54 | } 55 | 56 | return fmt.Sprintf("max shrink %s %s bold thicc", b.Color, b.ColorText) 57 | } 58 | 59 | func (b *Button) Render() app.UI { 60 | bt := app.Button() 61 | 62 | if b.TabIndex > 0 { 63 | bt.TabIndex(b.TabIndex) 64 | } 65 | 66 | for key, val := range b.Aria { 67 | bt.Aria(key, val) 68 | } 69 | 70 | for key, val := range b.Attr { 71 | bt.Attr(key, val) 72 | } 73 | 74 | for key, val := range b.DataSet { 75 | bt.DataSet(key, val) 76 | } 77 | 78 | return bt.ID(b.ID).Name(b.Name).Title(b.Title).Class(b.composeClass()).OnClick(b.onClick).Disabled(b.Disabled).Body( 79 | app.If(b.BadgeText != "" && b.BadgeText != "0", func() app.UI { 80 | return app.Div().Class("badge red-border border").Text(b.BadgeText) 81 | }), 82 | 83 | app.If(b.Disabled && b.ShowProgress, func() app.UI { 84 | return app.Progress().Class("circle white-border small") 85 | }), 86 | 87 | app.If(b.Text != "", func() app.UI { 88 | return app.Span().Body( 89 | app.I().Style("padding-right", "5px").Text(b.Icon), 90 | app.Text(b.Text), 91 | ) 92 | }).Else(func() app.UI { 93 | return app.I().Text(b.Icon) 94 | }), 95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/organisms/user_requests.go: -------------------------------------------------------------------------------- 1 | package organisms 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | 6 | "go.vxn.dev/littr/pkg/frontend/atomic/atoms" 7 | "go.vxn.dev/littr/pkg/models" 8 | ) 9 | 10 | type UserRequests struct { 11 | app.Compo 12 | 13 | LoggedUser models.User 14 | Users map[string]models.User 15 | 16 | OnClickAllowActionName string 17 | OnClickCancelActionName string 18 | OnClickUserActionName string 19 | OnMouseEnterActionName string 20 | OnMouseLeaveActionName string 21 | 22 | ButtonsDisabled bool 23 | } 24 | 25 | func (u *UserRequests) Render() app.UI { 26 | return app.Div().Class("post-feed").Body( 27 | app.Range(u.LoggedUser.RequestList).Map(func(key string) app.UI { 28 | if !u.LoggedUser.RequestList[key] { 29 | return nil 30 | } 31 | 32 | return app.Div().Class("post").Body( 33 | app.Div().Class("row medium top-padding").Body( 34 | &atoms.Image{ 35 | ID: key, 36 | Class: "responsive max left", 37 | Src: u.Users[key].AvatarURL, 38 | Styles: map[string]string{"max-width": "60px", "border-radius": "50%"}, 39 | }, 40 | 41 | &atoms.UserNickname{ 42 | Class: "primary-text bold max large-text", 43 | Nickname: key, 44 | SpanID: key, 45 | Title: "user's nickname", 46 | Text: key, 47 | OnClickActionName: u.OnClickUserActionName, 48 | OnMouseEnterActionName: u.OnMouseEnterActionName, 49 | OnMouseLeaveActionName: u.OnMouseLeaveActionName, 50 | }, 51 | 52 | &atoms.Button{ 53 | ID: key, 54 | Class: "max responsive no-padding bold grey10 white-text thicc", 55 | Icon: "close", 56 | Text: "Cancel", 57 | OnClickActionName: u.OnClickCancelActionName, 58 | Disabled: u.ButtonsDisabled, 59 | }, 60 | 61 | &atoms.Button{ 62 | ID: key, 63 | Class: "max responsive no-padding bold primary-container white-text thicc", 64 | Icon: "check", 65 | Text: "Allow", 66 | OnClickActionName: u.OnClickAllowActionName, 67 | Disabled: u.ButtonsDisabled, 68 | }, 69 | ), 70 | ) 71 | }), 72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /pkg/backend/users/types.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "go.vxn.dev/littr/pkg/models" 5 | ) 6 | 7 | type UserActivationRequest struct { 8 | UUID string `json:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"` 9 | } 10 | 11 | type UserCreateRequest struct { 12 | Email string `json:"email" example:"alice@example.com"` 13 | Nickname string `json:"nickname" example:"alice"` 14 | PassphrasePlain string `json:"passphrase_plain" example:"s3creTpassuWuort"` 15 | } 16 | 17 | type UserPagingRequest struct { 18 | PageNo int 19 | PagingSize int 20 | HideReplies bool 21 | } 22 | 23 | type UserPassphraseRequest struct { 24 | // Passphrase reset pre-request 25 | Email string `json:"email" example:"alice@example.com"` 26 | } 27 | 28 | type UserPassphraseReset struct { 29 | // Passphrase reset request 30 | UUID string `json:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"` 31 | } 32 | 33 | type UserUpdateListsRequest struct { 34 | // Lists update request payload. 35 | FlowList map[string]bool `json:"flow_list" example:"bob:false"` 36 | RequestList map[string]bool `json:"request_list" example:"cody:true"` 37 | ShadeList map[string]bool `json:"shade_list" example:"dave:true"` 38 | } 39 | 40 | type UserUpdateOptionsRequest struct { 41 | // Options updata request payload (legacy fields). 42 | UIMode bool `json:"ui_mode"` 43 | UITheme models.Theme `json:"ui_theme"` 44 | LiveMode bool `json:"live_mode"` 45 | LocalTimeMode bool `json:"local_time_mode"` 46 | Private bool `json:"private"` 47 | AboutText string `json:"about_you" example:"let's gooo"` 48 | WebsiteLink string `json:"website_link" example:"https://example.com"` 49 | OptionsMap models.UserOptionsMap `json:"options_map" example:"private:true"` 50 | } 51 | 52 | type UserUpdatePassphraseRequest struct { 53 | // New passphrase request payload. 54 | NewPassphrase string `json:"new_passphrase_plain"` 55 | CurrentPassphrase string `json:"current_passphrase_plain"` 56 | } 57 | 58 | type UserUpdateSubscriptionRequest []string 59 | 60 | type UserUploadAvatarRequest struct { 61 | // New avatar upload/update request payload. 62 | AvatarByteData []byte `json:"data" format:"base64"` 63 | AvatarFileName string `json:"figure" example:"avatar.jpeg"` 64 | } 65 | -------------------------------------------------------------------------------- /pkg/frontend/common/user.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "go.vxn.dev/littr/pkg/models" 9 | 10 | "github.com/maxence-charriere/go-app/v10/pkg/app" 11 | ) 12 | 13 | // LoadUser uses the app.Context pointer to load the encoded user string from the LocalStorage to decode it back to the models.User struct. 14 | func LoadUser(user *models.User, ctx *app.Context) error { 15 | var baseString string 16 | 17 | if err := (*ctx).LocalStorage().Get("user", &baseString); err != nil { 18 | return err 19 | } 20 | 21 | // Decode the user. 22 | str, err := base64.StdEncoding.DecodeString(baseString) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | // Unmarshal the result to get an User struct. 28 | err = json.Unmarshal(str, user) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | return nil 34 | } 35 | 36 | // SaveUser uses the app.Context pointer to save the given pointer to models.User and encode it into a JSON string. 37 | func SaveUser(user *models.User, ctx *app.Context) error { 38 | // Encode (marshal) the user model into JSON byte stream. 39 | userJSON, err := json.Marshal(user) 40 | if err != nil { 41 | return fmt.Errorf("%v", ErrLocalStorageUserSave) 42 | } 43 | 44 | // Save the encoded user data to the LocalStorage. 45 | if err := (*ctx).LocalStorage().Set("user", userJSON); err != nil { 46 | return err 47 | } 48 | 49 | if err := (*ctx).LocalStorage().Set("authGranted", true); err != nil { 50 | return err 51 | } 52 | 53 | return nil 54 | } 55 | 56 | // 57 | // Other attempts. 58 | // 59 | 60 | func LoadUser2(encoded string, user *models.User) error { 61 | if encoded == "" { 62 | return fmt.Errorf("string input is empty") 63 | } 64 | 65 | if user == nil { 66 | return fmt.Errorf("user pointer input is nil") 67 | } 68 | 69 | // beware base64 being used by the framework/browser 70 | decodedString, err := base64.StdEncoding.DecodeString(string(encoded)) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | // finally, unmarshal the byte stream into a model 76 | if err := json.Unmarshal(decodedString, user); err != nil { 77 | return err 78 | } 79 | 80 | return nil 81 | } 82 | 83 | func SaveUser2(plain *string, user *models.User) error { 84 | if plain == nil { 85 | return fmt.Errorf("string pointer input is empty") 86 | } 87 | 88 | if user == nil { 89 | return fmt.Errorf("user pointer input is nil") 90 | } 91 | 92 | return nil 93 | } 94 | -------------------------------------------------------------------------------- /pkg/frontend/settings/options.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/maxence-charriere/go-app/v10/pkg/app" 7 | 8 | "go.vxn.dev/littr/pkg/models" 9 | ) 10 | 11 | type optionsPayload struct { 12 | UIMode bool `json:"ui_mode"` 13 | UITheme models.Theme `json:"ui_theme"` 14 | LiveMode bool `json:"live_mode"` 15 | LocalTimeMode bool `json:"local_time_mode"` 16 | Private bool `json:"private"` 17 | AboutText string `json:"about_you"` 18 | WebsiteLink string `json:"website_link"` 19 | } 20 | 21 | func (c *Content) prefillPayload() optionsPayload { 22 | payload := optionsPayload{ 23 | UIMode: c.user.UIMode, 24 | UITheme: c.user.UITheme, 25 | LiveMode: c.user.Options["liveMode"], 26 | LocalTimeMode: c.user.Options["localTimeMode"], 27 | Private: c.user.Options["private"], 28 | AboutText: c.user.About, 29 | WebsiteLink: c.user.Web, 30 | } 31 | 32 | return payload 33 | } 34 | 35 | func (c *Content) updateOptions(payload optionsPayload) { 36 | if payload.UIMode != c.user.UIMode { 37 | body := app.Window().Get("document").Call("querySelector", "body") 38 | currentClass := body.Get("className").String() 39 | parts := strings.Split(currentClass, "-") 40 | 41 | mode := func() string { 42 | if len(parts) != 2 { 43 | return "light-orang" 44 | } 45 | 46 | if parts[0] == "light" { 47 | return "dark-" + parts[1] 48 | } 49 | 50 | return "light-" + parts[1] 51 | }() 52 | 53 | body.Set("className", mode) 54 | } 55 | 56 | if payload.UITheme != c.user.UITheme { 57 | body := app.Window().Get("document").Call("querySelector", "body") 58 | currentClass := body.Get("className").String() 59 | parts := strings.Split(currentClass, "-") 60 | 61 | // This is going to be replaced with switch soon. 62 | theme := func() string { 63 | if len(parts) != 2 { 64 | return "dark-orang" 65 | } 66 | 67 | if parts[1] == "blu" { 68 | return parts[0] + "-orang" 69 | } 70 | 71 | return parts[0] + "-blu" 72 | }() 73 | 74 | body.Set("className", theme) 75 | } 76 | 77 | c.user.UIMode = payload.UIMode 78 | c.user.UITheme = payload.UITheme 79 | c.user.Options["liveMode"] = payload.LiveMode 80 | c.user.Options["localTimeMode"] = payload.LocalTimeMode 81 | c.user.Options["private"] = payload.Private 82 | c.user.About = payload.AboutText 83 | c.user.Web = payload.WebsiteLink 84 | } 85 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/organisms/single_user_profile.go: -------------------------------------------------------------------------------- 1 | package organisms 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | 6 | "go.vxn.dev/littr/pkg/frontend/atomic/atoms" 7 | "go.vxn.dev/littr/pkg/frontend/atomic/molecules" 8 | "go.vxn.dev/littr/pkg/models" 9 | ) 10 | 11 | type SingleUserProfile struct { 12 | app.Compo 13 | 14 | LoggedUser models.User 15 | SingleUser models.User 16 | 17 | OnClickAskActionName string 18 | OnClickShadeActionName string 19 | OnClickCancelActionName string 20 | OnClickFollowActionName string 21 | OnClickUnfollowActionName string 22 | 23 | ButtonsDisabled bool 24 | } 25 | 26 | func (s *SingleUserProfile) Render() app.UI { 27 | var isInFlow, isRequested, isShaded, found bool 28 | 29 | if s.LoggedUser.FlowList != nil { 30 | if isInFlow, found = s.LoggedUser.FlowList[s.SingleUser.Nickname]; found && isInFlow { 31 | isInFlow = true 32 | } 33 | } 34 | 35 | if s.LoggedUser.ShadeList != nil { 36 | if isShaded, found = s.LoggedUser.ShadeList[s.SingleUser.Nickname]; found && isShaded { 37 | isShaded = true 38 | } 39 | } 40 | 41 | if s.SingleUser.RequestList != nil { 42 | if isRequested, found = s.SingleUser.RequestList[s.LoggedUser.Nickname]; !found { 43 | isRequested = false 44 | } 45 | } 46 | 47 | return app.Div().Body( 48 | app.If(s.SingleUser.Nickname != "", func() app.UI { 49 | return app.Div().Body( 50 | &atoms.Image{ 51 | Class: "center", 52 | Src: s.SingleUser.AvatarURL, 53 | Styles: map[string]string{"max-width": "15rem", "border-radius": "50%"}, 54 | }, 55 | 56 | &molecules.TextBox{ 57 | Class: "row border thicc", 58 | Icon: "", 59 | Text: s.SingleUser.About, 60 | }, 61 | app.Div().Class("space"), 62 | 63 | &molecules.UserFeedButtons{ 64 | LoggedUserNickname: s.LoggedUser.Nickname, 65 | User: s.SingleUser, 66 | 67 | IsInFlow: isInFlow, 68 | IsPrivate: s.SingleUser.Private, 69 | IsRequested: isRequested, 70 | IsShaded: isShaded, 71 | ButtonsDisabled: s.ButtonsDisabled, 72 | 73 | OnClickAskActionName: s.OnClickAskActionName, 74 | OnClickShadeActionName: s.OnClickShadeActionName, 75 | OnClickCancelActionName: s.OnClickCancelActionName, 76 | OnClickFollowActionName: s.OnClickFollowActionName, 77 | OnClickUnfollowActionName: s.OnClickUnfollowActionName, 78 | }, 79 | ) 80 | }), 81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /pkg/backend/pages/users.go: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "sort" 5 | 6 | "go.vxn.dev/littr/pkg/models" 7 | ) 8 | 9 | func onePageUsers(opts *PageOptions, data []interface{}) PagePointers { 10 | var ( 11 | allUsers *map[string]models.User 12 | users = []models.User{} 13 | caller = models.User{} 14 | part []models.User 15 | ) 16 | 17 | defer func() { 18 | users = []models.User{} 19 | part = []models.User{} 20 | }() 21 | 22 | for _, iface := range data { 23 | var ok bool 24 | 25 | allUsers, ok = iface.(*map[string]models.User) 26 | if ok { 27 | break 28 | } 29 | } 30 | 31 | if allUsers == nil { 32 | return PagePointers{} 33 | } 34 | 35 | for key, user := range *allUsers { 36 | // check and correct the corresponding item's key 37 | if key != user.Nickname { 38 | user.Nickname = key 39 | } 40 | 41 | if key == opts.CallerID { 42 | caller = user 43 | } 44 | 45 | users = append(users, user) 46 | } 47 | 48 | // sort by name 49 | sort.Slice(users, func(i, j int) bool { 50 | return users[i].Nickname < users[j].Nickname 51 | }) 52 | 53 | // cut the PAGE_SIZE number of posts only 54 | pageNo := func() int { 55 | if opts.PageNo < -1 { 56 | return 0 57 | } 58 | return opts.PageNo 59 | }() 60 | 61 | start := (PAGE_SIZE) * pageNo 62 | end := (PAGE_SIZE) * (pageNo + 1) 63 | 64 | if len(users) > start { 65 | // only valid for the very first page 66 | //part = posts[0:(pageSize * 2)] 67 | 68 | if len(users) <= end { 69 | // the very last page 70 | part = users[start:] 71 | } else { 72 | // the middle page 73 | part = users[start:end] 74 | } 75 | } else { 76 | // the very single page 77 | //part = users[len(users)-PAGE_SIZE-1 : len(users)-1] 78 | if len(users) > PAGE_SIZE*(pageNo-1) { 79 | if pageNo < 1 { 80 | part = users[0:] 81 | } else { 82 | part = users[PAGE_SIZE*(pageNo-1):] 83 | } 84 | } 85 | } 86 | 87 | if opts.Users.RequestList == nil { 88 | return PagePointers{} 89 | } 90 | 91 | // Add all (for now) users from the requestList to render properly at the top of the users page. 92 | for nick, requested := range *opts.Users.RequestList { 93 | if requested { 94 | part = append(part, (*allUsers)[nick]) 95 | } 96 | } 97 | 98 | uExport := make(map[string]models.User) 99 | 100 | for _, user := range part { 101 | uExport[user.Nickname] = user 102 | } 103 | 104 | uExport[opts.CallerID] = caller 105 | 106 | return PagePointers{Users: &uExport} 107 | } 108 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/molecules/post_footer.go: -------------------------------------------------------------------------------- 1 | package molecules 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | 6 | "go.vxn.dev/littr/pkg/frontend/atomic/atoms" 7 | "go.vxn.dev/littr/pkg/models" 8 | ) 9 | 10 | type PostFooter struct { 11 | app.Compo 12 | 13 | LoggedUserNickname string 14 | PostTimestamp string 15 | 16 | Post models.Post 17 | 18 | ButtonsDisabled bool 19 | 20 | OnClickDeleteActionName string 21 | OnClickStarActionName string 22 | OnClickReplyActionName string 23 | } 24 | 25 | func (p *PostFooter) Render() app.UI { 26 | // post footer (timestamp + reply buttom + star/delete button) 27 | return app.Div().Class("row").Body( 28 | app.Div().Class("max").Body( 29 | //app.Text(post.Timestamp.Format("Jan 02, 2006 / 15:04:05")), 30 | app.Text(p.PostTimestamp), 31 | ), 32 | 33 | app.If(p.Post.Nickname != "system", func() app.UI { 34 | return app.Div().Body( 35 | app.If(p.Post.ReplyCount > 0, func() app.UI { 36 | return app.B().Title("reply count").Text(p.Post.ReplyCount).Class("left-padding") 37 | }), 38 | 39 | &atoms.Button{ 40 | ID: p.Post.ID, 41 | Title: "reply", 42 | Class: "transparent circle", 43 | Icon: "reply", 44 | OnClickActionName: p.OnClickReplyActionName, 45 | Disabled: p.ButtonsDisabled, 46 | }, 47 | ) 48 | }), 49 | 50 | app.If(p.LoggedUserNickname == p.Post.Nickname, func() app.UI { 51 | return app.Div().Body( 52 | app.B().Title("reaction count").Text(p.Post.ReactionCount).Class("left-padding"), 53 | 54 | &atoms.Button{ 55 | ID: p.Post.ID, 56 | Title: "delete this post", 57 | Class: "transparent circle", 58 | Icon: "delete", 59 | OnClickActionName: p.OnClickDeleteActionName, 60 | Disabled: p.ButtonsDisabled, 61 | }, 62 | ) 63 | }).ElseIf(p.Post.Nickname == "system", func() app.UI { 64 | return app.Div() 65 | }).Else(func() app.UI { 66 | return app.Div().Body( 67 | app.B().Title("reaction count").Text(p.Post.ReactionCount).Class("left-padding"), 68 | 69 | &atoms.Button{ 70 | ID: p.Post.ID, 71 | Title: "increase the reaction count", 72 | Class: "transparent circle", 73 | Icon: "bomb", // snowflake = ac_unit 74 | OnClickActionName: p.OnClickStarActionName, 75 | Disabled: p.ButtonsDisabled, 76 | Attr: map[string]string{"touch-action": "none"}, 77 | }, 78 | ) 79 | }), 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/atoms/text.go: -------------------------------------------------------------------------------- 1 | package atoms 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/maxence-charriere/go-app/v10/pkg/app" 7 | ) 8 | 9 | type AssemblePayload struct { 10 | Tag string 11 | Attrs map[string]string 12 | Content string 13 | } 14 | 15 | type Text struct { 16 | app.Compo 17 | 18 | FormattedText string 19 | PlainText string 20 | Target string 21 | } 22 | 23 | var ( 24 | markRegex = regexp.MustCompile(`#(\w+)( [^$]*?)?#(.*?)##(\w+)#`) 25 | attrRegex = regexp.MustCompile(`(\w+)='(.*?)'`) 26 | ) 27 | 28 | func (t *Text) parseMarkupAndCompose() (elems []app.UI) { 29 | var lastIndex int 30 | 31 | if t.FormattedText == "" { 32 | elems = append(elems, app.Span().Text(t.PlainText)) 33 | return 34 | } 35 | 36 | for _, match := range markRegex.FindAllStringSubmatchIndex(t.FormattedText, -1) { 37 | start, end := match[0], match[1] 38 | 39 | if start > lastIndex { 40 | rawText := t.FormattedText[lastIndex:start] 41 | elems = append(elems, app.Text(rawText)) 42 | } 43 | 44 | tag := t.FormattedText[match[2]:match[3]] 45 | 46 | var ( 47 | attrs map[string]string 48 | attrString string 49 | content string 50 | ) 51 | 52 | if match[4] > 0 && match[5] > 0 { 53 | attrString = t.FormattedText[match[4]:match[5]] 54 | } 55 | 56 | content = t.FormattedText[match[6]:match[7]] 57 | 58 | // Parse attributes 59 | attrs = make(map[string]string) 60 | for _, attr := range attrRegex.FindAllStringSubmatch(attrString, -1) { 61 | attrs[attr[1]] = attr[2] 62 | } 63 | 64 | var compo app.UI 65 | 66 | switch tag { 67 | case "bold": 68 | compo = app.B().Class(attrs["class"]).Text(content) 69 | 70 | case "break": 71 | compo = app.Br().Class(attrs["class"]) 72 | 73 | case "icon": 74 | compo = app.I().Text(content) 75 | 76 | case "link": 77 | compo = app.A().Target(attrs["target"]).Href(attrs["to"]).Class(attrs["class"]).Text(content) 78 | 79 | default: 80 | compo = app.Span().Text(content) 81 | } 82 | 83 | elems = append(elems, compo) 84 | lastIndex = end 85 | } 86 | 87 | if lastIndex < len(t.FormattedText) { 88 | elems = append(elems, app.Text(t.FormattedText[lastIndex:])) 89 | } 90 | 91 | return elems 92 | } 93 | 94 | func (t *Text) Render() app.UI { 95 | if t.FormattedText == "" { 96 | return app.P().Class("max").Text(t.PlainText).Style("word-break", "break-word").Style("hyphens", "auto").Style("white-space", "pre-line") 97 | } 98 | 99 | return app.P().Class("max").Body(t.parseMarkupAndCompose()...).Style("word-break", "break-word").Style("hyphens", "auto").Style("white-space", "pre-line") 100 | } 101 | -------------------------------------------------------------------------------- /deployments/docker-compose-test.yml: -------------------------------------------------------------------------------- 1 | #version: '3.9' 2 | name: ${PROJECT_NAME} 3 | 4 | networks: 5 | traefik: 6 | name: ${DOCKER_NETWORK_NAME} 7 | 8 | volumes: 9 | littr-data: 10 | name: ${DOCKER_VOLUME_DATA_NAME} 11 | littr-pix: 12 | name: ${DOCKER_VOLUME_PIX_NAME} 13 | 14 | services: 15 | littr-backend-test: 16 | image: ${DOCKER_IMAGE_TAG} 17 | container_name: ${DOCKER_CONTAINER_NAME} 18 | env_file: 19 | - ../.env 20 | build: 21 | context: .. 22 | dockerfile: build/Dockerfile 23 | args: 24 | APP_NAME: ${APP_NAME} 25 | APP_PEPPER: ${APP_PEPPER} 26 | APP_VERSION: ${APP_VERSION} 27 | DOCKER_INTERNAL_PORT: ${DOCKER_INTERNAL_PORT} 28 | DOCKER_USER: ${DOCKER_USER} 29 | OARCH: ${GOARCH} 30 | GOCACHE: ${GOCACHE} 31 | GOMODCACHE: ${GOMODCACHE} 32 | GOLANG_VERSION: ${GOLANG_VERSION} 33 | TZ: ${TZ} 34 | VAPID_PUB_KEY: ${VAPID_PUB_KEY} 35 | restart: unless-stopped 36 | cpus: 0.3 37 | mem_limit: 128m 38 | mem_reservation: 32m 39 | volumes: 40 | - "littr-data:/opt/data" 41 | - "littr-pix:/opt/pix" 42 | ports: 43 | - "${DOCKER_EXTERNAL_PORT}:${DOCKER_INTERNAL_PORT}" 44 | networks: 45 | - traefik 46 | labels: 47 | - "traefik.http.routers.${APP_NAME}.rule=Host(${APP_URLS_TRAEFIK})" 48 | - "traefik.http.services.${APP_NAME}.loadbalancer.server.port=${DOCKER_INTERNAL_PORT}" 49 | - "traefik.docker.network=${DOCKER_NETWORK_NAME}" 50 | environment: 51 | API_TOKEN: ${API_TOKEN} 52 | APP_PEPPER: ${APP_PEPPER} 53 | TZ: ${TZ} 54 | VAPID_PUB_KEY: ${VAPID_PUB_KEY} 55 | healthcheck: 56 | test: ["CMD", "wget", "--header", "X-Dump-Token: ${API_TOKEN}", "localhost:${DOCKER_INTERNAL_PORT}/api/v1/dump/", "-O", "-", "-S" ] 57 | interval: 5m 58 | timeout: 5s 59 | retries: 3 60 | 61 | littr-swagger-test: 62 | image: swaggerapi/swagger-ui 63 | container_name: ${DOCKER_SWAGGER_CONTAINER_NAME} 64 | ports: 65 | - target: 8080 66 | published: ${DOCKER_SWAGGER_EXTERNAL_PORT} 67 | mode: host 68 | protocol: tcp 69 | environment: 70 | BASE_URL: "/docs/" 71 | BASE_PATH: "/api" 72 | SWAGGER_JSON: "/tmp/swagger.json" 73 | networks: 74 | - traefik 75 | volumes: 76 | - "../api/swagger.json:/tmp/swagger.json" 77 | labels: 78 | - "traefik.http.routers.${APP_NAME}-swagger.rule=Host(${APP_URLS_TRAEFIK}) && PathPrefix(`/docs`)" 79 | - "traefik.http.services.${APP_NAME}-swagger.loadbalancer.server.port=8080" 80 | #- "traefik.http.middlewares.${APP_NAME}.stripprefix.prefixes=/docs" 81 | - "traefik.docker.network=${DOCKER_NETWORK_NAME}" 82 | 83 | -------------------------------------------------------------------------------- /pkg/backend/pages/service.go: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "go.vxn.dev/littr/pkg/backend/common" 8 | ) 9 | 10 | var ( 11 | errCorruptedData = errors.New("data corrupted") 12 | errEmptyDataOutput = errors.New("no data to send") 13 | errInvalidPayload = errors.New("invalid payload was received") 14 | errNotImplementedYet = errors.New("method not implemented yet") 15 | errUnknownOriginNotAccepted = errors.New("request origin not specified") 16 | ) 17 | 18 | type pagingService struct { 19 | // 20 | } 21 | 22 | func NewPagingService() *pagingService { 23 | return &pagingService{} 24 | } 25 | 26 | func (s *pagingService) GetOne(ctx context.Context, options interface{}, data ...interface{}) (interface{}, error) { 27 | opts, ok := options.(*PageOptions) 28 | if !ok { 29 | return nil, errInvalidPayload 30 | } 31 | 32 | callerID := common.GetCallerID(ctx) 33 | opts.CallerID = callerID 34 | 35 | if opts.Flow != (FlowOptions{}) { 36 | return onePagePosts(opts, data...), nil 37 | } 38 | 39 | if opts.Polls != (PollOptions{}) { 40 | return onePagePolls(opts, data), nil 41 | } 42 | 43 | if opts.Users != (UserOptions{}) { 44 | return onePageUsers(opts, data), nil 45 | } 46 | // 47 | // 48 | // 49 | 50 | /*var data []interface{} 51 | 52 | for _, cache := range opts.Caches { 53 | switch cache.GetName() { 54 | case "FlowCache": 55 | genericMap, _ := cache.Range() 56 | 57 | m := make(map[string]models.Post) 58 | 59 | for key, valI := range *genericMap { 60 | val, ok := valI.(models.Post) 61 | if !ok { 62 | continue 63 | } 64 | 65 | m[key] = val 66 | } 67 | 68 | data = append(data, &m) 69 | 70 | case "UserCache": 71 | genericMap, _ := cache.Range() 72 | 73 | m := make(map[string]models.User) 74 | 75 | for key, valI := range *genericMap { 76 | val, ok := valI.(models.User) 77 | if !ok { 78 | continue 79 | } 80 | 81 | m[key] = val 82 | } 83 | 84 | data = append(data, &m) 85 | } 86 | } 87 | 88 | if data == nil || len(data) == 0 { 89 | // invalid input options = resulted in empty maps only 90 | return nil, errEmptyDataOutput 91 | } 92 | 93 | if opts.Flow != (FlowOptions{}) { 94 | return onePagePosts(opts, data), nil 95 | } 96 | 97 | /*if opts.Polls != (PollOptions{}) { 98 | return onePagePolls(opts, data), nil 99 | } 100 | 101 | if opts.Users != (UserOptions{}) { 102 | return onePageUsers(opts, data), nil 103 | }*/ 104 | 105 | return nil, nil 106 | } 107 | 108 | func (s *pagingService) GetMany(ctx context.Context, options interface{}) (interface{}, error) { 109 | return nil, errNotImplementedYet 110 | } 111 | -------------------------------------------------------------------------------- /pkg/frontend/common/event.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "strings" 7 | 8 | "go.vxn.dev/littr/pkg/models" 9 | //"github.com/maxence-charriere/go-app/v10/pkg/app" 10 | ) 11 | 12 | type Event struct { 13 | LastEventID string 14 | Type string 15 | Data string 16 | } 17 | 18 | func (e *Event) Dump() string { 19 | return fmt.Sprintf("Type:\t%s\nData:\t%s", e.Type, e.Data) 20 | } 21 | 22 | func NewSSEEvent(input string) *Event { 23 | // Read again and associate fields. 24 | r := strings.NewReader(input) 25 | b := bufio.NewReader(r) 26 | 27 | event := &Event{} 28 | 29 | if len(strings.Split(input, "\n")) >= 3 { 30 | for i := 0; i < 3; i++ { 31 | lineB, err := b.ReadSlice(byte('\n')) 32 | if err != nil { 33 | fmt.Println(err.Error()) 34 | continue 35 | } 36 | 37 | // Split the event string by ':', trim spaces at the extremites, and join the second field back together. 38 | parts := strings.Split(string(lineB), ":") 39 | parts[1] = strings.TrimSpace(parts[1]) 40 | line := strings.Join(parts[1:], " ") 41 | 42 | // Associate the line to event's fields. 43 | switch i { 44 | case 0: 45 | event.LastEventID = line 46 | case 1: 47 | event.Type = line 48 | case 2: 49 | event.Data = line 50 | } 51 | } 52 | } 53 | return event 54 | } 55 | 56 | func (e *Event) ParseEventData(user *models.User) (text, link string, keep bool) { 57 | // 58 | // Parse the event data 59 | // 60 | 61 | if e.Data == "heartbeat" { 62 | return 63 | } 64 | 65 | if !user.LiveMode || !user.Options["liveMode"] { 66 | return 67 | } 68 | 69 | // Explode the data CSV string. 70 | slice := strings.Split(e.Data, ",") 71 | 72 | switch slice[0] { 73 | // Server is stopping, being stopped, restarting etc. 74 | case "server-stop": 75 | text = MSG_SERVER_RESTART 76 | 77 | // Server is booting up (just started). 78 | case "server-start": 79 | text = MSG_SERVER_START 80 | keep = true 81 | 82 | // New post added. 83 | case "post": 84 | author := slice[1] 85 | if author == user.Nickname { 86 | return 87 | } 88 | 89 | // Exit when the author is not followed, nor is found in the user's flowList. 90 | if user != nil { 91 | if flowed, found := user.FlowList[author]; !flowed || !found { 92 | return 93 | } 94 | } 95 | 96 | keep = true 97 | 98 | // Notify the user via toast. 99 | text = fmt.Sprintf(MSG_NEW_POST, author) 100 | 101 | // New poll added. 102 | case "poll": 103 | pollID := slice[1] 104 | if pollID == "" { 105 | link = "/polls" 106 | } else { 107 | 108 | link = "/polls/" + pollID 109 | } 110 | text = MSG_NEW_POLL 111 | } 112 | 113 | return 114 | } 115 | -------------------------------------------------------------------------------- /pkg/frontend/login/content.go: -------------------------------------------------------------------------------- 1 | // The login view and view-controllers logic package. 2 | package login 3 | 4 | import ( 5 | "log" 6 | "strings" 7 | 8 | "go.vxn.dev/littr/pkg/frontend/common" 9 | 10 | "github.com/maxence-charriere/go-app/v10/pkg/app" 11 | ) 12 | 13 | type Content struct { 14 | app.Compo 15 | 16 | nickname string 17 | passphrase string 18 | 19 | toast common.Toast 20 | 21 | loginButtonDisabled bool 22 | 23 | //keyDownEventListener func() 24 | 25 | activationUUID string 26 | } 27 | 28 | func (c *Content) OnMount(ctx app.Context) { 29 | ctx.Handle("dismiss", c.handleDismiss) 30 | 31 | //c.keyDownEventListener = app.Window().AddEventListener("keydown", c.onKeyDown) 32 | } 33 | 34 | func (c *Content) handleSuccess(ctx *app.Context, t string) { 35 | toast := common.Toast{AppContext: ctx} 36 | 37 | switch t { 38 | case "registration": 39 | toast.Text(common.MSG_REGISTER_SUCCESS).Type(common.TTYPE_SUCCESS).Dispatch() 40 | case "reset": 41 | toast.Text(common.MSG_RESET_PASSPHRASE_SUCCESS).Type(common.TTYPE_SUCCESS).Dispatch() 42 | default: 43 | return 44 | } 45 | } 46 | 47 | func (c *Content) OnNav(ctx app.Context) { 48 | if app.IsServer { 49 | return 50 | } 51 | 52 | url := strings.Split(ctx.Page().URL().Path, "/") 53 | 54 | var activationUUID string 55 | 56 | log.Print("halo") 57 | 58 | // Look if we got the right path format and content = parse the URL. 59 | if len(url) > 2 && url[2] != "" { 60 | switch url[1] { 61 | case "activation": 62 | activationUUID = url[2] 63 | case "success": 64 | c.handleSuccess(&ctx, url[2]) 65 | return 66 | default: 67 | return 68 | } 69 | } else { 70 | return 71 | } 72 | 73 | log.Print("halo2") 74 | 75 | toast := common.Toast{AppContext: &ctx} 76 | 77 | ctx.Async(func() { 78 | ctx.Dispatch(func(ctx app.Context) { 79 | c.activationUUID = activationUUID 80 | }) 81 | 82 | payload := struct { 83 | UUID string `json:"uuid"` 84 | }{ 85 | UUID: activationUUID, 86 | } 87 | 88 | // Compose the API call input. 89 | input := &common.CallInput{ 90 | Method: "POST", 91 | Url: "/api/v1/users/activation", 92 | Data: payload, 93 | } 94 | 95 | output := &common.Response{} 96 | 97 | // Call the API to fetch the data. 98 | if ok := common.FetchData(input, output); !ok { 99 | toast.Text(common.ERR_CANNOT_REACH_BE).Type(common.TTYPE_ERR).Dispatch() 100 | return 101 | } 102 | 103 | // Something went wrong... 104 | if output.Code != 200 { 105 | toast.Text(output.Message).Type(common.TTYPE_ERR).Dispatch() 106 | return 107 | } 108 | 109 | // User successfully activated. 110 | toast.Text(common.MSG_USER_ACTIVATED).Type(common.TTYPE_SUCCESS).Dispatch() 111 | }) 112 | } 113 | -------------------------------------------------------------------------------- /pkg/frontend/polls/render.go: -------------------------------------------------------------------------------- 1 | package polls 2 | 3 | import ( 4 | "sort" 5 | 6 | "go.vxn.dev/littr/pkg/frontend/atomic/atoms" 7 | "go.vxn.dev/littr/pkg/frontend/atomic/organisms" 8 | "go.vxn.dev/littr/pkg/models" 9 | 10 | "github.com/maxence-charriere/go-app/v10/pkg/app" 11 | ) 12 | 13 | func (c *Content) sortPolls() []models.Poll { 14 | var sortedPolls []models.Poll 15 | 16 | for _, sortedPoll := range c.polls { 17 | sortedPolls = append(sortedPolls, sortedPoll) 18 | } 19 | 20 | // order polls by timestamp DESC 21 | sort.SliceStable(sortedPolls, func(i, j int) bool { 22 | return sortedPolls[i].Timestamp.After(sortedPolls[j].Timestamp) 23 | }) 24 | 25 | // prepare polls according to the actual pagination and pageNo 26 | pagedPolls := []models.Poll{} 27 | 28 | end := len(sortedPolls) 29 | start := 0 30 | 31 | stop := func(c *Content) int { 32 | var pos int 33 | 34 | if c.pagination > 0 { 35 | // (c.pageNo - 1) * c.pagination + c.pagination 36 | pos = c.pageNo * c.pagination 37 | } 38 | 39 | if pos > end { 40 | // kill the eventListener (observers scrolling) 41 | //c.scrollEventListener() 42 | c.paginationEnd = true 43 | 44 | return (end) 45 | } 46 | 47 | if pos < 0 { 48 | return 0 49 | } 50 | 51 | return pos 52 | }(c) 53 | 54 | if end > 0 && stop > 0 { 55 | pagedPolls = sortedPolls[start:stop] 56 | } 57 | 58 | return pagedPolls 59 | } 60 | 61 | func (c *Content) Render() app.UI { 62 | return app.Main().Class("responsive").Body( 63 | &atoms.PageHeading{ 64 | Title: "polls", 65 | }, 66 | 67 | // Poll deletion modal. 68 | &organisms.ModalPollDelete{ 69 | PollID: c.polls[c.interactedPollKey].ID, 70 | ModalShow: c.deletePollModalShow, 71 | ModalButtonsDisabled: c.deleteModalButtonsDisabled, 72 | OnClickDismissActionName: "dismiss", 73 | OnClickDeleteActionName: "delete", 74 | }, 75 | 76 | // The very polls feed. 77 | &organisms.PollFeed{ 78 | LoggedUser: c.user, 79 | 80 | SortedPolls: c.sortPolls(), 81 | 82 | Pagination: c.pagination, 83 | PageNo: c.pageNo, 84 | 85 | ButtonsDisabled: c.pollsButtonDisabled, 86 | LoaderShowImage: c.loaderShow, 87 | 88 | OnClickOptionOneActionName: "option-one-click", 89 | OnClickOptionTwoActionName: "option-two-click", 90 | OnClickOptionThreeActionName: "option-three-click", 91 | 92 | OnClickDeleteModalShowActionName: "delete-click", 93 | OnClickLinkActionName: "link", 94 | OnMouseEnterActionName: "mouse-enter", 95 | OnMouseLeaveActionName: "mouse-leave", 96 | }, 97 | 98 | &atoms.Loader{ 99 | ID: "page-end-anchor", 100 | ShowLoader: c.loaderShow, 101 | }, 102 | ) 103 | } 104 | -------------------------------------------------------------------------------- /pkg/backend/requests/repository.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "fmt" 5 | 6 | //"go.vxn.dev/littr/pkg/backend/common" 7 | "go.vxn.dev/littr/pkg/backend/db" 8 | //"go.vxn.dev/littr/pkg/backend/pages" 9 | "go.vxn.dev/littr/pkg/models" 10 | ) 11 | 12 | // The implementation of pkg/models.RequestRepositoryInterface. 13 | type RequestRepository struct { 14 | cache db.Cacher 15 | } 16 | 17 | func NewRequestRepository(cache db.Cacher) models.RequestRepositoryInterface { 18 | if cache == nil { 19 | return nil 20 | } 21 | 22 | return &RequestRepository{ 23 | cache: cache, 24 | } 25 | } 26 | 27 | /*func (r *RequestRepository) GetAll(pageOpts interface{}) (*map[string]models.Request, error) { 28 | // Assert type for pageOptions. 29 | opts, ok := pageOpts.(*pages.PageOptions) 30 | if !ok { 31 | return nil, fmt.Errorf("cannot read the page options at the repository level") 32 | } 33 | 34 | // Fetch page according to the calling user (in options). 35 | pagePtrs := pages.GetOnePage(*opts) 36 | if pagePtrs == (pages.PagePointers{}) || pagePtrs.Requests == nil || (*pagePtrs.Requests) == nil { 37 | return nil, fmt.Errorf(common.ERR_PAGE_EXPORT_NIL) 38 | } 39 | 40 | // If zero items were fetched, no need to continue asserting types. 41 | if len(*pagePtrs.Requests) == 0 { 42 | return nil, fmt.Errorf("no requests found in the database") 43 | } 44 | 45 | return pagePtrs.Requests, nil 46 | 47 | }*/ 48 | 49 | // GetRequestByID is a static function to export to other services. 50 | func GetRequestByID(requestID string, cache db.Cacher) (*models.Request, error) { 51 | if requestID == "" || cache == nil { 52 | return nil, fmt.Errorf("requestID is blank, or cache is nil") 53 | } 54 | 55 | // Fetch the request from the cache. 56 | reqRaw, found := cache.Load(requestID) 57 | if !found { 58 | return nil, fmt.Errorf("request not found") 59 | } 60 | 61 | request, ok := reqRaw.(models.Request) 62 | if !ok { 63 | return nil, fmt.Errorf("could not assert type *models.Request") 64 | } 65 | 66 | return &request, nil 67 | } 68 | 69 | func (r *RequestRepository) GetByID(requestID string) (*models.Request, error) { 70 | // Use the static function to get such request. 71 | request, err := GetRequestByID(requestID, r.cache) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | return request, nil 77 | } 78 | 79 | func (r *RequestRepository) Save(request *models.Request) error { 80 | // Store the request using its key in the cache. 81 | saved := r.cache.Store(request.ID, *request) 82 | if !saved { 83 | return fmt.Errorf("an error occurred while saving a request") 84 | } 85 | 86 | return nil 87 | } 88 | 89 | func (r *RequestRepository) Delete(requestID string) error { 90 | // Simple request's deletion. 91 | deleted := r.cache.Delete(requestID) 92 | if !deleted { 93 | return fmt.Errorf("request data could not be purged from the database") 94 | } 95 | 96 | return nil 97 | } 98 | -------------------------------------------------------------------------------- /pkg/frontend/common/.sse.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | //"context" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | //"os" 9 | //"os/signal" 10 | //"syscall" 11 | //"time" 12 | 13 | //"go.vxn.dev/littr/pkg/config" 14 | 15 | "github.com/maxence-charriere/go-app/v10/pkg/app" 16 | "github.com/tmaxmax/go-sse" 17 | ) 18 | 19 | // Default response validator. 20 | // https://pkg.go.dev/github.com/tmaxmax/go-sse@v0.8.0#ResponseValidator 21 | var DefaultValidator sse.ResponseValidator = func(r *http.Response) error { 22 | if r.StatusCode != http.StatusOK { 23 | return fmt.Errorf("expected status code %d %s, received %d %s", http.StatusOK, http.StatusText(http.StatusOK), r.StatusCode, http.StatusText(r.StatusCode)) 24 | } 25 | cts := r.Header.Get("Content-Type") 26 | //ct := contentType(cts) 27 | if expected := "text/event-stream"; cts != expected { 28 | return fmt.Errorf("expected content type to have %q, received %q", expected, cts) 29 | } 30 | return nil 31 | } 32 | 33 | // Noop response validator. 34 | // https://pkg.go.dev/github.com/tmaxmax/go-sse@v0.8.0#ResponseValidator 35 | var NoopValidator sse.ResponseValidator = func(_ *http.Response) error { 36 | return nil 37 | } 38 | 39 | // An example on how to encode topic subscriptions. 40 | func getRequestURL(sub string) string { 41 | q := url.Values{} 42 | switch sub { 43 | case "all": 44 | q.Add("topic", "numbers") 45 | q.Add("topic", "metrics") 46 | case "numbers", "metrics": 47 | q.Set("topic", sub) 48 | default: 49 | panic(fmt.Errorf("unexpected subscription topic %q", sub)) 50 | } 51 | 52 | return URL + "/api/v1/live?" + q.Encode() 53 | } 54 | 55 | // URL is a simple lambda function to retrieve the URL for a new SSE connection. 56 | var URL = func() string { 57 | // Use APP_URL_MAIN env variables in prod and staging environments. 58 | if app.Getenv("APP_URL_MAIN") != "" { 59 | return "https://" + app.Getenv("APP_URL_MAIN") 60 | } 61 | 62 | // Local development use only. 63 | return "http://localhost:8080" 64 | }() 65 | 66 | /*func sSEClient() { 67 | // Prepare the context for the client shutdown. 68 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 69 | defer cancel() 70 | 71 | sub := "all" 72 | 73 | // Compose a new connection with the context. 74 | req, _ := http.NewRequestWithContext(ctx, http.MethodGet, getRequestURL(sub), http.NoBody) 75 | _ = sse.NewConnection(req) 76 | }*/ 77 | 78 | // Subscribe to any event, regardless the type. 79 | /*conn.SubscribeToAll(func(event sse.Event) { 80 | ctx.NewActionWithValue("generic-event", event) 81 | 82 | // Print all events. 83 | fmt.Printf("%s: %s\n", event.Type, event.Data) 84 | 85 | /*switch event.Type { 86 | case "keepalive", "ops": 87 | fmt.Printf("%s: %s\n", event.Type, event.Data) 88 | case "server-stop": 89 | fmt.Println("server closed!") 90 | h.sseCancel() 91 | default: // no event name*/ 92 | //} 93 | //}) 94 | 95 | // 96 | // 97 | // 98 | -------------------------------------------------------------------------------- /pkg/frontend/welcome/render.go: -------------------------------------------------------------------------------- 1 | package welcome 2 | 3 | import ( 4 | //"go.vxn.dev/littr/pkg/frontend/common" 5 | 6 | "github.com/maxence-charriere/go-app/v10/pkg/app" 7 | ) 8 | 9 | func (c *Content) Render() app.UI { 10 | return app.Main().Class("responsive").Body( 11 | app.Article().Body( 12 | app.Div().Class("").Body( 13 | app.Div().Class("row center-align").Body( 14 | app.Img().Src("/web/android-chrome-512x512.png").Style("max-width", "10em"), 15 | app.H4().Body( 16 | app.Span().Body( 17 | app.Text("littr"), 18 | ), 19 | ), 20 | ), 21 | app.Div().Class("space"), 22 | app.H6().Class("margin-bottom center-align").Body( 23 | app.Span().Body( 24 | app.Text("A simple nanoblogging platform."), 25 | ), 26 | ), 27 | ), 28 | 29 | app.Div().Class("no-margin large-padding").Body( 30 | //app.I().Text("lightbulb").Class("amber-text"), 31 | app.P().Class().Body( 32 | app.Span().Class("primary-text").Text("Welcome to "), 33 | app.Span().Class("primary-text bold").Text("littr"), 34 | app.Span().Class("primary-text").Text("! "), 35 | app.Span().Text("This site acts as a simple platform for anyone who likes to post short notes, messages, daydreaming ideas and more! You can use it as a personal journal charting your journey through life that can be shared with other user accounts."), 36 | ), 37 | //app.Div().Class("small-space"), 38 | 39 | app.P().Class().Body( 40 | app.Span().Text("The very main page of this platform is called just "), 41 | app.Span().Class("primary-text bold").Text("flow"), 42 | app.Span().Text(". This page lists all your posts in reverse chronological order (newest to oldest) plus posts from other folks/accounts that you have added to your flow (that you are following)."), 43 | ), 44 | //app.Div().Class("small-space"), 45 | 46 | app.P().Class().Body( 47 | app.Span().Text("To navigate to the "), 48 | app.Span().Class("bold").Text("login"), 49 | app.Span().Text(" page (where the link to "), 50 | app.Span().Class("bold").Text("registration"), 51 | app.Span().Text(" sits as well), use the "), 52 | app.I().Class("small").Class("primary-text").Body( 53 | app.Text("key_vertical"), 54 | ), 55 | app.Span().Text(" button in the upper right corner."), 56 | ), 57 | ), 58 | ), 59 | 60 | /*app.Article().Class("center-align center").Body( 61 | app.H6().Class("margin-bottom center-align").Body( 62 | app.Span().Body( 63 | app.Text("flow page"), 64 | ), 65 | ), 66 | app.Div().Class("space"), 67 | 68 | app.Div().Style("z-index", "5").Class("medium no-padding center-align").Body( 69 | app.Img().Class("center-align bottom lazy").Src("https://krusty.space/littr_flow_new_post_live_v0.30.17.jpg").Style("max-width", "100%").Style("max-height", "100%").Attr("loading", "lazy"), 70 | ), 71 | ),*/ 72 | 73 | app.Div().Class("medium-space"), 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /pkg/frontend/settings/texts.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | const ( 4 | AlertUserDeletion = "Please note that this action is irreversible!" 5 | 6 | FormatUserInfo = "Logged as: #bold class='primary-text'#%s##bold# #break###break#E-mail: #bold class='primary-text'#%s##bold#" 7 | 8 | InfoAboutYouTextarea = "This textarea is to hold your current status, a brief info about you, or just anything up to 100 characters." 9 | InfoWebsiteLink = "Down below, you can enter a link to your personal homepage. The link will then be visible to others via the user modal on the users (flowers) page." 10 | InfoGravatarLinking = "Your avatar can be linked to your e-mail address. In such case, your e-mail address needs to be registered with the #link class='bold' to='http://gravatar.com/profile/avatars'#Gravatar.com##link#.#break###break# #break###break#Note: if you just changed your icon at Gravatar.com, and the thumbnail above shows the old avatar, some intercepting cache probably has the resource cached (e.g. your browser). You may need to wait for some time for the change to propagate through the network." 11 | InfoUIMode = "#bold class='blue-text'#The UI mode##bold# can be adjusted according to the user's choice. The setting will be applied to all devices connected via your account.#break###break# #break###break# #bold class='blue-text'#UI theme##bold# is a color scheme that is used to render this application for you." 12 | InfoLocalTimeMode = "#bold class='blue-text'#The local time mode##bold# is a feature allowing you to see any post's (or poll's) timestamp according to your device's setting (mainly the timezone). When disabled, the server time is used instead." 13 | InfoLiveMode = "#bold class='blue-text'#The live mode##bold# is a feature for the live flow experience. When enabled, a notice about some followed account's/user's new post is shown on the bottom of the page." 14 | InfoPrivateMode = "#bold class='blue-text'#Private account##bold# is a feature allowing one to be hidden on the site. When enabled, other accounts/users need to ask you to follow you (the follow request will show on the users page). Any reply to your post will be shown as redacted (a private content notice) to those not following you." 15 | InfoNotifications = "#bold class='blue-text'#Reply##bold# notifications are fired when someone posts a reply to your post. #break###break##bold class='blue-text'#Mention##bold# notifications are fired when someone mentions you via the at-sign (@) handler in their post (e.g. Hello, @example!).#break###break# #break###break#You will be prompted for the notification permission, which is required if you want to subscribe to the notification service. Your device's UUID (unique identification string) will be saved in the database to be used by the notification service. You can delete any subscribed device any time (if listed below)." 16 | InfoSubscribedDevice = "#bold#%s##bold##break###break# #break###break#Subsctibed to: %v#break###break#Registered: %s" 17 | ) 18 | -------------------------------------------------------------------------------- /pkg/models/service.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "context" 5 | 6 | gomail "github.com/wneessen/go-mail" 7 | ) 8 | 9 | // 10 | // Service interfaces 11 | // 12 | 13 | type AuthServiceInterface interface { 14 | Auth(ctx context.Context, user interface{}) (*User, []string, error) 15 | Logout(ctx context.Context) error 16 | } 17 | 18 | type MailServiceInterface interface { 19 | ComposeMail(payload interface{}) (*gomail.Msg, error) 20 | SendMail(msg *gomail.Msg) error 21 | } 22 | 23 | type NotificationServiceInterface interface { 24 | SendNotification(ctx context.Context, postID string) error 25 | } 26 | 27 | type PagingServiceInterface interface { 28 | GetOne(ctx context.Context, options interface{}, data ...interface{}) (interface{}, error) 29 | GetMany(ctx context.Context, options interface{}) (interface{}, error) 30 | } 31 | 32 | type PollServiceInterface interface { 33 | Create(ctx context.Context, createRequest interface{}) error 34 | Update(ctx context.Context, updateRequest interface{}) error 35 | Delete(ctx context.Context, pollID string) error 36 | FindAll(ctx context.Context, pageOpts interface{}) (*map[string]Poll, *User, error) 37 | FindByID(ctx context.Context, pollID string) (*Poll, *User, error) 38 | } 39 | 40 | type PostServiceInterface interface { 41 | Create(ctx context.Context, post *Post) error 42 | Update(ctx context.Context, post *Post) error 43 | Delete(ctx context.Context, postID string) error 44 | FindAll(ctx context.Context, pageOpts interface{}) (*map[string]Post, *map[string]User, error) 45 | //FindPage(ctx context.Context, opts interface{}) (*map[string]Post, *map[string]User, error) 46 | FindByID(ctx context.Context, postID string) (*Post, *User, error) 47 | } 48 | 49 | type StatServiceInterface interface { 50 | Calculate(ctx context.Context) (*map[string]int64, *map[string]UserStat, *map[string]User, error) 51 | } 52 | 53 | type TokenServiceInterface interface { 54 | Create(ctx context.Context, user *User) ([]string, error) 55 | Delete(ctx context.Context, tokenID string) error 56 | FindByID(ctx context.Context, tokenID string) (*Token, error) 57 | } 58 | 59 | type UserServiceInterface interface { 60 | Create(ctx context.Context, createRequest interface{}) error 61 | Subscribe(ctx context.Context, device *Device) error 62 | Unsubscribe(ctx context.Context, uuid string) error 63 | Activate(ctx context.Context, userID string) error 64 | Update(ctx context.Context, userID, reqType string, updateRequest interface{}) error 65 | UpdateAvatar(ctx context.Context, updateRequest interface{}) (*string, error) 66 | UpdateSubscriptionTags(ctx context.Context, uuid string, tags []string) error 67 | ProcessPassphraseRequest(ctx context.Context, reqType string, updateRequest interface{}) error 68 | Delete(ctx context.Context, userID string) error 69 | FindAll(ctx context.Context, pageOpts interface{}) (*map[string]User, error) 70 | FindByID(ctx context.Context, userID string) (*User, error) 71 | FindPostsByID(ctx context.Context, userID string, pageOpts interface{}) (*map[string]Post, *map[string]User, error) 72 | } 73 | -------------------------------------------------------------------------------- /cmd/littr/app_handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "github.com/maxence-charriere/go-app/v10/pkg/app" 8 | "go.vxn.dev/littr/pkg/config" 9 | ) 10 | 11 | // appHandler holds the pointer to the very main FE app handler. 12 | var appHandler = &app.Handler{ 13 | Name: "littr nanoblogger", 14 | ShortName: "littr", 15 | Title: "littr nanoblogger", 16 | Description: "A simple nanoblogging platform", 17 | Author: "krusty", 18 | Domain: config.ServerUrl, 19 | BackgroundColor: "#000000", 20 | ThemeColor: "#000000", 21 | LoadingLabel: "loading...", 22 | WasmContentLengthHeader: "X-Uncompressed-Content-Length", 23 | Lang: "en", 24 | Keywords: []string{ 25 | "blog", 26 | "blogging", 27 | "board", 28 | "go-app", 29 | "microblog", 30 | "microblogging", 31 | "nanoblog", 32 | "nanoblogging", 33 | "platform", 34 | "social network", 35 | }, 36 | Icon: app.Icon{ 37 | Maskable: "/web/android-chrome-192x192.png", 38 | Default: "/web/android-chrome-192x192.png", 39 | SVG: "/web/android-chrome-512x512.svg", 40 | Large: "/web/android-chrome-512x512.png", 41 | //AppleTouch: "/web/apple-touch-icon.png", 42 | }, 43 | Image: "/web/android-chrome-512x512.png", 44 | // Ensure the default light theme is dark. 45 | Body: func() app.HTMLBody { 46 | return app.Body().Class("") 47 | }, 48 | Version: os.Getenv("APP_VERSION") + "-" + time.Now().Format("2006-01-02_15:04:05"), 49 | // Environment constants to be transferred to the app context. 50 | Env: map[string]string{ 51 | "APP_ENVIRONMENT": os.Getenv("APP_ENVIRONMENT"), 52 | "APP_URL_MAIN": os.Getenv("APP_URL_MAIN"), 53 | "APP_VERSION": os.Getenv("APP_VERSION"), 54 | "REGISTRATION_ENABLED": os.Getenv("REGISTRATION_ENABLED"), 55 | "VAPID_PUB_KEY": os.Getenv("VAPID_PUB_KEY"), 56 | }, 57 | 58 | Preconnect: []string{ 59 | //"https://cdn.vxn.dev/", 60 | }, 61 | // Web fonts. 62 | Fonts: []string{ 63 | "https://cdn.vxn.dev/css/material-symbols-outlined.woff2", 64 | //"https://cdn.jsdelivr.net/npm/beercss@3.5.0/dist/cdn/material-symbols-outlined.woff2", 65 | }, 66 | // CSS styles files. 67 | Styles: []string{ 68 | //"https://cdn.vxn.dev/css/beercss-3.7.0-custom.min.css", 69 | "https://cdn.vxn.dev/css/beercss-3.9.7-custom.min.css", 70 | "https://cdn.vxn.dev/css/sortable.min.css", 71 | "/web/littr.css", 72 | }, 73 | // JS script files. 74 | Scripts: []string{ 75 | "https://cdn.vxn.dev/js/beer-3.9.7.min.js", 76 | //"https://cdn.jsdelivr.net/npm/beercss@3.7.0/dist/cdn/beer.min.js", 77 | "https://cdn.vxn.dev/js/material-dynamic-colors.min.js", 78 | //"https://cdn.jsdelivr.net/npm/material-dynamic-colors@1.1.2/dist/cdn/material-dynamic-colors.min.js", 79 | "https://cdn.vxn.dev/js/sortable.min.js", 80 | "https://cdn.vxn.dev/js/eventsource.min.js", 81 | "/web/littr.js", 82 | //"https://cdn.vxn.dev/js/littr.js", 83 | }, 84 | ServiceWorkerTemplate: config.EnchartedSW, 85 | } 86 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/molecules/image_input.go: -------------------------------------------------------------------------------- 1 | package molecules 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | 6 | "go.vxn.dev/littr/pkg/frontend/common" 7 | ) 8 | 9 | type ImageInput struct { 10 | app.Compo 11 | 12 | ImageData *[]byte 13 | ImageFile *string 14 | ImageLink *string 15 | 16 | ButtonsDisabled *bool 17 | 18 | LocalStorageFileName string 19 | LocalStorageDataName string 20 | } 21 | 22 | // https://github.com/maxence-charriere/go-app/issues/882 23 | func (i *ImageInput) onImageInput(ctx app.Context, e app.Event) { 24 | file := e.Get("target").Get("files").Index(0) 25 | 26 | //log.Println("name", file.Get("name").String()) 27 | //log.Println("size", file.Get("size").Int()) 28 | //log.Println("type", file.Get("type").String()) 29 | 30 | *i.ButtonsDisabled = true 31 | 32 | toast := common.Toast{AppContext: &ctx} 33 | 34 | ctx.Async(func() { 35 | defer ctx.Dispatch(func(ctx app.Context) { 36 | *i.ButtonsDisabled = false 37 | }) 38 | 39 | var ( 40 | data []byte 41 | err error 42 | processedImg *[]byte 43 | ) 44 | 45 | // Read the figure/image data. 46 | data, err = common.ReadFile(file) 47 | if err != nil { 48 | toast.Text(err.Error()).Type(common.TTYPE_ERR).Dispatch() 49 | return 50 | } 51 | 52 | processedImg, err = common.ProcessImage(&data) 53 | if err != nil { 54 | toast.Text(err.Error()).Type(common.TTYPE_ERR).Dispatch() 55 | return 56 | } 57 | 58 | // Load the image data to the Content structure. 59 | ctx.Dispatch(func(ctx app.Context) { 60 | *i.ImageFile = file.Get("name").String() 61 | *i.ImageData = *processedImg 62 | 63 | // Save the figure data in LS as a backup. 64 | if err := ctx.LocalStorage().Set(i.LocalStorageFileName, file.Get("name").String()); err != nil { 65 | toast.Text(common.ErrLocalStorageUserSave).Type(common.TTYPE_ERR).Dispatch() 66 | return 67 | } 68 | if err := ctx.LocalStorage().Set(i.LocalStorageDataName, *processedImg); err != nil { 69 | toast.Text(common.ErrLocalStorageUserSave).Type(common.TTYPE_ERR).Dispatch() 70 | return 71 | } 72 | }) 73 | 74 | // Cast the image ready message. 75 | toast.Text(common.MSG_IMAGE_READY).Type(common.TTYPE_INFO).Dispatch() 76 | }) 77 | } 78 | 79 | /*func (i *ImageInput) onImageInput(ctx app.Context, e app.Event) { 80 | ctx.NewActionWithValue(i.OnImageUploadActionName, e.Get("id").String()) 81 | }*/ 82 | 83 | func (i *ImageInput) OnMount(ctx app.Context) { 84 | if i.ImageLink == nil { 85 | i.ImageLink = new(string) 86 | } 87 | } 88 | 89 | func (i *ImageInput) Render() app.UI { 90 | return app.Div().Class("field label border extra primary-text thicc").Body( 91 | app.Input().ID("fig-upload").Class("active").Type("file").OnInput(i.onImageInput).Accept("image/*"), 92 | app.Input().Class("active").Type("text").Value(*i.ImageFile).Disabled(true), 93 | app.Label().Text("Image").Class("active primary-text"), 94 | 95 | app.If(*i.ButtonsDisabled, func() app.UI { 96 | return app.Progress().Class("circle primary-border small") 97 | }).Else(func() app.UI { 98 | return app.I().Text("image") 99 | }), 100 | ) 101 | 102 | } 103 | -------------------------------------------------------------------------------- /pkg/frontend/post/content.go: -------------------------------------------------------------------------------- 1 | // The post (new flow post, or new poll) view and view-controllers logic package. 2 | package post 3 | 4 | import ( 5 | //"fmt" 6 | "encoding/base64" 7 | 8 | "go.vxn.dev/littr/pkg/frontend/common" 9 | 10 | "github.com/maxence-charriere/go-app/v10/pkg/app" 11 | ) 12 | 13 | type Content struct { 14 | app.Compo 15 | 16 | newPost string 17 | newFigLink string 18 | newFigFile string 19 | newFigData []byte 20 | 21 | pollQuestion string 22 | pollOptionI string 23 | pollOptionII string 24 | pollOptionIII string 25 | 26 | toast common.Toast 27 | 28 | postButtonsDisabled bool 29 | } 30 | 31 | func (c *Content) OnMount(ctx app.Context) { 32 | if app.IsServer { 33 | return 34 | } 35 | 36 | // Always focus the post textarea when the post.Content component is mounted. 37 | app.Window().Get("document").Call("getElementById", "post-textarea").Call("focus") 38 | 39 | //c.keyDownEventListener = app.Window().AddEventListener("keydown", c.onKeyDown) 40 | ctx.Handle("dismiss", c.handleDismiss) 41 | ctx.Handle("send-poll", c.handlePostPoll) 42 | ctx.Handle("send-post", c.handlePostPoll) 43 | 44 | /*app.Window().Call("addEventListener", "keydown", app.FuncOf(func(this app.Value, args []app.Value) any { 45 | key := args[0].Get("key") 46 | 47 | ctx.NewActionWithKey("keydown", key) 48 | }))*/ 49 | 50 | /*app.Window().Call("addEventListener", "beforeunload", app.FuncOf(func(this app.Value, args []app.Value) any { 51 | // https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event 52 | if !args[0].IsNull() { 53 | args[0].Call("preventDefault") 54 | } 55 | 56 | fmt.Println("beforeunload event") 57 | 58 | var textareaValue string 59 | 60 | textarea := app.Window().GetElementByID("post-textarea") 61 | if !textarea.IsNull() { 62 | textareaValue = textarea.Get("value").String() 63 | } 64 | 65 | // Save the post draft to localStorage. 66 | if c.newPost != "" || textareaValue != "" { 67 | ctx.LocalStorage().Set("draftNewPost", textareaValue) 68 | } 69 | 70 | return nil 71 | }))*/ 72 | 73 | /*app.Window().Call("addEventListener", "visibilitychange", app.FuncOf(func(this app.Value, args []app.Value) any { 74 | //event := args[0] 75 | 76 | if app.Window().Get("document").Get("visibilityState").String() == "hidden" { 77 | var textareaValue string 78 | 79 | textarea := app.Window().GetElementByID("post-textarea") 80 | if !textarea.IsNull() { 81 | textareaValue = textarea.Get("value").String() 82 | } 83 | 84 | // Save the post draft to localStorage. 85 | if textareaValue != "" { 86 | ctx.LocalStorage().Set("draftNewPost", textareaValue) 87 | } 88 | } 89 | return nil 90 | }))*/ 91 | 92 | // Load the saved draft from localStorage. 93 | if err := ctx.LocalStorage().Get("newPostDraft", &c.newPost); err != nil { 94 | return 95 | } 96 | if err := ctx.LocalStorage().Get("newPostFigFile", &c.newFigFile); err != nil { 97 | return 98 | } 99 | 100 | var data string 101 | if err := ctx.LocalStorage().Get("newPostFigData", &data); err != nil { 102 | return 103 | } 104 | 105 | c.newFigData, _ = base64.StdEncoding.DecodeString(data) 106 | } 107 | -------------------------------------------------------------------------------- /pkg/backend/push/repository.go: -------------------------------------------------------------------------------- 1 | package push 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | //"go.vxn.dev/littr/pkg/backend/common" 8 | "go.vxn.dev/littr/pkg/backend/db" 9 | //"go.vxn.dev/littr/pkg/backend/pages" 10 | "go.vxn.dev/littr/pkg/models" 11 | ) 12 | 13 | // The implementation of pkg/models.SubscriptionRepositoryInterface. 14 | type SubscriptionRepository struct { 15 | cache db.Cacher 16 | } 17 | 18 | func NewSubscriptionRepository(cache db.Cacher) models.SubscriptionRepositoryInterface { 19 | if cache == nil { 20 | return nil 21 | } 22 | 23 | return &SubscriptionRepository{ 24 | cache: cache, 25 | } 26 | } 27 | 28 | /*func (r *SubscriptionRepository) GetAll(pageOpts interface{}) (*map[string]models.Subscription, error) { 29 | // Assert type for pageOptions. 30 | opts, ok := pageOpts.(*pages.PageOptions) 31 | if !ok { 32 | return nil, fmt.Errorf("cannot read the page options at the repository level") 33 | } 34 | 35 | // Fetch page according to the calling user (in options). 36 | pagePtrs := pages.GetOnePage(*opts) 37 | if pagePtrs == (pages.PagePointers{}) || pagePtrs.Subscriptions == nil || (*pagePtrs.Subscriptions) == nil { 38 | return nil, fmt.Errorf(common.ERR_PAGE_EXPORT_NIL) 39 | } 40 | 41 | // If zero items were fetched, no need to continue asserting types. 42 | if len(*pagePtrs.Subscriptions) == 0 { 43 | return nil, fmt.Errorf("no subscriptions found in the database") 44 | } 45 | 46 | return pagePtrs.Subscriptions, nil 47 | 48 | }*/ 49 | 50 | var ( 51 | ErrSubscriptionNotFound error = errors.New("could not find requested Subscription") 52 | ) 53 | 54 | // GetSubscriptionByID is a static function to export to other services. 55 | func GetSubscriptionByID(userID string, cache db.Cacher) (*[]models.Device, error) { 56 | if userID == "" || cache == nil { 57 | return nil, fmt.Errorf("subscriptionID is blank, or cache is nil") 58 | } 59 | 60 | // Fetch the subscription from the cache. 61 | tokRaw, found := cache.Load(userID) 62 | if !found { 63 | return nil, ErrSubscriptionNotFound 64 | } 65 | 66 | subscription, ok := tokRaw.([]models.Device) 67 | if !ok { 68 | return nil, fmt.Errorf("could not assert type *models.Subscription") 69 | } 70 | 71 | return &subscription, nil 72 | } 73 | 74 | func (r *SubscriptionRepository) GetByUserID(userID string) (*[]models.Device, error) { 75 | // Use the static function to get such subscription. 76 | subscription, err := GetSubscriptionByID(userID, r.cache) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | return subscription, nil 82 | } 83 | 84 | func (r *SubscriptionRepository) Save(userID string, subscription *[]models.Device) error { 85 | // Store the subscription using its key in the cache. 86 | saved := r.cache.Store(userID, *subscription) 87 | if !saved { 88 | return fmt.Errorf("an error occurred while saving a subscription") 89 | } 90 | 91 | return nil 92 | } 93 | 94 | func (r *SubscriptionRepository) Delete(userID string) error { 95 | // Simple subscription's deletion. 96 | deleted := r.cache.Delete(userID) 97 | if !deleted { 98 | return fmt.Errorf("subscription data could not be purged from the database") 99 | } 100 | 101 | return nil 102 | } 103 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/organisms/modal_user_info.go: -------------------------------------------------------------------------------- 1 | package organisms 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/maxence-charriere/go-app/v10/pkg/app" 7 | 8 | "go.vxn.dev/littr/pkg/frontend/atomic/atoms" 9 | "go.vxn.dev/littr/pkg/models" 10 | ) 11 | 12 | type ModalUserInfo struct { 13 | app.Compo 14 | 15 | User models.User 16 | Users map[string]models.User 17 | ShowModal bool 18 | 19 | OnClickDismissActionName string 20 | OnClickUserFlowActionName string 21 | 22 | userRegisteredTime string 23 | userLastActiveTime string 24 | } 25 | 26 | func (m *ModalUserInfo) processTimestamps() { 27 | if m.User.Nickname != "" { 28 | registeredTime := m.User.RegisteredTime 29 | lastActiveTime := m.User.LastActiveTime 30 | 31 | registered := app.Window(). 32 | Get("Date"). 33 | New(registeredTime.Format(time.RFC3339)) 34 | 35 | lastActive := app.Window(). 36 | Get("Date"). 37 | New(lastActiveTime.Format(time.RFC3339)) 38 | 39 | m.userRegisteredTime = registered.Call("toLocaleString", "en-GB").String() 40 | m.userLastActiveTime = lastActive.Call("toLocaleString", "en-GB").String() 41 | } 42 | } 43 | 44 | func (m *ModalUserInfo) Render() app.UI { 45 | m.processTimestamps() 46 | 47 | return app.Div().Body( 48 | app.If(m.ShowModal, func() app.UI { 49 | return app.Dialog().ID("user-modal").Class("grey10 white-text center-align active thicc").Style("max-width", "90%").Body( 50 | 51 | &atoms.Image{ 52 | Class: "", 53 | Src: m.User.AvatarURL, 54 | Styles: map[string]string{"max-width": "120px", "border-radius": "50%"}, 55 | }, 56 | 57 | app.Div().Class("row center-align").Body( 58 | app.H5().Class().Body( 59 | app.A().Href("/flow/users/"+m.User.Nickname).Text(m.User.Nickname), 60 | ), 61 | 62 | app.If(m.User.Web != "", func() app.UI { 63 | return app.A().Href(m.User.Web).Body( 64 | app.Span().Class("bold").Body( 65 | app.I().Text("captive_portal"), 66 | ), 67 | ) 68 | }), 69 | ), 70 | 71 | app.If(m.User.About != "", func() app.UI { 72 | return app.Article().Class("center-align white-text border thicc").Style("word-break", "break-word").Style("hyphens", "auto").Text(m.User.About) 73 | }), 74 | 75 | app.Article().Class("white-text border left-align thicc").Body( 76 | app.P().Class("bold").Text("Registered"), 77 | app.P().Class().Text(m.userRegisteredTime), 78 | 79 | app.P().Class("bold").Text("Last online"), 80 | app.P().Class().Text(m.userLastActiveTime), 81 | ), 82 | 83 | //app.Div().Class("large-space"), 84 | app.Div().Class("row center-align").Body( 85 | &atoms.Button{ 86 | Class: "max black white-text thicc", 87 | Icon: "close", 88 | Text: "Close", 89 | OnClickActionName: m.OnClickDismissActionName, 90 | }, 91 | 92 | &atoms.Button{ 93 | ID: m.User.Nickname, 94 | Class: "max primary-container white-text thicc", 95 | Icon: "tsunami", 96 | Text: "Flow", 97 | OnClickActionName: m.OnClickUserFlowActionName, 98 | }, 99 | ), 100 | ) 101 | }), 102 | ) 103 | } 104 | -------------------------------------------------------------------------------- /pkg/frontend/atomic/organisms/modal_app_info.go: -------------------------------------------------------------------------------- 1 | package organisms 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | 6 | "go.vxn.dev/littr/pkg/frontend/atomic/atoms" 7 | ) 8 | 9 | type ModalAppInfo struct { 10 | app.Compo 11 | 12 | ShowModal bool 13 | 14 | SseConnectionStatus string 15 | 16 | OnClickDismissActionName string 17 | OnClickReloadActionName string 18 | } 19 | 20 | func (m *ModalAppInfo) Render() app.UI { 21 | return app.Div().Body( 22 | app.If(m.ShowModal, func() app.UI { 23 | return app.Dialog().ID("info-modal").Class("grey10 white-text center-align active thicc").Body( 24 | app.Article().Class("row white-text center-align border thicc").Body( 25 | 26 | &atoms.Image{ 27 | Styles: map[string]string{"max-width": "10em"}, 28 | Src: "/web/android-chrome-512x512.png", 29 | }, 30 | 31 | app.H4().Body( 32 | app.Span().Body( 33 | app.Text("littr"), 34 | app.If(app.Getenv("APP_ENVIRONMENT") != "prod", func() app.UI { 35 | return app.Span().Class("col").Body( 36 | app.Sup().Body( 37 | app.If(app.Getenv("APP_ENVIRONMENT") == "stage", func() app.UI { 38 | return app.Text(" (stage) ") 39 | }).Else(func() app.UI { 40 | return app.Text(" (dev) ") 41 | }), 42 | ), 43 | ) 44 | }), 45 | ), 46 | ), 47 | ), 48 | 49 | app.Article().Class("center-align large-text border thicc").Body( 50 | app.P().Body( 51 | app.A().Class("primary-text bold").Href("/tos").Text("Terms of Service"), 52 | ), 53 | app.P().Body( 54 | app.A().Class("primary-text bold").Href("https://krusty.space/projects/littr").Text("Documentation (external)"), 55 | ), 56 | ), 57 | 58 | app.Article().Class("center-align white-text border thicc").Body( 59 | app.Text("Version: "), 60 | app.A().Text(app.Getenv("APP_VERSION")).Href("https://github.com/krustowski/littr").Style("font-weight", "bolder"), 61 | app.P().Body( 62 | app.Text("SSE status: "), 63 | app.If(m.SseConnectionStatus == "connected", func() app.UI { 64 | return app.Span().ID("heartbeat-info-text").Text(m.SseConnectionStatus).Class("green-text bold") 65 | }).Else(func() app.UI { 66 | return app.Span().ID("heartbeat-info-text").Text(m.SseConnectionStatus).Class("amber-text bold") 67 | }), 68 | ), 69 | ), 70 | 71 | app.Nav().Class("center-align").Body( 72 | app.P().Body( 73 | app.Text("Powered by "), 74 | app.A().Href("https://go-app.dev/").Text("go-app").Style("font-weight", "bolder"), 75 | app.Text(" & "), 76 | app.A().Href("https://www.beercss.com/").Text("beercss").Style("font-weight", "bolder"), 77 | ), 78 | ), 79 | 80 | app.Div().Class("row").Body( 81 | &atoms.Button{ 82 | Class: "max bold black white-text thicc", 83 | Icon: "close", 84 | Text: "Close", 85 | OnClickActionName: m.OnClickDismissActionName, 86 | }, 87 | 88 | &atoms.Button{ 89 | Class: "max bold primary-container white-text thicc", 90 | Icon: "refresh", 91 | Text: "Reload", 92 | OnClickActionName: m.OnClickReloadActionName, 93 | }, 94 | ), 95 | ) 96 | }), 97 | ) 98 | } 99 | -------------------------------------------------------------------------------- /pkg/frontend/post/render.go: -------------------------------------------------------------------------------- 1 | package post 2 | 3 | import ( 4 | "github.com/maxence-charriere/go-app/v10/pkg/app" 5 | "go.vxn.dev/littr/pkg/frontend/atomic/atoms" 6 | "go.vxn.dev/littr/pkg/frontend/atomic/molecules" 7 | ) 8 | 9 | func (c *Content) Render() app.UI { 10 | return app.Main().Class("responsive").Body( 11 | 12 | // 13 | // New post 14 | // 15 | 16 | &atoms.PageHeading{ 17 | Title: "new post", 18 | }, 19 | 20 | &atoms.Textarea{ 21 | ID: "post-textarea", 22 | Class: "field textarea label border extra primary-text thicc", 23 | ContentPointer: &c.newPost, 24 | Name: "newPost", 25 | LabelText: "Content", 26 | OnBlurActionName: "blur-post", 27 | }, 28 | 29 | &molecules.ImageInput{ 30 | ImageData: &c.newFigData, 31 | ImageFile: &c.newFigFile, 32 | ImageLink: &c.newFigLink, 33 | ButtonsDisabled: &c.postButtonsDisabled, 34 | LocalStorageFileName: "newPostImageFile", 35 | LocalStorageDataName: "newPostImageData", 36 | }, 37 | 38 | // New post button. 39 | &atoms.Button{ 40 | ID: "button-new-post", 41 | Class: "max responsive shrink center primary-container white-text bold thicc", 42 | Icon: "send", 43 | Text: "Send", 44 | Disabled: c.postButtonsDisabled, 45 | ShowProgress: c.postButtonsDisabled, 46 | OnClickActionName: "send-post", 47 | }, 48 | 49 | app.Div().Class("space"), 50 | 51 | // 52 | // New poll 53 | // 54 | 55 | &atoms.PageHeading{ 56 | Title: "new poll", 57 | }, 58 | app.Div().Class("space"), 59 | 60 | // newx poll input area 61 | app.Div().Class("field border label primary-text").Style("border-radius", "8px").Body( 62 | app.Input().ID("poll-question").Type("text").OnChange(c.ValueTo(&c.pollQuestion)).Required(true).Class("active").MaxLength(50).TabIndex(4), 63 | app.Label().Text("Question").Class("active primary-text"), 64 | ), 65 | app.Div().Class("field border label primary-text").Style("border-radius", "8px").Body( 66 | app.Input().ID("poll-option-i").Type("text").OnChange(c.ValueTo(&c.pollOptionI)).Required(true).Class("active").MaxLength(50).TabIndex(5), 67 | app.Label().Text("Option one").Class("active primary-text"), 68 | ), 69 | app.Div().Class("field border label primary-text").Style("border-radius", "8px").Body( 70 | app.Input().ID("poll-option-ii").Type("text").OnChange(c.ValueTo(&c.pollOptionII)).Required(true).Class("active").MaxLength(50).TabIndex(6), 71 | app.Label().Text("Option two").Class("active primary-text"), 72 | ), 73 | app.Div().Class("field border label primary-text").Style("border-radius", "8px").Body( 74 | app.Input().ID("poll-option-iii").Type("text").OnChange(c.ValueTo(&c.pollOptionIII)).Required(false).Class("active").MaxLength(60).TabIndex(7), 75 | app.Label().Text("Option three (optional)").Class("active primary-text"), 76 | ), 77 | 78 | &atoms.Button{ 79 | ID: "button-new-poll", 80 | Class: "max responsive shrink center primary-container white-text bold thicc", 81 | Icon: "send", 82 | Text: "Send", 83 | Disabled: c.postButtonsDisabled, 84 | ShowProgress: c.postButtonsDisabled, 85 | OnClickActionName: "send-poll", 86 | TabIndex: 8, 87 | }, 88 | 89 | app.Div().Class("space"), 90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /pkg/frontend/login/render.go: -------------------------------------------------------------------------------- 1 | package login 2 | 3 | import ( 4 | "go.vxn.dev/littr/pkg/config" 5 | 6 | "github.com/maxence-charriere/go-app/v10/pkg/app" 7 | ) 8 | 9 | func (c *Content) Render() app.UI { 10 | return app.Main().Class("responsive").Body( 11 | app.Div().Class("shrink-50 center").Body( 12 | app.Div().Class("row left-align").Body( 13 | app.Div().Class("max padding").Body( 14 | app.H5().Text("login"), 15 | ), 16 | ), 17 | app.Div().Class("space"), 18 | 19 | // Login credentials fields 20 | app.Div().Class("field border label primary-text thicc center-align").Body( 21 | app.Input().ID("login-input").Type("text").Required(true).TabIndex(1).OnChange(c.ValueTo(&c.nickname)).MaxLength(config.MaxNicknameLength).Class("active").Attr("autocomplete", "username"), 22 | app.Label().Text("Nickname").Class("active primary-text"), 23 | ), 24 | 25 | app.Div().Class("field border label primary-text thicc center-align").Body( 26 | app.Input().ID("passphrase-input").Type("password").Required(true).TabIndex(2).OnChange(c.ValueTo(&c.passphrase)).MaxLength(50).Class("active").Attr("autocomplete", "current-password"), 27 | app.Label().Text("Passphrase").Class("active primary-text"), 28 | ), 29 | 30 | // Session duration infobox. 31 | app.Article().Class("row border blue-border info thicc").Body( 32 | app.I().Text("info").Class("blue-text"), 33 | app.P().Class("max").Body( 34 | app.Span().Text("The login session lasts 30 days."), 35 | ), 36 | ), 37 | app.Div().Class("space"), 38 | 39 | // login button 40 | app.Div().Class("row center-align").Body( 41 | app.Button().ID("login-button").Class("max primary-container white-text bold thicc").OnClick(c.onClick).Disabled(c.loginButtonDisabled).TabIndex(3).Body( 42 | app.If(c.loginButtonDisabled, func() app.UI { 43 | return app.Progress().Class("circle white-border small") 44 | }), 45 | app.Span().Body( 46 | app.I().Style("padding-right", "5px").Text("login"), 47 | app.Text("Login"), 48 | ), 49 | ), 50 | ), 51 | app.Div().Class("little-space"), 52 | 53 | // reset button 54 | app.Div().Class("row center-align").Body( 55 | app.Button().Class("max primary-container white-text bold thicc").TabIndex(4).OnClick(c.onClickReset).Disabled(c.loginButtonDisabled).Body( 56 | app.Span().Body( 57 | app.I().Style("padding-right", "5px").Text("password"), 58 | app.Text("Recover passphrase"), 59 | ), 60 | ), 61 | ), 62 | app.Div().Class("little-space"), 63 | 64 | // register button 65 | app.Div().Class("row center-align").Body( 66 | // register button 67 | app.If(config.IsRegistrationEnabled, func() app.UI { 68 | return app.Button().Class("max primary-container white-text bold thicc").TabIndex(5).OnClick(c.onClickRegister).Disabled(c.loginButtonDisabled).Body( 69 | app.Span().Body( 70 | app.I().Style("padding-right", "5px").Text("app_registration"), 71 | app.Text("Register"), 72 | ), 73 | ) 74 | }).Else(func() app.UI { 75 | return app.Button().Class("max primary-container white-text bold thicc").TabIndex(5).OnClick(nil).Disabled(true).Body( 76 | app.Span().Body( 77 | app.I().Style("padding-right", "5px").Text("app_registration"), 78 | app.Text("Registration disabled"), 79 | ), 80 | ) 81 | }), 82 | ), 83 | app.Div().Class("medium-space"), 84 | ), 85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /pkg/backend/tokens/repository.go: -------------------------------------------------------------------------------- 1 | package tokens 2 | 3 | import ( 4 | "fmt" 5 | 6 | //"go.vxn.dev/littr/pkg/backend/common" 7 | "go.vxn.dev/littr/pkg/backend/db" 8 | //"go.vxn.dev/littr/pkg/backend/pages" 9 | "go.vxn.dev/littr/pkg/models" 10 | ) 11 | 12 | // The implementation of pkg/models.TokenRepositoryInterface. 13 | type TokenRepository struct { 14 | cache db.Cacher 15 | } 16 | 17 | func NewTokenRepository(cache db.Cacher) models.TokenRepositoryInterface { 18 | if cache == nil { 19 | return nil 20 | } 21 | 22 | return &TokenRepository{ 23 | cache: cache, 24 | } 25 | } 26 | 27 | /*func (r *TokenRepository) GetAll(pageOpts interface{}) (*map[string]models.Token, error) { 28 | // Assert type for pageOptions. 29 | opts, ok := pageOpts.(*pages.PageOptions) 30 | if !ok { 31 | return nil, fmt.Errorf("cannot read the page options at the repository level") 32 | } 33 | 34 | // Fetch page according to the calling user (in options). 35 | pagePtrs := pages.GetOnePage(*opts) 36 | if pagePtrs == (pages.PagePointers{}) || pagePtrs.Tokens == nil || (*pagePtrs.Tokens) == nil { 37 | return nil, fmt.Errorf(common.ERR_PAGE_EXPORT_NIL) 38 | } 39 | 40 | // If zero items were fetched, no need to continue asserting types. 41 | if len(*pagePtrs.Tokens) == 0 { 42 | return nil, fmt.Errorf("no tokens found in the database") 43 | } 44 | 45 | return pagePtrs.Tokens, nil 46 | 47 | }*/ 48 | 49 | // GetTokenByID is a static function to export to other services. 50 | func GetTokenByID(tokenID string, cache db.Cacher) (*models.Token, error) { 51 | if tokenID == "" || cache == nil { 52 | return nil, fmt.Errorf("tokenID is blank, or cache is nil") 53 | } 54 | 55 | // Fetch the token from the cache. 56 | tokRaw, found := cache.Load(tokenID) 57 | if !found { 58 | return nil, fmt.Errorf("could not find requested token") 59 | } 60 | 61 | token, ok := tokRaw.(models.Token) 62 | if !ok { 63 | return nil, fmt.Errorf("could not assert type *models.Token") 64 | } 65 | 66 | return &token, nil 67 | } 68 | 69 | func (r *TokenRepository) GetAll() (*map[string]models.Token, error) { 70 | rawTokens, count := r.cache.Range() 71 | if count == 0 { 72 | return nil, fmt.Errorf("no items found") 73 | } 74 | 75 | tokens := make(map[string]models.Token) 76 | 77 | // Assert types to fetched interface map. 78 | for key, rawToken := range *rawTokens { 79 | token, ok := rawToken.(models.Token) 80 | if !ok { 81 | return nil, fmt.Errorf("token's data corrupted") 82 | } 83 | 84 | tokens[key] = token 85 | } 86 | 87 | return &tokens, nil 88 | } 89 | 90 | func (r *TokenRepository) GetByID(tokenID string) (*models.Token, error) { 91 | // Use the static function to get such token. 92 | token, err := GetTokenByID(tokenID, r.cache) 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | return token, nil 98 | } 99 | 100 | func (r *TokenRepository) Save(token *models.Token) error { 101 | // Store the token using its key in the cache. 102 | saved := r.cache.Store(token.Hash, *token) 103 | if !saved { 104 | return fmt.Errorf("an error occurred while saving a token") 105 | } 106 | 107 | return nil 108 | } 109 | 110 | func (r *TokenRepository) Delete(tokenID string) error { 111 | // Simple token's deletion. 112 | deleted := r.cache.Delete(tokenID) 113 | if !deleted { 114 | return fmt.Errorf("token data could not be purged from the database") 115 | } 116 | 117 | return nil 118 | } 119 | -------------------------------------------------------------------------------- /pkg/backend/tokens/service.go: -------------------------------------------------------------------------------- 1 | package tokens 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "fmt" 7 | "os" 8 | "time" 9 | 10 | "go.vxn.dev/littr/pkg/backend/common" 11 | "go.vxn.dev/littr/pkg/models" 12 | 13 | "github.com/golang-jwt/jwt" 14 | ) 15 | 16 | /*type TokenServiceInterface interface { 17 | Create(ctx context.Context, token *Token) error 18 | Delete(ctx context.Context, tokenID string) error 19 | FindByID(ctx context.Context, tokenID string) (*Token, error) 20 | }*/ 21 | 22 | type TokenService struct { 23 | tokenRepository models.TokenRepositoryInterface 24 | } 25 | 26 | func NewTokenService(tokenRepository models.TokenRepositoryInterface) models.TokenServiceInterface { 27 | return &TokenService{ 28 | tokenRepository: tokenRepository, 29 | } 30 | } 31 | 32 | func (s *TokenService) Create(ctx context.Context, user *models.User) ([]string, error) { 33 | if user == nil { 34 | return nil, fmt.Errorf("given user is nil") 35 | } 36 | 37 | tokens, err := NewToken(user, s.tokenRepository) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | return tokens, nil 43 | } 44 | 45 | func NewToken(user *models.User, r models.TokenRepositoryInterface) ([]string, error) { 46 | secret := os.Getenv("APP_PEPPER") 47 | 48 | if secret == "" { 49 | return nil, fmt.Errorf("server secret is blank") 50 | } 51 | 52 | // Compose the user's personal (access) token content. 53 | userClaims := UserClaims{ 54 | Nickname: user.Nickname, 55 | // Access token is restricted to 15 minutes of its validity. 56 | StandardClaims: jwt.StandardClaims{ 57 | IssuedAt: time.Now().Unix(), 58 | ExpiresAt: time.Now().Add(time.Minute * 15).Unix(), 59 | }, 60 | } 61 | 62 | // Get new access token = sign the access token with the server's secret. 63 | signedAccessToken, err := NewAccessToken(userClaims, secret) 64 | if err != nil { 65 | return nil, fmt.Errorf(common.ERR_AUTH_ACC_TOKEN_FAIL) 66 | } 67 | 68 | // Compose the user's personal (refresh) token content. Refresh token is restricted (mainly) to 4 weeks of its validity. 69 | refreshClaims := jwt.StandardClaims{ 70 | IssuedAt: time.Now().Unix(), 71 | ExpiresAt: time.Now().Add(common.TokenTTL).Unix(), 72 | } 73 | 74 | // Get new refresh token = sign the refresh token with the server's secret. 75 | signedRefreshToken, err := NewRefreshToken(refreshClaims, secret) 76 | if err != nil { 77 | return nil, fmt.Errorf(common.ERR_AUTH_REF_TOKEN_FAIL) 78 | } 79 | 80 | // Prepare the refresh token's hash for the database payload. 81 | refreshSum := sha256.New() 82 | refreshSum.Write([]byte(signedRefreshToken)) 83 | refreshTokenSum := fmt.Sprintf("%x", refreshSum.Sum(nil)) 84 | 85 | // Prepare the refresh token struct for the database saving. 86 | token := models.Token{ 87 | Hash: refreshTokenSum, 88 | CreatedAt: time.Now(), 89 | Nickname: user.Nickname, 90 | TTL: common.TokenTTL, 91 | } 92 | 93 | // Save new refresh token's hash to the Token database. 94 | if err := r.Save(&token); err != nil { 95 | return nil, fmt.Errorf(common.ERR_TOKEN_SAVE_FAIL) 96 | } 97 | 98 | return []string{signedAccessToken, signedRefreshToken}, nil 99 | } 100 | 101 | func (s *TokenService) Delete(ctx context.Context, tokenID string) error { 102 | return errNotImplemented 103 | } 104 | 105 | func (s *TokenService) FindByID(ctx context.Context, tokenID string) (*models.Token, error) { 106 | return nil, errNotImplemented 107 | } 108 | --------------------------------------------------------------------------------