mirror of
https://github.com/ordinary-dev/phoenix
synced 2024-09-20 03:40:29 +05:00
build: version 1.3.0
This commit is contained in:
commit
5c77887154
5
.dockerignore
Normal file
5
.dockerignore
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
*~
|
||||||
|
*.db
|
||||||
|
*.bak
|
||||||
|
*.sqlite3
|
||||||
|
**/.DS_Store
|
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
|
@ -4,7 +4,15 @@ updates:
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
github-deps:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
- package-ecosystem: "gomod"
|
- package-ecosystem: "gomod"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
go-deps:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
|
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
|
@ -23,15 +23,15 @@ jobs:
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v2
|
uses: github/codeql-action/init@v3
|
||||||
with:
|
with:
|
||||||
languages: go
|
languages: go
|
||||||
|
|
||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@v2
|
uses: github/codeql-action/autobuild@v3
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v2
|
uses: github/codeql-action/analyze@v3
|
||||||
with:
|
with:
|
||||||
category: "/language:go"
|
category: "/language:go"
|
||||||
|
|
8
.github/workflows/docker.yml
vendored
8
.github/workflows/docker.yml
vendored
|
@ -26,12 +26,12 @@ jobs:
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Docker buildx
|
- name: Setup Docker buildx
|
||||||
uses: docker/setup-buildx-action@v3.0.0
|
uses: docker/setup-buildx-action@v3.2.0
|
||||||
|
|
||||||
# Login against a Docker registry
|
# Login against a Docker registry
|
||||||
# https://github.com/docker/login-action
|
# https://github.com/docker/login-action
|
||||||
- name: Log into registry ${{ env.REGISTRY }}
|
- name: Log into registry ${{ env.REGISTRY }}
|
||||||
uses: docker/login-action@v3.0.0
|
uses: docker/login-action@v3.1.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
@ -41,7 +41,7 @@ jobs:
|
||||||
# https://github.com/docker/metadata-action
|
# https://github.com/docker/metadata-action
|
||||||
- name: Extract Docker metadata
|
- name: Extract Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5.0.0
|
uses: docker/metadata-action@v5.5.1
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
|
@ -49,7 +49,7 @@ jobs:
|
||||||
# https://github.com/docker/build-push-action
|
# https://github.com/docker/build-push-action
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
id: build-and-push
|
id: build-and-push
|
||||||
uses: docker/build-push-action@v5.0.0
|
uses: docker/build-push-action@v5.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
FROM golang:1.21.3-alpine3.18 AS builder
|
FROM golang:1.22-alpine AS builder
|
||||||
|
|
||||||
RUN apk add gcc
|
RUN apk add --no-cache gcc libc-dev
|
||||||
RUN apk add musl-dev
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
@ -12,7 +11,7 @@ ADD . .
|
||||||
|
|
||||||
RUN go build -o main
|
RUN go build -o main
|
||||||
|
|
||||||
FROM alpine:3.18.4
|
FROM alpine:3.19
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/main /usr/local/bin/phoenix
|
COPY --from=builder /app/main /usr/local/bin/phoenix
|
||||||
|
@ -21,7 +20,6 @@ COPY templates ./templates
|
||||||
|
|
||||||
RUN mkdir /var/lib/phoenix
|
RUN mkdir /var/lib/phoenix
|
||||||
ENV P_DBPATH="/var/lib/phoenix/db.sqlite3"
|
ENV P_DBPATH="/var/lib/phoenix/db.sqlite3"
|
||||||
ENV P_PRODUCTION="true"
|
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
|
|
9
Makefile
9
Makefile
|
@ -1,10 +1,13 @@
|
||||||
all: fmt vet
|
all: fmt test
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
gofmt -s -w .
|
gofmt -s -w .
|
||||||
|
|
||||||
vet:
|
test:
|
||||||
go vet ./...
|
go test ./...
|
||||||
|
|
||||||
|
run:
|
||||||
|
go run .
|
||||||
|
|
||||||
favicons:
|
favicons:
|
||||||
convert -background none assets/favicons/favicon.svg -resize 16x16 assets/favicons/favicon-16.png
|
convert -background none assets/favicons/favicon.svg -resize 16x16 assets/favicons/favicon-16.png
|
||||||
|
|
|
@ -19,7 +19,8 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
button {
|
button,
|
||||||
|
textarea {
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
|
@ -35,11 +36,17 @@ button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
input:focus,
|
input:focus,
|
||||||
input:hover,
|
input:hover,
|
||||||
button:active,
|
button:active,
|
||||||
button:hover,
|
button:hover,
|
||||||
button:focus {
|
button:focus,
|
||||||
|
textarea:focus,
|
||||||
|
textarea:hover {
|
||||||
border-color: #812abd;
|
border-color: #812abd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
31
assets/css/import.css
vendored
Normal file
31
assets/css/import.css
vendored
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
body {
|
||||||
|
padding: 2em 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 800px) {
|
||||||
|
body {
|
||||||
|
padding: 2em 10em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
max-width: 400px;
|
||||||
|
margin-top: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
margin-top: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
form textarea {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
form button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
|
@ -9,8 +9,29 @@
|
||||||
margin-top: 3em;
|
margin-top: 3em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group {
|
@media screen and (min-width: 600px) {
|
||||||
width: 100%;
|
.page {
|
||||||
|
padding: 2em 10em
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
gap: 4em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls a {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls a img {
|
||||||
|
filter: invert(100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
|
@ -18,23 +39,6 @@ h2 {
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
div > a {
|
img {
|
||||||
display: flex;
|
|
||||||
padding-top: 6px;
|
|
||||||
padding-bottom: 6px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
div > a > img {
|
|
||||||
filter: invert(100%);
|
filter: invert(100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 600px) {
|
|
||||||
.page {
|
|
||||||
padding: 2em 10em
|
|
||||||
}
|
|
||||||
.group {
|
|
||||||
max-width: 230px;
|
|
||||||
}
|
|
||||||
}
|
|
12
assets/css/index-list.css
Normal file
12
assets/css/index-list.css
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
.group {
|
||||||
|
width: 100%;
|
||||||
|
max-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links > a {
|
||||||
|
display: flex;
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
37
assets/css/index-tiles.css
Normal file
37
assets/css/index-tiles.css
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
.group {
|
||||||
|
width: 100%;
|
||||||
|
max-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links > a {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
width: 130px;
|
||||||
|
height: 130px;
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 2px solid #444;
|
||||||
|
border-radius: 5px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links > a:hover {
|
||||||
|
border-color: #812abd;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links img {
|
||||||
|
flex-grow: 1;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
|
@ -2,6 +2,21 @@ body {
|
||||||
padding: 2em 1em;
|
padding: 2em 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions a img {
|
||||||
|
filter: invert(100%);
|
||||||
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
|
@ -6,18 +6,29 @@ import (
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var Cfg Config
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
// A long and random secret string used for authorization.
|
||||||
SecretKey string `required:"true"`
|
SecretKey string `required:"true"`
|
||||||
|
// Path to the sqlite database.
|
||||||
DBPath string `required:"true"`
|
DBPath string `required:"true"`
|
||||||
|
|
||||||
LogLevel string `default:"warning"`
|
LogLevel string `default:"warning"`
|
||||||
EnableGinLogger bool `default:"false"`
|
|
||||||
Production bool `default:"true"`
|
// Allows you to skip authorization if the "Remote-User" header is specified.
|
||||||
|
// Don't use it if you don't know why you need it.
|
||||||
HeaderAuth bool `default:"false"`
|
HeaderAuth bool `default:"false"`
|
||||||
|
|
||||||
|
// Data for the first user.
|
||||||
|
// Optional, the site also allows you to create the first user.
|
||||||
DefaultUsername string
|
DefaultUsername string
|
||||||
DefaultPassword string
|
DefaultPassword string
|
||||||
|
|
||||||
// Controls the "secure" option for a token cookie.
|
// Controls the "secure" option for a token cookie.
|
||||||
SecureCookie bool `default:"true"`
|
SecureCookie bool `default:"true"`
|
||||||
|
|
||||||
|
// Site title.
|
||||||
Title string `default:"Phoenix"`
|
Title string `default:"Phoenix"`
|
||||||
// Any supported css value, embedded directly into every page.
|
// Any supported css value, embedded directly into every page.
|
||||||
FontFamily string `default:"sans-serif"`
|
FontFamily string `default:"sans-serif"`
|
||||||
|
@ -29,13 +40,12 @@ func GetConfig() (*Config, error) {
|
||||||
logrus.Infof("Config: %v", err)
|
logrus.Infof("Config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var cfg Config
|
err = envconfig.Process("p", &Cfg)
|
||||||
err = envconfig.Process("p", &cfg)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &cfg, nil
|
return &Cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg *Config) GetLogLevel() logrus.Level {
|
func (cfg *Config) GetLogLevel() logrus.Level {
|
||||||
|
@ -44,7 +54,7 @@ func (cfg *Config) GetLogLevel() logrus.Level {
|
||||||
return logrus.DebugLevel
|
return logrus.DebugLevel
|
||||||
case "info":
|
case "info":
|
||||||
return logrus.InfoLevel
|
return logrus.InfoLevel
|
||||||
case "warning":
|
case "warning", "warn":
|
||||||
return logrus.WarnLevel
|
return logrus.WarnLevel
|
||||||
case "error":
|
case "error":
|
||||||
return logrus.ErrorLevel
|
return logrus.ErrorLevel
|
||||||
|
|
|
@ -2,53 +2,94 @@ package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Admin struct {
|
type Admin struct {
|
||||||
ID uint64 `gorm:"primaryKey"`
|
ID int
|
||||||
Username string `gorm:"unique;notNull"`
|
Username string
|
||||||
Bcrypt string `gorm:"notNull"`
|
Bcrypt string
|
||||||
}
|
}
|
||||||
|
|
||||||
func CountAdmins(db *gorm.DB) int64 {
|
func CountAdmins() (int64, error) {
|
||||||
var admins []Admin
|
|
||||||
var count int64
|
var count int64
|
||||||
db.Model(&admins).Count(&count)
|
query := `SELECT COUNT(*) FROM admins`
|
||||||
return count
|
if err := DB.QueryRow(query).Scan(&count); err != nil {
|
||||||
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateAdmin(db *gorm.DB, username string, password string) (Admin, error) {
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateAdmin(username string, password string) (*Admin, error) {
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 10)
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Admin{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
admin := Admin{
|
query := `
|
||||||
Username: username,
|
INSERT INTO admins(username, bcrypt)
|
||||||
Bcrypt: string(hash),
|
VALUES (?, ?)
|
||||||
}
|
RETURNING id
|
||||||
result := db.Create(&admin)
|
`
|
||||||
|
|
||||||
if result.Error != nil {
|
|
||||||
return Admin{}, result.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
return admin, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func AuthorizeAdmin(db *gorm.DB, username string, password string) (Admin, error) {
|
|
||||||
var admin Admin
|
var admin Admin
|
||||||
result := db.Where("username = ?", username).First(&admin)
|
admin.Username = username
|
||||||
|
admin.Bcrypt = string(hash)
|
||||||
|
|
||||||
if result.Error != nil {
|
err = DB.
|
||||||
return Admin{}, result.Error
|
QueryRow(query, admin.Username, admin.Bcrypt).
|
||||||
}
|
Scan(&admin.ID)
|
||||||
|
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(admin.Bcrypt), []byte(password))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Admin{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return admin, nil
|
return &admin, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAdminIfPasswordMatches(username string, password string) (*Admin, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, username, bcrypt
|
||||||
|
FROM admins
|
||||||
|
WHERE username = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
var admin Admin
|
||||||
|
err := DB.
|
||||||
|
QueryRow(query, username).
|
||||||
|
Scan(&admin.ID, &admin.Username, &admin.Bcrypt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = bcrypt.CompareHashAndPassword([]byte(admin.Bcrypt), []byte(password))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &admin, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteAdmin(id int) error {
|
||||||
|
query := `
|
||||||
|
DELETE FROM admins
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
res, err := DB.Exec(query, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowsAffected != 1 {
|
||||||
|
return ErrWrongNumberOfAffectedRows
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
59
database/admins_test.go
Normal file
59
database/admins_test.go
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdmins(t *testing.T) {
|
||||||
|
initTestDatabase(t)
|
||||||
|
defer deleteTestDatabase(t)
|
||||||
|
|
||||||
|
// We should have no admins.
|
||||||
|
count, err := CountAdmins()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatal("user count is not zero")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the first user.
|
||||||
|
username := "test"
|
||||||
|
password := "test"
|
||||||
|
admin, err := CreateAdmin(username, password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check password and get admin.
|
||||||
|
dbAdmin, err := GetAdminIfPasswordMatches(username, password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if dbAdmin.ID != admin.ID {
|
||||||
|
t.Fatal("wrong admin id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check wrong password handling.
|
||||||
|
if _, err := GetAdminIfPasswordMatches("test", "wrong-password"); err == nil {
|
||||||
|
t.Fatal("wrong password was accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count users again.
|
||||||
|
count, err = CountAdmins()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatal("user count is not one")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete user.
|
||||||
|
if err := DeleteAdmin(admin.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
21
database/connection.go
Normal file
21
database/connection.go
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/ordinary-dev/phoenix/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DB *sql.DB
|
||||||
|
|
||||||
|
func EstablishDatabaseConnection(cfg *config.Config) error {
|
||||||
|
var err error
|
||||||
|
DB, err = sql.Open("sqlite3", cfg.DBPath)
|
||||||
|
|
||||||
|
if err := DB.Ping(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
29
database/connection_test.go
Normal file
29
database/connection_test.go
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const TEST_DB_PATH = "/tmp/phoenix.sqlite3"
|
||||||
|
|
||||||
|
func initTestDatabase(t *testing.T) {
|
||||||
|
var err error
|
||||||
|
DB, err = sql.Open("sqlite3", TEST_DB_PATH)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ApplyMigrations(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteTestDatabase(t *testing.T) {
|
||||||
|
if err := os.Remove(TEST_DB_PATH); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,19 +0,0 @@
|
||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/ordinary-dev/phoenix/config"
|
|
||||||
"gorm.io/driver/sqlite"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetDatabaseConnection(cfg *config.Config) (*gorm.DB, error) {
|
|
||||||
db, err := gorm.Open(sqlite.Open(cfg.DBPath), &gorm.Config{})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migrate the schema
|
|
||||||
db.AutoMigrate(&Admin{}, &Group{}, &Link{})
|
|
||||||
|
|
||||||
return db, nil
|
|
||||||
}
|
|
9
database/errors.go
Normal file
9
database/errors.go
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrWrongNumberOfAffectedRows = errors.New("wrong number of affected rows")
|
||||||
|
)
|
|
@ -1,7 +1,106 @@
|
||||||
package database
|
package database
|
||||||
|
|
||||||
type Group struct {
|
type Group struct {
|
||||||
ID uint64 `gorm:"primaryKey"`
|
ID int `json:"id"`
|
||||||
Name string `gorm:"unique,notNull"`
|
Name string `json:"name"`
|
||||||
Links []Link `gorm:"constraint:OnDelete:CASCADE;"`
|
Links []Link `json:"links"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetGroupsWithLinks() ([]Group, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name
|
||||||
|
FROM groups
|
||||||
|
ORDER BY groups.id
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := DB.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var groups []Group
|
||||||
|
for rows.Next() {
|
||||||
|
var group Group
|
||||||
|
if err := rows.Scan(&group.ID, &group.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
groups = append(groups, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range groups {
|
||||||
|
groups[i].Links, err = GetLinksFromGroup(groups[i].ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new group in the database.
|
||||||
|
// The function fills in the ID.
|
||||||
|
func CreateGroup(group *Group) error {
|
||||||
|
query := `
|
||||||
|
INSERT INTO groups (name)
|
||||||
|
VALUES (?)
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
|
||||||
|
if err := DB.QueryRow(query, group.Name).Scan(&group.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateGroup(id int, name string) error {
|
||||||
|
query := `
|
||||||
|
UPDATE groups
|
||||||
|
SET name = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
res, err := DB.Exec(query, name, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowsAffected != 1 {
|
||||||
|
return ErrWrongNumberOfAffectedRows
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteGroup(groupID int) error {
|
||||||
|
query := `
|
||||||
|
DELETE FROM groups
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
res, err := DB.Exec(query, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowsAffected != 1 {
|
||||||
|
return ErrWrongNumberOfAffectedRows
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
47
database/groups_test.go
Normal file
47
database/groups_test.go
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGroups(t *testing.T) {
|
||||||
|
initTestDatabase(t)
|
||||||
|
defer deleteTestDatabase(t)
|
||||||
|
|
||||||
|
// Create the first group.
|
||||||
|
group := Group{
|
||||||
|
Name: "test",
|
||||||
|
}
|
||||||
|
if err := CreateGroup(&group); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if group.ID == 0 {
|
||||||
|
t.Fatal("group id is zero")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update group.
|
||||||
|
if err := UpdateGroup(group.ID, "new-name"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read groups.
|
||||||
|
groupList, err := GetGroupsWithLinks()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(groupList) != 1 {
|
||||||
|
t.Fatal("group list length is not one")
|
||||||
|
}
|
||||||
|
|
||||||
|
if groupList[0].Name != "new-name" {
|
||||||
|
t.Fatal("wrong group name")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete group.
|
||||||
|
if err := DeleteGroup(group.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,124 @@
|
||||||
package database
|
package database
|
||||||
|
|
||||||
type Link struct {
|
type Link struct {
|
||||||
ID uint64 `gorm:"primaryKey"`
|
ID int `json:"id"`
|
||||||
Name string `gorm:"notNull"`
|
Name string `json:"name"`
|
||||||
Href string `gorm:"notNull"`
|
Href string `json:"href"`
|
||||||
GroupID uint64 `gorm:"notNull"`
|
GroupID int `json:"-"`
|
||||||
Icon *string
|
Icon *string `json:"icon,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLinksFromGroup(groupID int) ([]Link, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, href, group_id, icon
|
||||||
|
FROM links
|
||||||
|
WHERE group_id = ?
|
||||||
|
ORDER BY id
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := DB.Query(query, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var links []Link
|
||||||
|
for rows.Next() {
|
||||||
|
var link Link
|
||||||
|
if err := rows.Scan(&link.ID, &link.Name, &link.Href, &link.GroupID, &link.Icon); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
links = append(links, link)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return links, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLink(id int) (*Link, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, href, group_id, icon
|
||||||
|
FROM links
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
var link Link
|
||||||
|
err := DB.
|
||||||
|
QueryRow(query, id).
|
||||||
|
Scan(&link.ID, &link.Name, &link.Href, &link.GroupID, &link.Icon)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &link, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new link in the database.
|
||||||
|
// The function fills in the ID.
|
||||||
|
func CreateLink(link *Link) error {
|
||||||
|
query := `
|
||||||
|
INSERT INTO links (name, href, group_id, icon)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
|
||||||
|
err := DB.
|
||||||
|
QueryRow(query, link.Name, link.Href, link.GroupID, link.Icon).
|
||||||
|
Scan(&link.ID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateLink(link *Link) error {
|
||||||
|
query := `
|
||||||
|
UPDATE links
|
||||||
|
SET name = ?, href = ?, group_id = ?, icon = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
res, err := DB.Exec(query, link.Name, link.Href, link.GroupID, link.Icon, link.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowsAffected != 1 {
|
||||||
|
return ErrWrongNumberOfAffectedRows
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteLink(linkID int) error {
|
||||||
|
query := `
|
||||||
|
DELETE FROM links
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
res, err := DB.Exec(query, linkID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowsAffected != 1 {
|
||||||
|
return ErrWrongNumberOfAffectedRows
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
51
database/links_test.go
Normal file
51
database/links_test.go
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLinks(t *testing.T) {
|
||||||
|
initTestDatabase(t)
|
||||||
|
defer deleteTestDatabase(t)
|
||||||
|
|
||||||
|
// Create the first group.
|
||||||
|
group := Group{
|
||||||
|
Name: "test",
|
||||||
|
}
|
||||||
|
if err := CreateGroup(&group); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the first link.
|
||||||
|
icon := "test/icon"
|
||||||
|
link := Link{
|
||||||
|
Name: "test",
|
||||||
|
Href: "/test",
|
||||||
|
GroupID: group.ID,
|
||||||
|
Icon: &icon,
|
||||||
|
}
|
||||||
|
if err := CreateLink(&link); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if link.ID == 0 {
|
||||||
|
t.Fatal("link id is zero")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update link.
|
||||||
|
link.Href = "/new-href"
|
||||||
|
if err := UpdateLink(&link); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete link.
|
||||||
|
if err := DeleteLink(link.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete group.
|
||||||
|
if err := DeleteGroup(group.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
98
database/migrations.go
Normal file
98
database/migrations.go
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// List of migrations that should be applied.
|
||||||
|
// Migration ID = index + 1.
|
||||||
|
var migrations = []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS admins (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
bcrypt TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS groups (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS links (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
href TEXT NOT NULL,
|
||||||
|
group_id INTEGER NOT NULL,
|
||||||
|
icon TEXT,
|
||||||
|
CONSTRAINT fk_groups_links
|
||||||
|
FOREIGN KEY (group_id)
|
||||||
|
REFERENCES groups(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplyMigrations() error {
|
||||||
|
// Create a table to record applied migrations and retrieve the saved data.
|
||||||
|
_, err := DB.Exec(`CREATE TABLE IF NOT EXISTS migrations (
|
||||||
|
version INTEGER NOT NULL DEFAULT 0
|
||||||
|
)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentVersion int
|
||||||
|
err = DB.
|
||||||
|
QueryRow("SELECT version FROM migrations").
|
||||||
|
Scan(¤tVersion)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The table is empty, create a record.
|
||||||
|
_, err = DB.Exec("INSERT INTO migrations (version) VALUES (0)")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply all migrations.
|
||||||
|
for i, migration := range migrations {
|
||||||
|
migrationID := i + 1
|
||||||
|
if migrationID <= currentVersion {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := applyMigration(migrationID, migration); err != nil {
|
||||||
|
return fmt.Errorf("migration #%d: %w", migrationID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("Migration #%v has been applied", migrationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyMigration(migrationID int, query string) error {
|
||||||
|
tx, err := DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(query); err != nil {
|
||||||
|
return fmt.Errorf("error when applying migration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec("UPDATE migrations SET version = ?", migrationID); err != nil {
|
||||||
|
return fmt.Errorf("error when updating schema version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
17
database/migrations_test.go
Normal file
17
database/migrations_test.go
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMigrations(t *testing.T) {
|
||||||
|
initTestDatabase(t)
|
||||||
|
defer deleteTestDatabase(t)
|
||||||
|
|
||||||
|
// We should be able to call the function multiple times.
|
||||||
|
if err := ApplyMigrations(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
38
go.mod
38
go.mod
|
@ -1,43 +1,17 @@
|
||||||
module github.com/ordinary-dev/phoenix
|
module github.com/ordinary-dev/phoenix
|
||||||
|
|
||||||
go 1.20
|
go 1.22
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/golang-jwt/jwt/v5 v5.0.0
|
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/kelseyhightower/envconfig v1.4.0
|
github.com/kelseyhightower/envconfig v1.4.0
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
golang.org/x/crypto v0.14.0
|
golang.org/x/crypto v0.21.0
|
||||||
gorm.io/driver/sqlite v1.5.4
|
|
||||||
gorm.io/gorm v1.25.5
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.9.1 // indirect
|
github.com/stretchr/testify v1.8.3 // indirect
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
golang.org/x/sys v0.18.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
|
||||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
|
||||||
github.com/leodido/go-urn v1.2.4 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.17 // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
|
||||||
golang.org/x/arch v0.3.0 // indirect
|
|
||||||
golang.org/x/net v0.17.0 // indirect
|
|
||||||
golang.org/x/sys v0.13.0 // indirect
|
|
||||||
golang.org/x/text v0.13.0 // indirect
|
|
||||||
google.golang.org/protobuf v1.30.0 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
92
go.sum
92
go.sum
|
@ -1,104 +1,28 @@
|
||||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
|
||||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
|
||||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
|
||||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
|
||||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
|
||||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
|
||||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
|
||||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
|
||||||
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
||||||
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
|
||||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
|
||||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
|
||||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
|
||||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
|
||||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
|
||||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
|
||||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
|
||||||
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
|
|
||||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
|
||||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
|
||||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
|
||||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
|
||||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
|
||||||
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
|
||||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
|
|
||||||
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
|
|
||||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
|
||||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
|
||||||
|
|
33
jwttoken/token.go
Normal file
33
jwttoken/token.go
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
package jwttoken
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
|
"github.com/ordinary-dev/phoenix/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TOKEN_LIFETIME_IN_SECONDS = 60 * 60 * 24 * 30
|
||||||
|
TOKEN_COOKIE_NAME = "phoenix-token"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetJWTToken() (string, error) {
|
||||||
|
claims := jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Second * TOKEN_LIFETIME_IN_SECONDS)),
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(config.Cfg.SecretKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TokenToCookie(value string) *http.Cookie {
|
||||||
|
return &http.Cookie{
|
||||||
|
Name: TOKEN_COOKIE_NAME,
|
||||||
|
Value: value,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: config.Cfg.SecureCookie,
|
||||||
|
MaxAge: TOKEN_LIFETIME_IN_SECONDS,
|
||||||
|
}
|
||||||
|
}
|
38
main.go
38
main.go
|
@ -4,42 +4,56 @@ import (
|
||||||
"github.com/ordinary-dev/phoenix/config"
|
"github.com/ordinary-dev/phoenix/config"
|
||||||
"github.com/ordinary-dev/phoenix/database"
|
"github.com/ordinary-dev/phoenix/database"
|
||||||
"github.com/ordinary-dev/phoenix/views"
|
"github.com/ordinary-dev/phoenix/views"
|
||||||
"github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Configure logger
|
// Configure logger
|
||||||
logrus.SetFormatter(&logrus.TextFormatter{
|
log.SetFormatter(&log.TextFormatter{
|
||||||
FullTimestamp: true,
|
FullTimestamp: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Read config
|
// Read config
|
||||||
cfg, err := config.GetConfig()
|
cfg, err := config.GetConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatalf("%v", err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set log level
|
// Set log level
|
||||||
logLevel := cfg.GetLogLevel()
|
logLevel := cfg.GetLogLevel()
|
||||||
logrus.SetLevel(logLevel)
|
log.SetLevel(logLevel)
|
||||||
logrus.Infof("Setting log level to %v", logLevel)
|
log.Infof("Setting log level to %v", logLevel)
|
||||||
|
|
||||||
// Connect to the database
|
// Connect to the database
|
||||||
db, err := database.GetDatabaseConnection(cfg)
|
err = database.EstablishDatabaseConnection(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Fatalf("%v", err)
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply migrations.
|
||||||
|
if err := database.ApplyMigrations(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the first user
|
// Create the first user
|
||||||
if cfg.DefaultUsername != "" && cfg.DefaultPassword != "" {
|
if cfg.DefaultUsername != "" && cfg.DefaultPassword != "" {
|
||||||
if database.CountAdmins(db) < 1 {
|
adminCount, err := database.CountAdmins()
|
||||||
_, err := database.CreateAdmin(db, cfg.DefaultUsername, cfg.DefaultPassword)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logrus.Errorf("%v", err)
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if adminCount < 1 {
|
||||||
|
_, err := database.CreateAdmin(cfg.DefaultUsername, cfg.DefaultPassword)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
engine := views.GetGinEngine(cfg, db)
|
server, err := views.GetHttpServer()
|
||||||
engine.Run(":8080")
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server.ListenAndServe()
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,10 +8,11 @@ Self-hosted start page without the extra stuff.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- No javascript
|
- No javascript
|
||||||
- Relatively low resource consumption (around 7 MiB of RAM)
|
|
||||||
- Authorization support
|
- Authorization support
|
||||||
- SSO via Trusted Header Auth (_Reverse Proxy_)
|
- SSO via Trusted Header Auth (_Reverse Proxy_)
|
||||||
- Font Awesome integration
|
- Font Awesome integration
|
||||||
|
- Multiple styles
|
||||||
|
- Export and import
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
Service settings can be set through environment variables.
|
Service settings can be set through environment variables.
|
||||||
|
@ -21,8 +22,6 @@ Service settings can be set through environment variables.
|
||||||
| P_DBPATH | Path to the sqlite database. | Docker: `/var/lib/phoenix/db.sqlite3` |
|
| P_DBPATH | Path to the sqlite database. | Docker: `/var/lib/phoenix/db.sqlite3` |
|
||||||
| P_SECRETKEY | A long and random secret string used for authorization. | |
|
| P_SECRETKEY | A long and random secret string used for authorization. | |
|
||||||
| P_LOGLEVEL | Log level settings: `debug`, `info`, `warning`, `error`, `fatal` | `warning` |
|
| P_LOGLEVEL | Log level settings: `debug`, `info`, `warning`, `error`, `fatal` | `warning` |
|
||||||
| P_ENABLEGINLOGGER | Enable gin's logging middleware. Can create a lot of logs. | `false` |
|
|
||||||
| P_PRODUCTION | Is this instance running in production mode? | `true` |
|
|
||||||
| P_HEADERAUTH | Enable Trusted Header Auth (SSO) | `false` |
|
| P_HEADERAUTH | Enable Trusted Header Auth (SSO) | `false` |
|
||||||
| P_DEFAULTUSERNAME | Data for the first user. | |
|
| P_DEFAULTUSERNAME | Data for the first user. | |
|
||||||
| P_DEFAULTPASSWORD | Data for the first user. | |
|
| P_DEFAULTPASSWORD | Data for the first user. | |
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{{define "head"}}
|
{{define "head"}}
|
||||||
<title>{{.WebsiteTitle}}</title>
|
<title>{{ .title }}</title>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="description" content="A minimalistic start page with your collection of links to important sites." />
|
<meta name="description" content="A minimalistic start page with your collection of links to important sites." />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
@ -10,7 +10,7 @@
|
||||||
<link rel="apple-touch-icon" href="/assets/favicons/favicon-180.png" />
|
<link rel="apple-touch-icon" href="/assets/favicons/favicon-180.png" />
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: {{.FontFamily}};
|
font-family: {{ .fontFamily }};
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{{end}}
|
{{end}}
|
27
templates/import.html.tmpl
Normal file
27
templates/import.html.tmpl
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
{{template "head" .}}
|
||||||
|
<link rel="stylesheet" href="assets/css/import.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Import</h1>
|
||||||
|
|
||||||
|
<a href="/settings">Settings</a>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Importing does not erase existing links, but may create duplicates.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form action="/import" method="POST">
|
||||||
|
<label for="exportFile">JSON data</label>
|
||||||
|
<textarea
|
||||||
|
id="exportFile"
|
||||||
|
name="exportFile"
|
||||||
|
placeholder="{ groups: [] }"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -2,33 +2,55 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
{{template "head" .}}
|
{{template "head" .}}
|
||||||
<link rel="stylesheet" href="assets/css/index.css" />
|
<link rel="stylesheet" href="/assets/css/index-common.css" />
|
||||||
|
<link rel="stylesheet" href="/assets/css/index-{{ .style }}.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<h1>{{.WebsiteTitle}}</h1>
|
<h1>{{ .title }}</h1>
|
||||||
|
|
||||||
{{ if not .groups }}
|
{{ if not .groups }}
|
||||||
<p>
|
<p>
|
||||||
You don't have any links.
|
You don't have any links.<br />
|
||||||
Go to <a href="/settings" style="text-decoration: underline">settings</a> and create one.
|
Go to settings and create one.
|
||||||
</p>
|
</p>
|
||||||
{{else}}
|
|
||||||
<a href="/settings">Settings</a>
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<a href="/settings">
|
||||||
|
<img src="/assets/icons/solid/gear.svg" width="20" height="20" /> Settings
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{{ if ne .style "list" }}
|
||||||
|
<a href="/?style=list">
|
||||||
|
<img src="/assets/icons/solid/list.svg" width="20" height="20" /> List
|
||||||
|
</a>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if ne .style "tiles" }}
|
||||||
|
<a href="/?style=tiles">
|
||||||
|
<img src="/assets/icons/solid/table-cells-large.svg" width="20" height="20" /> Tiles
|
||||||
|
</a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{{range .groups}}
|
{{range .groups}}
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<h2>{{.Name}}</h2>
|
<h2>{{.Name}}</h2>
|
||||||
|
<div class="links">
|
||||||
{{range .Links}}
|
{{range .Links}}
|
||||||
<a href="{{.Href}}" target="_blank" rel="noreferrer">
|
<a href="{{.Href}}" target="_blank" rel="noreferrer">
|
||||||
{{ if .Icon }}
|
{{ if .Icon }}
|
||||||
<img src="/assets/icons/{{.Icon}}.svg" width="20" height="20" />
|
<img src="/assets/icons/{{.Icon}}.svg" width="20" height="20" />
|
||||||
|
{{ else }}
|
||||||
|
<img src="/assets/icons/solid/globe.svg" width="20" height="20" />
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{.Name}}
|
{{.Name}}
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -6,12 +6,23 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
<a href="/">Main page</a>
|
|
||||||
|
<div class="actions">
|
||||||
|
<a href="/">
|
||||||
|
<img src="/assets/icons/solid/house.svg" width="20" height="20" /> Main page
|
||||||
|
</a>
|
||||||
|
<a href="/export">
|
||||||
|
<img src="/assets/icons/solid/file-export.svg" width="20" height="20" /> Export links
|
||||||
|
</a>
|
||||||
|
<a href="/import">
|
||||||
|
<img src="/assets/icons/solid/file-import.svg" width="20" height="20" /> Import links
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{range .groups}}
|
{{range .groups}}
|
||||||
<h2 id="group-{{.ID}}">Group "{{.Name}}"</h2>
|
<h2 id="group-{{.ID}}">Group "{{.Name}}"</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<form method="POST" action="/api/groups/{{.ID}}/put" class="innerForm">
|
<form method="POST" action="/groups/{{.ID}}/update" class="innerForm">
|
||||||
<input
|
<input
|
||||||
value="{{.Name}}"
|
value="{{.Name}}"
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
|
@ -25,7 +36,7 @@
|
||||||
<img src="/assets/svg/floppy-disk-solid.svg" width="16px" height="16px" />
|
<img src="/assets/svg/floppy-disk-solid.svg" width="16px" height="16px" />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/api/groups/{{.ID}}/delete">
|
<form method="POST" action="/groups/{{.ID}}/delete">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
aria-label="Delete the group"
|
aria-label="Delete the group"
|
||||||
|
@ -37,8 +48,7 @@
|
||||||
|
|
||||||
{{range .Links}}
|
{{range .Links}}
|
||||||
<div class="row" id="link-{{.ID}}">
|
<div class="row" id="link-{{.ID}}">
|
||||||
<form method="POST" action="/api/links/{{.ID}}/put" class="innerForm">
|
<form method="POST" action="/links/{{.ID}}/update" class="innerForm">
|
||||||
<!-- method: put -->
|
|
||||||
<input
|
<input
|
||||||
class="small-row"
|
class="small-row"
|
||||||
value="{{ if .Icon }}{{ .Icon }}{{ end }}"
|
value="{{ if .Icon }}{{ .Icon }}{{ end }}"
|
||||||
|
@ -65,7 +75,7 @@
|
||||||
<img src="/assets/svg/floppy-disk-solid.svg" width="16px" height="16px" />
|
<img src="/assets/svg/floppy-disk-solid.svg" width="16px" height="16px" />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/api/links/{{.ID}}/delete">
|
<form method="POST" action="/links/{{.ID}}/delete">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
aria-label="Delete the link"
|
aria-label="Delete the link"
|
||||||
|
@ -76,7 +86,7 @@
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form action="/api/links" method="POST" class="row">
|
<form action="/links" method="POST" class="row">
|
||||||
<input
|
<input
|
||||||
class="small-row"
|
class="small-row"
|
||||||
name="icon"
|
name="icon"
|
||||||
|
@ -110,7 +120,7 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<h2>New group</h2>
|
<h2>New group</h2>
|
||||||
<form method="POST" action="/api/groups" class="row">
|
<form method="POST" action="/groups" class="row">
|
||||||
<input
|
<input
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
name="groupName"
|
name="groupName"
|
||||||
|
|
15
testutils/workingdir.go
Normal file
15
testutils/workingdir.go
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
package testutils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Change the current directory to the project directory.
|
||||||
|
// Useful for tests that work with files.
|
||||||
|
func ResetWorkingDir() error {
|
||||||
|
_, filename, _, _ := runtime.Caller(0)
|
||||||
|
dir := path.Join(path.Dir(filename), "..")
|
||||||
|
return os.Chdir(dir)
|
||||||
|
}
|
176
views/auth.go
176
views/auth.go
|
@ -1,176 +0,0 @@
|
||||||
package views
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
"github.com/ordinary-dev/phoenix/config"
|
|
||||||
"github.com/ordinary-dev/phoenix/database"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
const TOKEN_LIFETIME_IN_SECONDS = 60 * 60 * 24 * 30
|
|
||||||
|
|
||||||
func ShowRegistrationForm(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
|
|
||||||
return func(ctx *gin.Context) {
|
|
||||||
if database.CountAdmins(db) > 0 {
|
|
||||||
ShowError(ctx, cfg, errors.New("At least 1 user already exists"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Render(ctx, cfg, http.StatusOK, "auth.html.tmpl", gin.H{
|
|
||||||
"title": "Create an account",
|
|
||||||
"description": "To prevent other people from seeing your links, create an account.",
|
|
||||||
"button": "Create",
|
|
||||||
"formAction": "/api/users",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ShowLoginForm(cfg *config.Config) gin.HandlerFunc {
|
|
||||||
return func(ctx *gin.Context) {
|
|
||||||
Render(ctx, cfg, http.StatusOK, "auth.html.tmpl", gin.H{
|
|
||||||
"title": "Sign in",
|
|
||||||
"description": "Authorization is required to view this page.",
|
|
||||||
"button": "Sign in",
|
|
||||||
"formAction": "/api/users/signin",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Requires the user to log in before viewing the page.
|
|
||||||
// Returns error if the user is not authorized.
|
|
||||||
// If `nil` is returned instead of an error, it is safe to display protected content.
|
|
||||||
func RequireAuth(c *gin.Context, cfg *config.Config) (*jwt.RegisteredClaims, error) {
|
|
||||||
tokenValue, err := c.Cookie("phoenix-token")
|
|
||||||
|
|
||||||
// Anonymous visitor
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check token
|
|
||||||
token, err := jwt.ParseWithClaims(tokenValue, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
|
|
||||||
// Validate the alg
|
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
||||||
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
|
|
||||||
}
|
|
||||||
|
|
||||||
return []byte(cfg.SecretKey), nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
claims, ok := token.Claims.(*jwt.RegisteredClaims)
|
|
||||||
if !ok || !token.Valid {
|
|
||||||
return nil, errors.New("Token is invalid")
|
|
||||||
}
|
|
||||||
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func AuthMiddleware(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
|
||||||
return func(ctx *gin.Context) {
|
|
||||||
claims, err := RequireAuth(ctx, cfg)
|
|
||||||
if err != nil {
|
|
||||||
if cfg.HeaderAuth && ctx.Request.Header.Get("Remote-User") != "" {
|
|
||||||
// Generate access token.
|
|
||||||
token, err := GetJWTToken(cfg)
|
|
||||||
if err != nil {
|
|
||||||
ShowError(ctx, cfg, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
SetTokenCookie(ctx, token, cfg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if database.CountAdmins(db) < 1 {
|
|
||||||
ctx.Redirect(http.StatusFound, "/registration")
|
|
||||||
} else {
|
|
||||||
ctx.Redirect(http.StatusFound, "/signin")
|
|
||||||
}
|
|
||||||
ctx.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new token if the old one is about to expire
|
|
||||||
if time.Now().Add(time.Second * (TOKEN_LIFETIME_IN_SECONDS / 2)).After(claims.ExpiresAt.Time) {
|
|
||||||
newToken, err := GetJWTToken(cfg)
|
|
||||||
if err != nil {
|
|
||||||
ShowError(ctx, cfg, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
SetTokenCookie(ctx, newToken, cfg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetJWTToken(cfg *config.Config) (string, error) {
|
|
||||||
claims := jwt.RegisteredClaims{
|
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Second * TOKEN_LIFETIME_IN_SECONDS)),
|
|
||||||
}
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
||||||
return token.SignedString([]byte(cfg.SecretKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateUser(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
|
||||||
return func(ctx *gin.Context) {
|
|
||||||
if database.CountAdmins(db) > 0 {
|
|
||||||
ShowError(ctx, cfg, errors.New("At least 1 user already exists"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to create a user.
|
|
||||||
username := ctx.PostForm("username")
|
|
||||||
password := ctx.PostForm("password")
|
|
||||||
_, err := database.CreateAdmin(db, username, password)
|
|
||||||
if err != nil {
|
|
||||||
ShowError(ctx, cfg, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate access token.
|
|
||||||
token, err := GetJWTToken(cfg)
|
|
||||||
if err != nil {
|
|
||||||
ShowError(ctx, cfg, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
SetTokenCookie(ctx, token, cfg)
|
|
||||||
|
|
||||||
// Redirect to homepage.
|
|
||||||
ctx.Redirect(http.StatusFound, "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AuthorizeUser(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
|
|
||||||
return func(ctx *gin.Context) {
|
|
||||||
// Check credentials.
|
|
||||||
username := ctx.PostForm("username")
|
|
||||||
password := ctx.PostForm("password")
|
|
||||||
_, err := database.AuthorizeAdmin(db, username, password)
|
|
||||||
if err != nil {
|
|
||||||
ShowError(ctx, cfg, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate an access token.
|
|
||||||
token, err := GetJWTToken(cfg)
|
|
||||||
if err != nil {
|
|
||||||
ShowError(ctx, cfg, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
SetTokenCookie(ctx, token, cfg)
|
|
||||||
|
|
||||||
// Redirect to homepage.
|
|
||||||
ctx.Redirect(http.StatusFound, "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save token in cookies
|
|
||||||
func SetTokenCookie(c *gin.Context, token string, cfg *config.Config) {
|
|
||||||
c.SetCookie("phoenix-token", token, TOKEN_LIFETIME_IN_SECONDS, "/", "", cfg.SecureCookie, true)
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
package views
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/ordinary-dev/phoenix/config"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ShowError(ctx *gin.Context, cfg *config.Config, err error) {
|
|
||||||
Render(ctx, cfg, http.StatusBadRequest, "error.html.tmpl", gin.H{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
ctx.Abort()
|
|
||||||
}
|
|
|
@ -1,70 +0,0 @@
|
||||||
package views
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/ordinary-dev/phoenix/config"
|
|
||||||
"github.com/ordinary-dev/phoenix/database"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
)
|
|
||||||
|
|
||||||
func CreateGroup(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
|
|
||||||
return func(ctx *gin.Context) {
|
|
||||||
// Save new group to the database.
|
|
||||||
group := database.Group{
|
|
||||||
Name: ctx.PostForm("groupName"),
|
|
||||||
}
|
|
||||||
if result := db.Create(&group); result.Error != nil {
|
|
||||||
ShowError(ctx, cfg, result.Error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// This page is called from the settings, return the user back.
|
|
||||||
ctx.Redirect(http.StatusFound, fmt.Sprintf("/settings#group-%v", group.ID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func UpdateGroup(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
|
|
||||||
return func(ctx *gin.Context) {
|
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
ShowError(ctx, cfg, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var group database.Group
|
|
||||||
if result := db.First(&group, id); result.Error != nil {
|
|
||||||
ShowError(ctx, cfg, result.Error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
group.Name = ctx.PostForm("groupName")
|
|
||||||
if result := db.Save(&group); result.Error != nil {
|
|
||||||
ShowError(ctx, cfg, result.Error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// This page is called from the settings, return the user back.
|
|
||||||
ctx.Redirect(http.StatusFound, fmt.Sprintf("/settings#group-%v", group.ID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func DeleteGroup(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
|
|
||||||
return func(ctx *gin.Context) {
|
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
ShowError(ctx, cfg, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if result := db.Delete(&database.Group{}, id); result.Error != nil {
|
|
||||||
ShowError(ctx, cfg, result.Error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to settings.
|
|
||||||
ctx.Redirect(http.StatusFound, "/settings")
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,29 +0,0 @@
|
||||||
package views
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/ordinary-dev/phoenix/config"
|
|
||||||
"github.com/ordinary-dev/phoenix/database"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ShowMainPage(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
|
|
||||||
return func(ctx *gin.Context) {
|
|
||||||
// Get a list of groups with links
|
|
||||||
var groups []database.Group
|
|
||||||
result := db.
|
|
||||||
Model(&database.Group{}).
|
|
||||||
Preload("Links").
|
|
||||||
Find(&groups)
|
|
||||||
|
|
||||||
if result.Error != nil {
|
|
||||||
ShowError(ctx, cfg, result.Error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Render(ctx, cfg, http.StatusOK, "index.html.tmpl", gin.H{
|
|
||||||
"groups": groups,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,90 +0,0 @@
|
||||||
package views
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/ordinary-dev/phoenix/config"
|
|
||||||
"github.com/ordinary-dev/phoenix/database"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
)
|
|
||||||
|
|
||||||
func CreateLink(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
|
|
||||||
return func(ctx *gin.Context) {
|
|
||||||
groupID, err := strconv.ParseUint(ctx.PostForm("groupID"), 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
ShowError(ctx, cfg, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
link := database.Link{
|
|
||||||
Name: ctx.PostForm("linkName"),
|
|
||||||
Href: ctx.PostForm("href"),
|
|
||||||
GroupID: groupID,
|
|
||||||
}
|
|
||||||
icon := ctx.PostForm("icon")
|
|
||||||
if icon == "" {
|
|
||||||
link.Icon = nil
|
|
||||||
} else {
|
|
||||||
link.Icon = &icon
|
|
||||||
}
|
|
||||||
if result := db.Create(&link); result.Error != nil {
|
|
||||||
ShowError(ctx, cfg, result.Error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to settings.
|
|
||||||
ctx.Redirect(http.StatusFound, fmt.Sprintf("/settings#link-%v", link.ID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func UpdateLink(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
|
|
||||||
return func(ctx *gin.Context) {
|
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
ShowError(ctx, cfg, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var link database.Link
|
|
||||||
if result := db.First(&link, id); result.Error != nil {
|
|
||||||
ShowError(ctx, cfg, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
link.Name = ctx.PostForm("linkName")
|
|
||||||
link.Href = ctx.PostForm("href")
|
|
||||||
icon := ctx.PostForm("icon")
|
|
||||||
if icon == "" {
|
|
||||||
link.Icon = nil
|
|
||||||
} else {
|
|
||||||
link.Icon = &icon
|
|
||||||
}
|
|
||||||
if result := db.Save(&link); result.Error != nil {
|
|
||||||
ShowError(ctx, cfg, result.Error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to settings.
|
|
||||||
ctx.Redirect(http.StatusFound, fmt.Sprintf("/settings#link-%v", link.ID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func DeleteLink(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
|
|
||||||
return func(ctx *gin.Context) {
|
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
ShowError(ctx, cfg, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if result := db.Delete(&database.Link{}, id); result.Error != nil {
|
|
||||||
ShowError(ctx, cfg, result.Error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to settings.
|
|
||||||
ctx.Redirect(http.StatusFound, "/settings")
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,63 +0,0 @@
|
||||||
package views
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/ordinary-dev/phoenix/config"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetGinEngine(cfg *config.Config, db *gorm.DB) *gin.Engine {
|
|
||||||
if cfg.Production {
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
engine := gin.New()
|
|
||||||
engine.Use(gin.Recovery())
|
|
||||||
if cfg.EnableGinLogger {
|
|
||||||
engine.Use(gin.Logger())
|
|
||||||
}
|
|
||||||
|
|
||||||
engine.LoadHTMLGlob("templates/*")
|
|
||||||
engine.Static("/assets", "./assets")
|
|
||||||
|
|
||||||
engine.Use(SecurityHeadersMiddleware)
|
|
||||||
|
|
||||||
engine.GET("/signin", ShowLoginForm(cfg))
|
|
||||||
engine.POST("/api/users/signin", AuthorizeUser(db, cfg))
|
|
||||||
|
|
||||||
engine.GET("/registration", ShowRegistrationForm(cfg, db))
|
|
||||||
engine.POST("/api/users", CreateUser(db, cfg))
|
|
||||||
|
|
||||||
// This group requires authorization before viewing.
|
|
||||||
protected := engine.Group("/")
|
|
||||||
protected.Use(AuthMiddleware(db, cfg))
|
|
||||||
|
|
||||||
// Main page
|
|
||||||
protected.GET("/", ShowMainPage(cfg, db))
|
|
||||||
|
|
||||||
protected.GET("/settings", ShowSettings(cfg, db))
|
|
||||||
|
|
||||||
// Create new group
|
|
||||||
protected.POST("/api/groups", CreateGroup(cfg, db))
|
|
||||||
|
|
||||||
// Update group
|
|
||||||
// HTML forms cannot be submitted using PUT or PATCH methods without javascript.
|
|
||||||
protected.POST("/api/groups/:id/put", UpdateGroup(cfg, db))
|
|
||||||
|
|
||||||
// Delete group
|
|
||||||
// HTML forms cannot be submitted using the DELETE method without javascript.
|
|
||||||
protected.POST("/api/groups/:id/delete", DeleteGroup(cfg, db))
|
|
||||||
|
|
||||||
// Create new link
|
|
||||||
protected.POST("/api/links", CreateLink(cfg, db))
|
|
||||||
|
|
||||||
// Update link.
|
|
||||||
// HTML forms cannot be submitted using PUT or PATCH methods without javascript.
|
|
||||||
protected.POST("/api/links/:id/put", UpdateLink(cfg, db))
|
|
||||||
|
|
||||||
// Delete link
|
|
||||||
// HTML forms cannot be submitted using the DELETE method without javascript.
|
|
||||||
protected.POST("/api/links/:id/delete", DeleteLink(cfg, db))
|
|
||||||
|
|
||||||
return engine
|
|
||||||
}
|
|
88
views/middleware/auth.go
Normal file
88
views/middleware/auth.go
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
|
"github.com/ordinary-dev/phoenix/config"
|
||||||
|
"github.com/ordinary-dev/phoenix/database"
|
||||||
|
"github.com/ordinary-dev/phoenix/jwttoken"
|
||||||
|
"github.com/ordinary-dev/phoenix/views/pages"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Try to find the access token in the request.
|
||||||
|
// Returns error if the user is not authorized.
|
||||||
|
// If `nil` is returned instead of an error, it is safe to display protected content.
|
||||||
|
func ParseToken(r *http.Request) (*jwt.RegisteredClaims, error) {
|
||||||
|
tokenCookie, err := r.Cookie(jwttoken.TOKEN_COOKIE_NAME)
|
||||||
|
|
||||||
|
// Anonymous visitor.
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check token.
|
||||||
|
token, err := jwt.ParseWithClaims(tokenCookie.Value, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
// Validate the alg.
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
|
}
|
||||||
|
|
||||||
|
return []byte(config.Cfg.SecretKey), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(*jwt.RegisteredClaims)
|
||||||
|
if !ok || !token.Valid {
|
||||||
|
return nil, fmt.Errorf("token is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequireAuth(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Check if SSO is enabled.
|
||||||
|
if config.Cfg.HeaderAuth && r.Header.Get("Remote-User") != "" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := ParseToken(r)
|
||||||
|
|
||||||
|
// Most likely the user is not authorized.
|
||||||
|
if err != nil {
|
||||||
|
count, err := database.CountAdmins()
|
||||||
|
if err != nil {
|
||||||
|
pages.ShowError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if count < 1 {
|
||||||
|
http.Redirect(w, r, "/registration", http.StatusFound)
|
||||||
|
} else {
|
||||||
|
http.Redirect(w, r, "/signin", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new token if the old one is about to expire
|
||||||
|
if time.Now().Add(time.Second * (jwttoken.TOKEN_LIFETIME_IN_SECONDS / 2)).After(claims.ExpiresAt.Time) {
|
||||||
|
newToken, err := jwttoken.GetJWTToken()
|
||||||
|
if err != nil {
|
||||||
|
pages.ShowError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, jwttoken.TokenToCookie(newToken))
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
22
views/middleware/logging.go
Normal file
22
views/middleware/logging.go
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
func LoggingMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"latency": time.Since(start),
|
||||||
|
"method": r.Method,
|
||||||
|
"path": r.URL.Path,
|
||||||
|
}).Info("Request")
|
||||||
|
})
|
||||||
|
}
|
18
views/middleware/security.go
Normal file
18
views/middleware/security.go
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Adds several headers to the response to improve security.
|
||||||
|
// For example, headers prevent embedding a site and passing information about the referrer.
|
||||||
|
func SecurityHeadersMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("Referrer-Policy", "same-origin")
|
||||||
|
w.Header().Set("Content-Security-Policy", "script-src 'self'; ")
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
18
views/pages/errors.go
Normal file
18
views/pages/errors.go
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ShowError(w http.ResponseWriter, statusCode int, err error) {
|
||||||
|
log.WithField("code", statusCode).Error(err)
|
||||||
|
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
Render("error.html.tmpl", w, map[string]any{
|
||||||
|
"title": "Error",
|
||||||
|
"description": "The request failed.",
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
29
views/pages/export.go
Normal file
29
views/pages/export.go
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/ordinary-dev/phoenix/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExportFile struct {
|
||||||
|
Groups []database.Group `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Export(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
groups, err := database.GetGroupsWithLinks()
|
||||||
|
if err != nil {
|
||||||
|
ShowError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Content-Disposition", "attachment; filename=phoenix.json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
enc.Encode(&ExportFile{
|
||||||
|
Groups: groups,
|
||||||
|
})
|
||||||
|
}
|
58
views/pages/groups.go
Normal file
58
views/pages/groups.go
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ordinary-dev/phoenix/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Save new group to the database.
|
||||||
|
group := database.Group{
|
||||||
|
Name: strings.TrimSpace(r.FormValue("groupName")),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.CreateGroup(&group); err != nil {
|
||||||
|
ShowError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// This page is called from the settings, return the user back.
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/settings#group-%v", group.ID), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ShowError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newName := strings.TrimSpace(r.FormValue("groupName"))
|
||||||
|
if err := database.UpdateGroup(int(id), newName); err != nil {
|
||||||
|
ShowError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// This page is called from the settings, return the user back.
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/settings#group-%v", id), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ShowError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.DeleteGroup(int(id)); err != nil {
|
||||||
|
ShowError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to settings.
|
||||||
|
http.Redirect(w, r, "/settings", http.StatusFound)
|
||||||
|
}
|
38
views/pages/import.go
Normal file
38
views/pages/import.go
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/ordinary-dev/phoenix/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ImportPage(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
Render("import.html.tmpl", w, map[string]any{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func Import(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var exportFile ExportFile
|
||||||
|
data := []byte(r.FormValue("exportFile"))
|
||||||
|
if err := json.Unmarshal(data, &exportFile); err != nil {
|
||||||
|
ShowError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, g := range exportFile.Groups {
|
||||||
|
if err := database.CreateGroup(&g); err != nil {
|
||||||
|
ShowError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, l := range g.Links {
|
||||||
|
l.GroupID = g.ID
|
||||||
|
if err := database.CreateLink(&l); err != nil {
|
||||||
|
ShowError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
|
}
|
45
views/pages/index.go
Normal file
45
views/pages/index.go
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ordinary-dev/phoenix/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ShowMainPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
groups, err := database.GetGroupsWithLinks()
|
||||||
|
if err != nil {
|
||||||
|
ShowError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get desired style.
|
||||||
|
style := r.FormValue("style")
|
||||||
|
|
||||||
|
if style == "tiles" || style == "list" {
|
||||||
|
// If a valid style is specified in the url -
|
||||||
|
// save the value in a cookie.
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "phoenix-style",
|
||||||
|
Value: style,
|
||||||
|
Expires: time.Now().Add(time.Hour * 24 * 30 * 12 * 10),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// The style is not specified or has an incorrect type, check the cookies.
|
||||||
|
styleCookie, err := r.Cookie("phoenix-style")
|
||||||
|
if err == nil {
|
||||||
|
style = styleCookie.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if style != "tiles" && style != "list" {
|
||||||
|
style = "list"
|
||||||
|
}
|
||||||
|
|
||||||
|
Render("index.html.tmpl", w, map[string]any{
|
||||||
|
"description": "Self-hosted start page.",
|
||||||
|
"groups": groups,
|
||||||
|
"style": style,
|
||||||
|
})
|
||||||
|
}
|
84
views/pages/links.go
Normal file
84
views/pages/links.go
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ordinary-dev/phoenix/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateLink(w http.ResponseWriter, r *http.Request) {
|
||||||
|
groupID, err := strconv.Atoi(r.FormValue("groupID"))
|
||||||
|
if err != nil {
|
||||||
|
ShowError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
link := database.Link{
|
||||||
|
Name: strings.TrimSpace(r.FormValue("linkName")),
|
||||||
|
Href: strings.TrimSpace(r.FormValue("href")),
|
||||||
|
GroupID: groupID,
|
||||||
|
}
|
||||||
|
icon := strings.TrimSpace(r.FormValue("icon"))
|
||||||
|
if icon == "" {
|
||||||
|
link.Icon = nil
|
||||||
|
} else {
|
||||||
|
link.Icon = &icon
|
||||||
|
}
|
||||||
|
if err := database.CreateLink(&link); err != nil {
|
||||||
|
ShowError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to settings.
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/settings#link-%v", link.ID), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateLink(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
ShowError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
link, err := database.GetLink(id)
|
||||||
|
if err != nil {
|
||||||
|
ShowError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
link.Name = strings.TrimSpace(r.FormValue("linkName"))
|
||||||
|
link.Href = strings.TrimSpace(r.FormValue("href"))
|
||||||
|
icon := strings.TrimSpace(r.FormValue("icon"))
|
||||||
|
if icon == "" {
|
||||||
|
link.Icon = nil
|
||||||
|
} else {
|
||||||
|
link.Icon = &icon
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.UpdateLink(link); err != nil {
|
||||||
|
ShowError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to settings.
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/settings#link-%v", link.ID), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteLink(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
ShowError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.DeleteLink(id); err != nil {
|
||||||
|
ShowError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to settings.
|
||||||
|
http.Redirect(w, r, "/settings", http.StatusFound)
|
||||||
|
}
|
63
views/pages/registration.go
Normal file
63
views/pages/registration.go
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ordinary-dev/phoenix/database"
|
||||||
|
"github.com/ordinary-dev/phoenix/jwttoken"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ShowRegistrationForm(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
userCount, err := database.CountAdmins()
|
||||||
|
if err != nil {
|
||||||
|
ShowError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if userCount > 0 {
|
||||||
|
ShowError(w, http.StatusBadRequest, errors.New("at least 1 user already exists"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Render("auth.html.tmpl", w, map[string]any{
|
||||||
|
"title": "Create an account",
|
||||||
|
"description": "To prevent other people from seeing your links, create an account.",
|
||||||
|
"button": "Create",
|
||||||
|
"formAction": "/registration",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userCount, err := database.CountAdmins()
|
||||||
|
if err != nil {
|
||||||
|
ShowError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if userCount > 0 {
|
||||||
|
ShowError(w, http.StatusBadRequest, errors.New("at least 1 user already exists"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to create a user.
|
||||||
|
username := strings.TrimSpace(r.FormValue("username"))
|
||||||
|
password := strings.TrimSpace(r.FormValue("password"))
|
||||||
|
_, err = database.CreateAdmin(username, password)
|
||||||
|
if err != nil {
|
||||||
|
ShowError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate access token.
|
||||||
|
token, err := jwttoken.GetJWTToken()
|
||||||
|
if err != nil {
|
||||||
|
ShowError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, jwttoken.TokenToCookie(token))
|
||||||
|
|
||||||
|
// Redirect to homepage.
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
|
}
|
20
views/pages/settings.go
Normal file
20
views/pages/settings.go
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/ordinary-dev/phoenix/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ShowSettings(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
groups, err := database.GetGroupsWithLinks()
|
||||||
|
if err != nil {
|
||||||
|
ShowError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Render("settings.html.tmpl", w, map[string]any{
|
||||||
|
"title": "Settings",
|
||||||
|
"groups": groups,
|
||||||
|
})
|
||||||
|
}
|
39
views/pages/signin.go
Normal file
39
views/pages/signin.go
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ordinary-dev/phoenix/database"
|
||||||
|
"github.com/ordinary-dev/phoenix/jwttoken"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ShowSignInForm(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
Render("auth.html.tmpl", w, map[string]any{
|
||||||
|
"title": "Sign in",
|
||||||
|
"description": "Authorization is required to view this page.",
|
||||||
|
"button": "Sign in",
|
||||||
|
"formAction": "/signin",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func AuthorizeUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Check credentials.
|
||||||
|
username := strings.TrimSpace(r.FormValue("username"))
|
||||||
|
password := strings.TrimSpace(r.FormValue("password"))
|
||||||
|
_, err := database.GetAdminIfPasswordMatches(username, password)
|
||||||
|
if err != nil {
|
||||||
|
ShowError(w, http.StatusUnauthorized, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate an access token.
|
||||||
|
token, err := jwttoken.GetJWTToken()
|
||||||
|
if err != nil {
|
||||||
|
ShowError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, jwttoken.TokenToCookie(token))
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
|
}
|
73
views/pages/templates.go
Normal file
73
views/pages/templates.go
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/ordinary-dev/phoenix/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Preloaded templates.
|
||||||
|
// The key is the file name.
|
||||||
|
templates = make(map[string]*template.Template)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Preload all templates into `Templates` map.
|
||||||
|
func LoadTemplates() error {
|
||||||
|
// Fragments are reusable parts of templates.
|
||||||
|
fragments, err := os.ReadDir("templates/fragments")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var fragmentPaths []string
|
||||||
|
for _, f := range fragments {
|
||||||
|
fragmentPaths = append(
|
||||||
|
fragmentPaths,
|
||||||
|
path.Join("templates/fragments", f.Name()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load pages.
|
||||||
|
files, err := os.ReadDir("templates")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
if f.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
templatePaths := []string{path.Join("templates", f.Name())}
|
||||||
|
templatePaths = append(templatePaths, fragmentPaths...)
|
||||||
|
|
||||||
|
tmpl, err := template.ParseFiles(templatePaths...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
templates[f.Name()] = tmpl
|
||||||
|
log.Infof("Template %v was loaded", f.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Render(template string, wr io.Writer, data map[string]any) {
|
||||||
|
data["fontFamily"] = config.Cfg.FontFamily
|
||||||
|
|
||||||
|
if _, ok := data["title"]; !ok {
|
||||||
|
data["title"] = config.Cfg.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
err := templates[template].Execute(wr, data)
|
||||||
|
if err != nil {
|
||||||
|
log.WithField("template", template).Error(err)
|
||||||
|
}
|
||||||
|
}
|
18
views/pages/templates_test.go
Normal file
18
views/pages/templates_test.go
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ordinary-dev/phoenix/testutils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check that all templates can be loaded.
|
||||||
|
func TestLoadTemplates(t *testing.T) {
|
||||||
|
if err := testutils.ResetWorkingDir(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := LoadTemplates(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,13 +0,0 @@
|
||||||
package views
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/ordinary-dev/phoenix/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Fill in the necessary parameters from the settings and output html.
|
|
||||||
func Render(ctx *gin.Context, cfg *config.Config, status int, templatePath string, params map[string]any) {
|
|
||||||
params["WebsiteTitle"] = cfg.Title
|
|
||||||
params["FontFamily"] = cfg.FontFamily
|
|
||||||
ctx.HTML(status, templatePath, params)
|
|
||||||
}
|
|
67
views/routes.go
Normal file
67
views/routes.go
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
package views
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/ordinary-dev/phoenix/views/middleware"
|
||||||
|
"github.com/ordinary-dev/phoenix/views/pages"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create and configure an HTTP server.
|
||||||
|
//
|
||||||
|
// Unfortunately, I haven't found a way to use PUT and DELETE methods without JavaScript.
|
||||||
|
// POST is used instead.
|
||||||
|
func GetHttpServer() (*http.Server, error) {
|
||||||
|
if err := pages.LoadTemplates(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
mux.Handle("/assets/", http.StripPrefix(
|
||||||
|
"/assets",
|
||||||
|
http.FileServer(http.Dir("assets")),
|
||||||
|
))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /signin", pages.ShowSignInForm)
|
||||||
|
mux.HandleFunc("POST /signin", pages.AuthorizeUser)
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /registration", pages.ShowRegistrationForm)
|
||||||
|
mux.HandleFunc("POST /registration", pages.CreateUser)
|
||||||
|
|
||||||
|
protectedMux := http.NewServeMux()
|
||||||
|
mux.Handle("/", middleware.RequireAuth(protectedMux))
|
||||||
|
|
||||||
|
protectedMux.HandleFunc("GET /", pages.ShowMainPage)
|
||||||
|
protectedMux.HandleFunc("GET /settings", pages.ShowSettings)
|
||||||
|
|
||||||
|
// Groups.
|
||||||
|
|
||||||
|
// Create group.
|
||||||
|
protectedMux.HandleFunc("POST /groups", pages.CreateGroup)
|
||||||
|
// Update group.
|
||||||
|
protectedMux.HandleFunc("POST /groups/{id}/update", pages.UpdateGroup)
|
||||||
|
// Delete group.
|
||||||
|
protectedMux.HandleFunc("POST /groups/{id}/delete", pages.DeleteGroup)
|
||||||
|
|
||||||
|
// Links.
|
||||||
|
|
||||||
|
// Create link.
|
||||||
|
protectedMux.HandleFunc("POST /links", pages.CreateLink)
|
||||||
|
// Update link.
|
||||||
|
protectedMux.HandleFunc("POST /links/{id}/update", pages.UpdateLink)
|
||||||
|
// Delete link.
|
||||||
|
protectedMux.HandleFunc("POST /links/{id}/delete", pages.DeleteLink)
|
||||||
|
|
||||||
|
// Import-export
|
||||||
|
protectedMux.HandleFunc("GET /export", pages.Export)
|
||||||
|
protectedMux.HandleFunc("GET /import", pages.ImportPage)
|
||||||
|
protectedMux.HandleFunc("POST /import", pages.Import)
|
||||||
|
|
||||||
|
return &http.Server{
|
||||||
|
Addr: ":8080",
|
||||||
|
Handler: middleware.LoggingMiddleware(
|
||||||
|
middleware.SecurityHeadersMiddleware(mux),
|
||||||
|
),
|
||||||
|
}, nil
|
||||||
|
}
|
|
@ -1,14 +0,0 @@
|
||||||
package views
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Adds several headers to the response to improve security.
|
|
||||||
// For example, headers prevent embedding a site and passing information about the referrer.
|
|
||||||
func SecurityHeadersMiddleware(c *gin.Context) {
|
|
||||||
c.Writer.Header().Set("X-Frame-Options", "SAMEORIGIN")
|
|
||||||
c.Writer.Header().Set("X-Content-Type-Options", "nosniff")
|
|
||||||
c.Writer.Header().Set("Referrer-Policy", "same-origin")
|
|
||||||
c.Writer.Header().Set("Content-Security-Policy", "script-src 'self'; ")
|
|
||||||
}
|
|
|
@ -1,29 +0,0 @@
|
||||||
package views
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/ordinary-dev/phoenix/config"
|
|
||||||
"github.com/ordinary-dev/phoenix/database"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ShowSettings(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
|
|
||||||
return func(ctx *gin.Context) {
|
|
||||||
// Get a list of groups with links
|
|
||||||
var groups []database.Group
|
|
||||||
result := db.
|
|
||||||
Model(&database.Group{}).
|
|
||||||
Preload("Links").
|
|
||||||
Find(&groups)
|
|
||||||
|
|
||||||
if result.Error != nil {
|
|
||||||
ShowError(ctx, cfg, result.Error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Render(ctx, cfg, http.StatusOK, "settings.html.tmpl", gin.H{
|
|
||||||
"groups": groups,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in a new issue