🎉 Init project from scratch

This commit is contained in:
2024-02-01 23:26:17 +08:00
commit 434773976f
64 changed files with 1760 additions and 0 deletions

52
pkg/cmd/main.go Normal file
View File

@ -0,0 +1,52 @@
package main
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/server"
"os"
"os/signal"
"syscall"
interactive "code.smartsheep.studio/hydrogen/interactive/pkg"
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
)
func init() {
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout})
}
func main() {
// Configure settings
viper.AddConfigPath(".")
viper.AddConfigPath("..")
viper.SetConfigName("settings")
viper.SetConfigType("toml")
// Load settings
if err := viper.ReadInConfig(); err != nil {
log.Panic().Err(err).Msg("An error occurred when loading settings.")
}
// Connect to database
if err := database.NewSource(); err != nil {
log.Fatal().Err(err).Msg("An error occurred when connect to database.")
} else if err := database.RunMigration(database.C); err != nil {
log.Fatal().Err(err).Msg("An error occurred when running database auto migration.")
}
// Server
server.NewServer()
go server.Listen()
// Messages
log.Info().Msgf("Passport v%s is started...", interactive.AppVersion)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Info().Msgf("Passport v%s is quitting...", interactive.AppVersion)
}

16
pkg/database/migrator.go Normal file
View File

@ -0,0 +1,16 @@
package database
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"gorm.io/gorm"
)
func RunMigration(source *gorm.DB) error {
if err := source.AutoMigrate(
&models.Account{},
); err != nil {
return err
}
return nil
}

28
pkg/database/source.go Normal file
View File

@ -0,0 +1,28 @@
package database
import (
"github.com/rs/zerolog/log"
"github.com/samber/lo"
"github.com/spf13/viper"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
var C *gorm.DB
func NewSource() error {
var err error
dialector := postgres.Open(viper.GetString("database.dsn"))
C, err = gorm.Open(dialector, &gorm.Config{NamingStrategy: schema.NamingStrategy{
TablePrefix: viper.GetString("database.prefix"),
}, Logger: logger.New(&log.Logger, logger.Config{
Colorful: true,
IgnoreRecordNotFoundError: true,
LogLevel: lo.Ternary(viper.GetBool("debug"), logger.Info, logger.Silent),
})})
return err
}

5
pkg/meta.go Normal file
View File

@ -0,0 +1,5 @@
package pkg
const (
AppVersion = "1.0.0"
)

14
pkg/models/accounts.go Normal file
View File

@ -0,0 +1,14 @@
package models
// Account profiles basically fetched from Hydrogen.Passport
// But cache at here for better usage
// At the same time this model can make relations between local models
type Account struct {
BaseModel
Name string `json:"name"`
Avatar string `json:"avatar"`
EmailAddress string `json:"email_address"`
PowerLevel int `json:"power_level"`
ExternalID uint `json:"external_id"`
}

17
pkg/models/base.go Normal file
View File

@ -0,0 +1,17 @@
package models
import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type JSONMap = datatypes.JSONType[map[string]any]
type BaseModel struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
}

12
pkg/security/encryptor.go Normal file
View File

@ -0,0 +1,12 @@
package security
import "golang.org/x/crypto/bcrypt"
func HashPassword(raw string) string {
data, _ := bcrypt.GenerateFromPassword([]byte(raw), 12)
return string(data)
}
func VerifyPassword(text string, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(password), []byte(text)) == nil
}

56
pkg/security/jwt.go Normal file
View File

@ -0,0 +1,56 @@
package security
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/spf13/viper"
)
type PayloadClaims struct {
jwt.RegisteredClaims
Type string `json:"typ"`
}
const (
JwtAccessType = "access"
JwtRefreshType = "refresh"
)
func EncodeJwt(id string, typ, sub string, aud []string, exp time.Time) (string, error) {
tk := jwt.NewWithClaims(jwt.SigningMethodHS512, PayloadClaims{
jwt.RegisteredClaims{
Subject: sub,
Audience: aud,
Issuer: fmt.Sprintf("https://%s", viper.GetString("domain")),
ExpiresAt: jwt.NewNumericDate(exp),
NotBefore: jwt.NewNumericDate(time.Now()),
IssuedAt: jwt.NewNumericDate(time.Now()),
ID: id,
},
typ,
})
return tk.SignedString([]byte(viper.GetString("secret")))
}
func DecodeJwt(str string) (PayloadClaims, error) {
var claims PayloadClaims
tk, err := jwt.ParseWithClaims(str, &claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(viper.GetString("secret")), nil
})
if err != nil {
return claims, err
}
if data, ok := tk.Claims.(*PayloadClaims); ok {
return *data, nil
} else {
return claims, fmt.Errorf("unexpected token payload: not payload claims type")
}
}

35
pkg/server/auth.go Normal file
View File

@ -0,0 +1,35 @@
package server
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"code.smartsheep.studio/hydrogen/passport/pkg/security"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/keyauth"
"strconv"
)
var auth = keyauth.New(keyauth.Config{
KeyLookup: "header:Authorization",
AuthScheme: "Bearer",
Validator: func(c *fiber.Ctx, token string) (bool, error) {
claims, err := security.DecodeJwt(token)
if err != nil {
return false, err
}
id, _ := strconv.Atoi(claims.Subject)
var user models.Account
if err := database.C.Where(&models.Account{
BaseModel: models.BaseModel{ID: uint(id)},
}).First(&user).Error; err != nil {
return false, err
}
c.Locals("principal", user)
return true, nil
},
ContextKey: "token",
})

87
pkg/server/auth_api.go Normal file
View File

@ -0,0 +1,87 @@
package server
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/services"
"context"
"encoding/json"
"fmt"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/spf13/viper"
"golang.org/x/oauth2"
)
var cfg = oauth2.Config{
RedirectURL: fmt.Sprintf("https://%s/api/auth/callback", viper.GetString("domain")),
ClientID: viper.GetString("passport.client_id"),
ClientSecret: viper.GetString("passport.client_secret"),
Scopes: []string{"openid"},
Endpoint: oauth2.Endpoint{
AuthURL: fmt.Sprintf("%s/auth/o/connect", viper.GetString("passport.endpoint")),
TokenURL: fmt.Sprintf("%s/api/auth/token", viper.GetString("passport.endpoint")),
AuthStyle: oauth2.AuthStyleInParams,
},
}
func doLogin(c *fiber.Ctx) error {
url := cfg.AuthCodeURL(uuid.NewString())
return c.JSON(fiber.Map{
"target": url,
})
}
func doPostLogin(c *fiber.Ctx) error {
code := c.Query("code")
token, err := cfg.Exchange(context.Background(), code)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("failed to exchange token: %q", err))
}
agent := fiber.
Get(fmt.Sprintf("%s/api/users/me", viper.GetString("passport.endpoint"))).
Set(fiber.HeaderAuthorization, fmt.Sprintf("Bearer %s", token.AccessToken))
_, body, errs := agent.Bytes()
if len(errs) > 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("failed to get userinfo: %q", errs))
}
var userinfo services.PassportUserinfo
err = json.Unmarshal(body, &userinfo)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("failed to parse userinfo: %q", err))
}
account, err := services.LinkAccount(userinfo)
access, refresh, err := services.GetToken(account)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("failed to get token: %q", err))
}
return c.JSON(fiber.Map{
"access_token": access,
"refresh_token": refresh,
})
}
func doRefreshToken(c *fiber.Ctx) error {
var data struct {
RefreshToken string `json:"refresh_token" validate:"required"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
}
access, refresh, err := services.RefreshToken(data.RefreshToken)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("failed to get token: %q", err))
}
return c.JSON(fiber.Map{
"access_token": access,
"refresh_token": refresh,
})
}

79
pkg/server/startup.go Normal file
View File

@ -0,0 +1,79 @@
package server
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/view"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cache"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/filesystem"
"github.com/gofiber/fiber/v2/middleware/idempotency"
"github.com/gofiber/fiber/v2/middleware/logger"
jsoniter "github.com/json-iterator/go"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
"net/http"
"strings"
"time"
)
var A *fiber.App
func NewServer() {
A = fiber.New(fiber.Config{
DisableStartupMessage: true,
EnableIPValidation: true,
ServerHeader: "Hydrogen.Interactive",
AppName: "Hydrogen.Interactive",
ProxyHeader: fiber.HeaderXForwardedFor,
JSONEncoder: jsoniter.ConfigCompatibleWithStandardLibrary.Marshal,
JSONDecoder: jsoniter.ConfigCompatibleWithStandardLibrary.Unmarshal,
EnablePrintRoutes: viper.GetBool("debug"),
})
A.Use(idempotency.New())
A.Use(cors.New(cors.Config{
AllowCredentials: true,
AllowMethods: strings.Join([]string{
fiber.MethodGet,
fiber.MethodPost,
fiber.MethodHead,
fiber.MethodOptions,
fiber.MethodPut,
fiber.MethodDelete,
fiber.MethodPatch,
}, ","),
AllowOriginsFunc: func(origin string) bool {
return true
},
}))
A.Use(logger.New(logger.Config{
Format: "${status} | ${latency} | ${method} ${path}\n",
Output: log.Logger,
}))
A.Get("/.well-known", getMetadata)
api := A.Group("/api").Name("API")
{
api.Get("/auth", doLogin)
api.Get("/auth/callback", doPostLogin)
api.Post("/auth/refresh", doRefreshToken)
}
A.Use("/", cache.New(cache.Config{
Expiration: 24 * time.Hour,
CacheControl: true,
}), filesystem.New(filesystem.Config{
Root: http.FS(view.FS),
PathPrefix: "dist",
Index: "index.html",
NotFoundFile: "dist/index.html",
}))
}
func Listen() {
if err := A.Listen(viper.GetString("bind")); err != nil {
log.Fatal().Err(err).Msg("An error occurred when starting server...")
}
}

18
pkg/server/utils.go Normal file
View File

@ -0,0 +1,18 @@
package server
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
)
var validation = validator.New(validator.WithRequiredStructEnabled())
func BindAndValidate(c *fiber.Ctx, out any) error {
if err := c.BodyParser(out); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else if err := validation.Struct(out); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return nil
}

View File

@ -0,0 +1,13 @@
package server
import (
"github.com/gofiber/fiber/v2"
"github.com/spf13/viper"
)
func getMetadata(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"name": viper.GetString("name"),
"domain": viper.GetString("domain"),
})
}

90
pkg/services/auth.go Normal file
View File

@ -0,0 +1,90 @@
package services
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"code.smartsheep.studio/hydrogen/interactive/pkg/security"
"errors"
"fmt"
"github.com/google/uuid"
"gorm.io/gorm"
"strconv"
"time"
)
type PassportUserinfo struct {
Sub uint `json:"sub"`
Email string `json:"email"`
Picture string `json:"picture"`
PreferredUsernames string `json:"preferred_usernames"`
}
func LinkAccount(userinfo PassportUserinfo) (models.Account, error) {
var account models.Account
if err := database.C.Where(&models.Account{
ExternalID: userinfo.Sub,
}).First(&account).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
account = models.Account{
Name: userinfo.PreferredUsernames,
Avatar: userinfo.Picture,
EmailAddress: userinfo.Email,
PowerLevel: 0,
ExternalID: userinfo.Sub,
}
return account, database.C.Save(&account).Error
}
return account, err
}
return account, nil
}
func GetToken(account models.Account) (string, string, error) {
var err error
var refresh, access string
sub := strconv.Itoa(int(account.ID))
access, err = security.EncodeJwt(
uuid.NewString(),
security.JwtAccessType,
sub,
[]string{"interactive"},
time.Now().Add(30*time.Minute),
)
if err != nil {
return refresh, access, err
}
refresh, err = security.EncodeJwt(
uuid.NewString(),
security.JwtRefreshType,
sub,
[]string{"interactive"},
time.Now().Add(30*24*time.Hour),
)
if err != nil {
return refresh, access, err
}
return access, refresh, nil
}
func RefreshToken(token string) (string, string, error) {
parseInt := func(str string) int {
val, _ := strconv.Atoi(str)
return val
}
var account models.Account
if claims, err := security.DecodeJwt(token); err != nil {
return "404", "403", err
} else if claims.Type != security.JwtRefreshType {
return "404", "403", fmt.Errorf("invalid token type, expected refresh token")
} else if err := database.C.Where(models.Account{
BaseModel: models.BaseModel{ID: uint(parseInt(claims.Subject))},
}).First(&account).Error; err != nil {
return "404", "403", err
}
return GetToken(account)
}

51
pkg/services/mailer.go Normal file
View File

@ -0,0 +1,51 @@
package services
import (
"crypto/tls"
"fmt"
"net/smtp"
"net/textproto"
"github.com/jordan-wright/email"
"github.com/spf13/viper"
)
func SendMail(target string, subject string, content string) error {
mail := &email.Email{
To: []string{target},
From: viper.GetString("mailer.name"),
Subject: subject,
Text: []byte(content),
Headers: textproto.MIMEHeader{},
}
return mail.SendWithTLS(
fmt.Sprintf("%s:%d", viper.GetString("mailer.smtp_host"), viper.GetInt("mailer.smtp_port")),
smtp.PlainAuth(
"",
viper.GetString("mailer.username"),
viper.GetString("mailer.password"),
viper.GetString("mailer.smtp_host"),
),
&tls.Config{ServerName: viper.GetString("mailer.smtp_host")},
)
}
func SendMailHTML(target string, subject string, content string) error {
mail := &email.Email{
To: []string{target},
From: viper.GetString("mailer.name"),
Subject: subject,
HTML: []byte(content),
Headers: textproto.MIMEHeader{},
}
return mail.SendWithTLS(
fmt.Sprintf("%s:%d", viper.GetString("mailer.smtp_host"), viper.GetInt("mailer.smtp_port")),
smtp.PlainAuth(
"",
viper.GetString("mailer.username"),
viper.GetString("mailer.password"),
viper.GetString("mailer.smtp_host"),
),
&tls.Config{ServerName: viper.GetString("mailer.smtp_host")},
)
}

5
pkg/view/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/dist
/node_modules
package-lock.json
yarn.lock

28
pkg/view/README.md Normal file
View File

@ -0,0 +1,28 @@
## Usage
```bash
$ npm install # or pnpm install or yarn install
```
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
## Available Scripts
In the project directory, you can run:
### `npm run dev`
Runs the app in the development mode.<br>
Open [http://localhost:5173](http://localhost:5173) to view it in the browser.
### `npm run build`
Builds the app for production to the `dist` folder.<br>
It correctly bundles Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
## Deployment
Learn more about deploying your application with the [documentations](https://vitejs.dev/guide/static-deploy.html)

6
pkg/view/embed.go Normal file
View File

@ -0,0 +1,6 @@
package view
import "embed"
//go:embed all:dist
var FS embed.FS

13
pkg/view/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Goatpass</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

26
pkg/view/package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "@hydrogen/interactive-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@solidjs/router": "^0.10.10",
"solid-js": "^1.8.7",
"universal-cookie": "^7.0.2"
},
"devDependencies": {
"autoprefixer": "^10.4.17",
"daisyui": "^4.6.0",
"postcss": "^8.4.33",
"solid-devtools": "^0.29.3",
"tailwindcss": "^3.4.1",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vite-plugin-solid": "^2.8.0"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

5
pkg/view/src/.prettierrc Normal file
View File

@ -0,0 +1,5 @@
{
"printWidth": 120,
"tabWidth": 2,
"singleQuote": false
}

View File

@ -0,0 +1,197 @@
:root {
--bs-body-font-family: "IBM Plex Serif", "Noto Serif SC", sans-serif !important;
}
html,
body {
font-family: var(--bs-body-font-family);
}
/* ibm-plex-serif-100 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: normal;
font-weight: 100;
src: url("./ibm-plex-serif-v19-latin-100.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-100italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: italic;
font-weight: 100;
src: url("./ibm-plex-serif-v19-latin-100italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-200 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: normal;
font-weight: 200;
src: url("./ibm-plex-serif-v19-latin-200.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-200italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: italic;
font-weight: 200;
src: url("./ibm-plex-serif-v19-latin-200italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-300 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: normal;
font-weight: 300;
src: url("./ibm-plex-serif-v19-latin-300.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-300italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: italic;
font-weight: 300;
src: url("./ibm-plex-serif-v19-latin-300italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-regular - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: normal;
font-weight: 400;
src: url("./ibm-plex-serif-v19-latin-regular.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: italic;
font-weight: 400;
src: url("./ibm-plex-serif-v19-latin-italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-500 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: normal;
font-weight: 500;
src: url("./ibm-plex-serif-v19-latin-500.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-500italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: italic;
font-weight: 500;
src: url("./ibm-plex-serif-v19-latin-500italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-600 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: normal;
font-weight: 600;
src: url("./ibm-plex-serif-v19-latin-600.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-600italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: italic;
font-weight: 600;
src: url("./ibm-plex-serif-v19-latin-600italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-700 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: normal;
font-weight: 700;
src: url("./ibm-plex-serif-v19-latin-700.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* ibm-plex-serif-700italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "IBM Plex Serif";
font-style: italic;
font-weight: 700;
src: url("./ibm-plex-serif-v19-latin-700italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-200 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 200;
src: url("./noto-serif-sc-v22-chinese-simplified-200.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-300 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 300;
src: url("./noto-serif-sc-v22-chinese-simplified-300.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-regular - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 400;
src: url("./noto-serif-sc-v22-chinese-simplified-regular.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-500 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 500;
src: url("./noto-serif-sc-v22-chinese-simplified-500.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-600 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 600;
src: url("./noto-serif-sc-v22-chinese-simplified-600.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-700 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 700;
src: url("./noto-serif-sc-v22-chinese-simplified-700.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* noto-serif-sc-900 - chinese-simplified */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Noto Serif SC";
font-style: normal;
font-weight: 900;
src: url("./noto-serif-sc-v22-chinese-simplified-900.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

8
pkg/view/src/index.css Normal file
View File

@ -0,0 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body {
padding: 0;
margin: 0;
}

32
pkg/view/src/index.tsx Normal file
View File

@ -0,0 +1,32 @@
import "solid-devtools";
/* @refresh reload */
import { render } from "solid-js/web";
import "./index.css";
import "./assets/fonts/fonts.css";
import { lazy } from "solid-js";
import { Route, Router } from "@solidjs/router";
import RootLayout from "./layouts/RootLayout.tsx";
import { UserinfoProvider } from "./stores/userinfo.tsx";
import { WellKnownProvider } from "./stores/wellKnown.tsx";
const root = document.getElementById("root");
render(() => (
<WellKnownProvider>
<UserinfoProvider>
<Router root={RootLayout}>
<Route path="/" component={lazy(() => import("./pages/dashboard.tsx"))} />
<Route path="/security" component={lazy(() => import("./pages/security.tsx"))} />
<Route path="/personalise" component={lazy(() => import("./pages/personalise.tsx"))} />
<Route path="/auth/login" component={lazy(() => import("./pages/auth/login.tsx"))} />
<Route path="/auth/register" component={lazy(() => import("./pages/auth/register.tsx"))} />
<Route path="/auth/oauth/connect" component={lazy(() => import("./pages/auth/connect.tsx"))} />
<Route path="/auth/oauth/callback" component={lazy(() => import("./pages/auth/callback.tsx"))} />
<Route path="/users/me/confirm" component={lazy(() => import("./pages/users/confirm.tsx"))} />
</Router>
</UserinfoProvider>
</WellKnownProvider>
), root!);

View File

@ -0,0 +1,46 @@
import Navbar from "./shared/Navbar.tsx";
import { readProfiles, useUserinfo } from "../stores/userinfo.tsx";
import { createEffect, createSignal, Show } from "solid-js";
import { readWellKnown } from "../stores/wellKnown.tsx";
import { BeforeLeaveEventArgs, useBeforeLeave, useLocation, useNavigate } from "@solidjs/router";
export default function RootLayout(props: any) {
const [ready, setReady] = createSignal(false);
Promise.all([readWellKnown(), readProfiles()]).then(() => setReady(true));
const navigate = useNavigate();
const userinfo = useUserinfo();
const location = useLocation();
createEffect(() => {
if (ready()) {
keepGate(location.pathname);
}
}, [ready, userinfo]);
function keepGate(path: string, e?: BeforeLeaveEventArgs) {
const whitelist = ["/auth/login", "/auth/register", "/users/me/confirm"];
if (!userinfo?.isLoggedIn && !whitelist.includes(path)) {
if (!e?.defaultPrevented) e?.preventDefault();
navigate(`/auth/login?redirect_uri=${path}`);
}
}
useBeforeLeave((e: BeforeLeaveEventArgs) => keepGate(e.to.toString().split("?")[0], e));
return (
<Show when={ready()} fallback={
<div class="h-screen w-screen flex justify-center items-center">
<div>
<span class="loading loading-lg loading-infinity"></span>
</div>
</div>
}>
<Navbar />
<main class="h-[calc(100vh-68px)] mt-[68px]">{props.children}</main>
</Show>
);
}

View File

@ -0,0 +1,118 @@
import { For, Match, Show, Switch } from "solid-js";
import { clearUserinfo, useUserinfo } from "../../stores/userinfo.tsx";
import { useNavigate } from "@solidjs/router";
import { useWellKnown } from "../../stores/wellKnown.tsx";
interface MenuItem {
label: string;
href?: string;
children?: MenuItem[];
}
export default function Navbar() {
const nav: MenuItem[] = [
{
label: "You", children: [
{ label: "Dashboard", href: "/" },
{ label: "Security", href: "/security" },
{ label: "Personalise", href: "/personalise" }
]
}
];
const wellKnown = useWellKnown();
const userinfo = useUserinfo();
const navigate = useNavigate();
function logout() {
clearUserinfo();
navigate("/auth/login");
}
return (
<div class="navbar bg-base-100 shadow-md px-5 z-10 fixed top-0">
<div class="navbar-start">
<div class="dropdown">
<div tabIndex={0} role="button" class="btn btn-ghost lg:hidden">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h8m-8 6h16"
/>
</svg>
</div>
<ul
tabIndex={0}
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52"
>
<For each={nav}>
{(item) => (
<li>
<a href={item.href}>{item.label}</a>
<Show when={item.children}>
<ul class="p-2">
<For each={item.children}>
{(item) =>
<li>
<a href={item.href}>{item.label}</a>
</li>
}
</For>
</ul>
</Show>
</li>
)}
</For>
</ul>
</div>
<a href="/" class="btn btn-ghost text-xl">
{wellKnown?.name ?? "Goatpass"}
</a>
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<For each={nav}>
{(item) => (
<li>
<Show when={item.children} fallback={<a href={item.href}>{item.label}</a>}>
<details>
<summary>
<a href={item.href}>{item.label}</a>
</summary>
<ul class="p-2">
<For each={item.children}>
{(item) =>
<li>
<a href={item.href}>{item.label}</a>
</li>
}
</For>
</ul>
</details>
</Show>
</li>
)}
</For>
</ul>
</div>
<div class="navbar-end pe-5">
<Switch>
<Match when={userinfo?.isLoggedIn}>
<button type="button" class="btn btn-sm btn-ghost" onClick={() => logout()}>Logout</button>
</Match>
<Match when={!userinfo?.isLoggedIn}>
<a href="/auth/login" class="btn btn-sm btn-primary">Login</a>
</Match>
</Switch>
</div>
</div>
);
}

View File

@ -0,0 +1,108 @@
import { getAtk, readProfiles, useUserinfo } from "../stores/userinfo.tsx";
import { createSignal, For, Show } from "solid-js";
export default function DashboardPage() {
const userinfo = useUserinfo();
const [error, setError] = createSignal<string | null>(null);
function getGreeting() {
const currentHour = new Date().getHours();
if (currentHour >= 0 && currentHour < 12) {
return "Good morning! Wishing you a day filled with joy and success. ☀️";
} else if (currentHour >= 12 && currentHour < 18) {
return "Afternoon! Hope you have a productive and joyful afternoon! ☀️";
} else {
return "Good evening! Wishing you a relaxing and pleasant evening. 🌙";
}
}
async function readNotification(item: any) {
const res = await fetch(`/api/notifications/${item.id}/read`, {
method: "PUT",
headers: { Authorization: `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
setError(await res.text());
} else {
await readProfiles();
setError(null);
}
}
return (
<div class="max-w-[720px] mx-auto px-5 pt-12">
<div id="greeting" class="px-5">
<h1 class="text-2xl font-bold">{userinfo?.displayName}</h1>
<p>{getGreeting()}</p>
</div>
<div id="alerts">
<Show when={!userinfo?.meta?.confirmed_at}>
<div role="alert" class="alert alert-warning mt-5">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<span>Your account isn't confirmed yet. Please check your inbox and confirm your account.</span> <br />
<span>Otherwise your account will be deactivate after 48 hours.</span>
</div>
</div>
</Show>
<Show when={error()}>
<div role="alert" class="alert alert-error mt-5">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</Show>
</div>
<div class="card shadow-xl mt-5">
<div class="card-body">
<h2 class="card-title">Notifications</h2>
<div class="bg-base-200 mt-3 mx-[-32px]">
<Show when={userinfo?.meta?.notifications?.length <= 0}>
<table class="table">
<tbody>
<tr>
<td class="px-[32px]">You're done! There are no notifications unread for you.</td>
</tr>
</tbody>
</table>
</Show>
<Show when={userinfo?.meta?.notifications?.length > 0}>
<table class="table">
<tbody>
<For each={userinfo?.meta?.notifications}>
{item =>
<tr>
<td class="px-[32px]">
<h2 class="font-bold">{item.subject}</h2>
<p>{item.content}</p>
<div class="flex gap-2">
<Show when={item.is_important}>
<span class="font-bold">Important</span>
</Show>
<a class="link" onClick={() => readNotification(item)}>Mark as read</a>
</div>
</td>
</tr>
}
</For>
</tbody>
</table>
</Show>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,95 @@
import Cookie from "universal-cookie";
import { createContext, useContext } from "solid-js";
import { createStore } from "solid-js/store";
export interface Userinfo {
isLoggedIn: boolean,
displayName: string,
profiles: any,
meta: any
}
const UserinfoContext = createContext<Userinfo>();
const defaultUserinfo: Userinfo = {
isLoggedIn: false,
displayName: "Citizen",
profiles: null,
meta: null
};
const [userinfo, setUserinfo] = createStore<Userinfo>(structuredClone(defaultUserinfo));
export function getAtk(): string {
return new Cookie().get("access_token");
}
export async function refreshAtk() {
const rtk = new Cookie().get("refresh_token");
const res = await fetch("/api/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
refresh_token: rtk,
grant_type: "refresh_token"
})
});
if (res.status !== 200) {
console.error(await res.text())
} else {
const data = await res.json();
new Cookie().set("access_token", data["access_token"], { path: "/", maxAge: undefined });
new Cookie().set("refresh_token", data["refresh_token"], { path: "/", maxAge: undefined });
}
}
function checkLoggedIn(): boolean {
return new Cookie().get("access_token");
}
export async function readProfiles(recovering = true) {
if (!checkLoggedIn()) return;
const res = await fetch("/api/users/me", {
headers: { "Authorization": `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
if (recovering) {
// Auto retry after refresh access token
await refreshAtk();
return await readProfiles(false);
} else {
clearUserinfo();
window.location.reload();
}
}
const data = await res.json();
setUserinfo({
isLoggedIn: true,
displayName: data["nick"],
profiles: null,
meta: data
});
}
export function clearUserinfo() {
new Cookie().remove("access_token", { path: "/", maxAge: undefined });
new Cookie().remove("refresh_token", { path: "/", maxAge: undefined });
setUserinfo(defaultUserinfo);
}
export function UserinfoProvider(props: any) {
return (
<UserinfoContext.Provider value={userinfo}>
{props.children}
</UserinfoContext.Provider>
);
}
export function useUserinfo() {
return useContext(UserinfoContext);
}

View File

@ -0,0 +1,23 @@
import { createContext, useContext } from "solid-js";
import { createStore } from "solid-js/store";
const WellKnownContext = createContext<any>();
const [wellKnown, setWellKnown] = createStore<any>(null);
export async function readWellKnown() {
const res = await fetch("/.well-known")
setWellKnown(await res.json())
}
export function WellKnownProvider(props: any) {
return (
<WellKnownContext.Provider value={wellKnown}>
{props.children}
</WellKnownContext.Provider>
);
}
export function useWellKnown() {
return useContext(WellKnownContext);
}

1
pkg/view/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,44 @@
/** @type {import("tailwindcss").Config} */
export default {
content: [
"./src/**/*.{js,jsx,ts,tsx}"
],
daisyui: {
themes: [
{
light: {
...require("daisyui/src/theming/themes")["light"],
primary: "#4750a3",
secondary: "#93c5fd",
accent: "#0f766e",
info: "#67e8f9",
success: "#15803d",
warning: "#f97316",
error: "#dc2626",
"--rounded-box": "0",
"--rounded-btn": "0",
"--rounded-badge": "0",
"--tab-radius": "0"
}
},
{
dark: {
...require("daisyui/src/theming/themes")["dark"],
primary: "#4750a3",
secondary: "#93c5fd",
accent: "#0f766e",
info: "#67e8f9",
success: "#15803d",
warning: "#f97316",
error: "#dc2626",
"--rounded-box": "0",
"--rounded-btn": "0",
"--rounded-badge": "0",
"--tab-radius": "0"
}
}
]
},
plugins: [require("daisyui")]
};

26
pkg/view/tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "ES2015", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

13
pkg/view/vite.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
import devtools from "solid-devtools/vite";
export default defineConfig({
plugins: [devtools({ autoname: true }), solid()],
server: {
proxy: {
"/api": "http://localhost:8444",
"/.well-known": "http://localhost:8444"
}
}
});