Initial commit

This commit is contained in:
Ivan R. 2023-04-06 10:36:11 +05:00
commit 16cd7aa635
No known key found for this signature in database
GPG key ID: 56C7BAAE859B302C
24 changed files with 968 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*.sqlite3
*.db

24
assets/auth.css Normal file
View file

@ -0,0 +1,24 @@
.page {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
form {
width: 100%;
padding: 0 1em;
}
input,
button {
width: 100%;
margin-top: 10px;
}
@media screen and (min-width: 600px) {
form {
max-width: 400px;
}
}

48
assets/base.css Normal file
View file

@ -0,0 +1,48 @@
html,
body {
height: 100%;
}
body {
margin: 0;
background:
linear-gradient(0deg, #1d1926 19px, transparent 1px) center,
linear-gradient(90deg, #1d1926 19px, transparent 1px) center,
#7a7093;
background-size: 20px 20px;
font-family: sans-serif;
font-size: 16px;
color: white;
}
* {
box-sizing: border-box;
}
a {
color: inherit;
}
input,
button {
font-size: inherit;
font-family: inherit;
padding: 8px 10px;
outline: none;
background-color: #24272d;
color: white;
border: 2px solid gray;
border-radius: 5px;
transition: border-color 0.1s ease-in-out;
}
button {
cursor: pointer;
}
input:focus,
input:hover,
button:active,
button:hover {
border-color: #812abd;
}

18
assets/error.css Normal file
View file

@ -0,0 +1,18 @@
.page {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.content {
padding: 0 1em;
width: 100%;
}
@media screen and (min-width: 600px) {
.content {
max-width: 400px;
}
}

32
assets/index.css Normal file
View file

@ -0,0 +1,32 @@
.page {
padding: 2em 1em;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 1em;
margin-top: 3em;
}
.group {
width: 100%;
}
h2 {
margin-top: 0;
}
a {
display: block;
margin-top: 10px;
}
@media screen and (min-width: 600px) {
.page {
padding: 2em 10em
}
.group {
max-width: 230px;
}
}

50
assets/settings.css Normal file
View file

@ -0,0 +1,50 @@
body {
padding: 2em 1em;
}
.row {
display: flex;
gap: 10px;
margin-top: 1em;
width: 100%;
}
.innerForm {
display: flex;
gap: 10px;
min-width: 0;
flex-grow: 1;
}
.row:last-child {
padding-bottom: 4em;
}
h2 {
font-size: 20px;
margin-top: 3em;
margin-bottom: 10px;
}
input {
min-width: 0;
flex-grow: 1;
}
.link-name {
width: 130px;
flex-grow: 0;
}
.link-href {
flex-grow: 1;
}
@media screen and (min-width: 600px) {
body {
padding: 2em 10em;
}
.row {
max-width: 500px;
}
}

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
<!--! Font Awesome Pro 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
<path fill="white" d="M64 32C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64H384c35.3 0 64-28.7 64-64V173.3c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32H64zm0 96c0-17.7 14.3-32 32-32H288c17.7 0 32 14.3 32 32v64c0 17.7-14.3 32-32 32H96c-17.7 0-32-14.3-32-32V128zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/>
</svg>

After

Width:  |  Height:  |  Size: 561 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
<!--! Font Awesome Pro 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
<path fill="white" d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32V224H48c-17.7 0-32 14.3-32 32s14.3 32 32 32H192V432c0 17.7 14.3 32 32 32s32-14.3 32-32V288H400c17.7 0 32-14.3 32-32s-14.3-32-32-32H256V80z"/>
</svg>

After

Width:  |  Height:  |  Size: 446 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
<!--! Font Awesome Pro 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. -->
<path fill="white" d="M135.2 17.7L128 32H32C14.3 32 0 46.3 0 64S14.3 96 32 96H416c17.7 0 32-14.3 32-32s-14.3-32-32-32H320l-7.2-14.3C307.4 6.8 296.3 0 284.2 0H163.8c-12.1 0-23.2 6.8-28.6 17.7zM416 128H32L53.2 467c1.6 25.3 22.6 45 47.9 45H346.9c25.3 0 46.3-19.7 47.9-45L416 128z"/>
</svg>

After

Width:  |  Height:  |  Size: 519 B

108
backend/admins.go Normal file
View file

@ -0,0 +1,108 @@
package backend
import (
"crypto/rand"
"encoding/base64"
"errors"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"time"
)
type Admin struct {
ID uint64 `gorm:"primaryKey"`
Username string `gorm:"unique;notNull"`
Bcrypt string `gorm:"notNull"`
AccessTokens []AccessToken
}
type AccessToken struct {
ID uint64 `gorm:"primaryKey"`
Value string `gorm:"notNull"`
AdminID uint64 `gorm:"notNull"`
ValidUntil time.Time `gorm:"NotNull"`
}
func CountAdmins(db *gorm.DB) int64 {
var admins []Admin
var count int64
db.Model(&admins).Count(&count)
return count
}
func CreateAdmin(db *gorm.DB, username string, password string) (Admin, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), 10)
if err != nil {
return Admin{}, err
}
admin := Admin{
Username: username,
Bcrypt: string(hash),
}
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
result := db.Where("username = ?", username).First(&admin)
if result.Error != nil {
return Admin{}, result.Error
}
err := bcrypt.CompareHashAndPassword([]byte(admin.Bcrypt), []byte(password))
if err != nil {
return Admin{}, err
}
return admin, nil
}
func CreateAccessToken(db *gorm.DB, adminID uint64) (AccessToken, error) {
bytes := make([]byte, 64)
if _, err := rand.Read(bytes); err != nil {
return AccessToken{}, err
}
accessToken := AccessToken{
AdminID: adminID,
Value: base64.StdEncoding.EncodeToString(bytes),
// Valid for 1 month
ValidUntil: time.Now().AddDate(0, 1, 0),
}
result := db.Create(&accessToken)
if result.Error != nil {
return AccessToken{}, result.Error
}
return accessToken, nil
}
// Save token for 29 days in cookies
func SetTokenCookie(c *gin.Context, token AccessToken) {
c.SetCookie("phoenix-token", token.Value, 60*60*24*29, "/", "", false, true)
}
func ValidateToken(db *gorm.DB, value string) error {
var token AccessToken
result := db.Where("value = ?", value).First(&token)
if result.Error != nil {
return result.Error
}
if time.Now().After(token.ValidUntil) {
return errors.New("Access token expired")
}
return nil
}

18
backend/db.go Normal file
View file

@ -0,0 +1,18 @@
package backend
import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func GetDatabaseConnection() (*gorm.DB, error) {
db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
if err != nil {
return nil, err
}
// Migrate the schema
db.AutoMigrate(&Admin{}, &AccessToken{}, &Group{}, &Link{})
return db, nil
}

32
backend/groups.go Normal file
View file

@ -0,0 +1,32 @@
package backend
import (
"gorm.io/gorm"
)
type Group struct {
ID uint64 `gorm:"primaryKey"`
Name string `gorm:"unique,notNull"`
Links []Link
}
func GetGroups(db *gorm.DB) ([]Group, error) {
var groups []Group
result := db.Model(&Group{}).Preload("Links").Find(&groups)
if result.Error != nil {
return nil, result.Error
}
return groups, nil
}
func CreateGroup(db *gorm.DB, groupName string) (Group, error) {
group := Group{
Name: groupName,
}
result := db.Create(&group)
if result.Error != nil {
return Group{}, result.Error
}
return group, nil
}

48
backend/links.go Normal file
View file

@ -0,0 +1,48 @@
package backend
import (
"gorm.io/gorm"
)
type Link struct {
ID uint64 `gorm:"primaryKey"`
Name string `gorm:"notNull"`
Href string `gorm:"notNull"`
GroupID uint64 `gorm:"notNull"`
}
func CreateLink(db *gorm.DB, linkName string, href string, groupID uint64) (Link, error) {
link := Link{
Name: linkName,
Href: href,
GroupID: groupID,
}
result := db.Create(&link)
if result.Error != nil {
return Link{}, result.Error
}
return link, nil
}
func UpdateLink(db *gorm.DB, id uint64, linkName string, href string) (Link, error) {
var link Link
db.First(&link, id)
link.Name = linkName
link.Href = href
result := db.Save(&link)
if result.Error != nil {
return Link{}, result.Error
}
return link, nil
}
func DeleteLink(db *gorm.DB, id uint64) error {
result := db.Delete(&Link{}, id)
if result.Error != nil {
return result.Error
}
return nil
}

38
go.mod Normal file
View file

@ -0,0 +1,38 @@
module github.com/ordinary-dev/phoenix
go 1.20
require (
github.com/gin-gonic/gin v1.9.0
gorm.io/driver/sqlite v1.4.4
gorm.io/gorm v1.24.6
)
require (
github.com/bytedance/sonic v1.8.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // 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.11.2 // indirect
github.com/goccy/go-json v0.10.0 // 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.0.9 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mattn/go-sqlite3 v1.14.15 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.6 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.9 // indirect
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
golang.org/x/crypto v0.5.0 // indirect
golang.org/x/net v0.7.0 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/text v0.7.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

93
go.sum Normal file
View file

@ -0,0 +1,93 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.8.0 h1:ea0Xadu+sHlu7x5O3gKhRpQ1IKiMrSiHttPF0ybECuA=
github.com/bytedance/sonic v1.8.0/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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.0 h1:OjyFBKICoexlu99ctXNR2gg+c5pKrKMuyjgARg9qeY8=
github.com/gin-gonic/gin v1.9.0/go.mod h1:W1Me9+hsUSyj3CePGrd1/QrKJMSJ1Tu/0hFEH89961k=
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.11.2 h1:q3SHpufmypg+erIExEKUmsgmhDTyhcJ38oeKGACXohU=
github.com/go-playground/validator/v10 v10.11.2/go.mod h1:NieE624vt4SCTJtD87arVLvdmjPAeV8BQlHtMnw9D7s=
github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA=
github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
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.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
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/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/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.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU=
github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
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/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
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.6.1/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 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.9 h1:rmenucSohSTiyL09Y+l2OCk+FrMxGMzho2+tjr5ticU=
github.com/ugorji/go/codec v1.2.9/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
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.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.4.4 h1:gIufGoR0dQzjkyqDyYSCvsYR6fba1Gw5YKDqKeChxFc=
gorm.io/driver/sqlite v1.4.4/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.24.6 h1:wy98aq9oFEetsc4CAbKD2SoBCdMzsbSIvSUUFJuHi5s=
gorm.io/gorm v1.24.6/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

194
main.go Normal file
View file

@ -0,0 +1,194 @@
package main
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/ordinary-dev/phoenix/backend"
"github.com/ordinary-dev/phoenix/views"
"log"
"net/http"
"strconv"
)
func main() {
db, err := backend.GetDatabaseConnection()
if err != nil {
log.Fatal(err)
}
r := gin.Default()
r.LoadHTMLGlob("templates/*")
r.Static("/assets", "./assets")
// Main page
r.GET("/", func(c *gin.Context) {
views.RequireAuth(c, db)
groups, err := backend.GetGroups(db)
if err != nil {
views.ShowError(c, err)
return
}
c.HTML(http.StatusOK, "index.html.tmpl", gin.H{
"groups": groups,
})
})
// Settings
r.GET("/settings", func(c *gin.Context) {
views.RequireAuth(c, db)
groups, err := backend.GetGroups(db)
if err != nil {
views.ShowError(c, err)
return
}
c.HTML(http.StatusOK, "settings.html.tmpl", gin.H{
"groups": groups,
})
})
// Create new user
r.POST("/users", func(c *gin.Context) {
// If at least 1 administator exists, require authorization
if backend.CountAdmins(db) > 0 {
tokenValue, err := c.Cookie("phoenix-token")
// Anonymous visitor
if err != nil {
err = errors.New("At least 1 user exists, you have to sign in first")
views.ShowError(c, err)
return
}
err = backend.ValidateToken(db, tokenValue)
if err != nil {
views.ShowError(c, err)
return
}
}
// User is authorized or no user exists.
// Try to create a user.
username := c.PostForm("username")
password := c.PostForm("password")
admin, err := backend.CreateAdmin(db, username, password)
if err != nil {
views.ShowError(c, err)
return
}
// Generate access token.
token, err := backend.CreateAccessToken(db, admin.ID)
if err != nil {
views.ShowError(c, err)
return
}
backend.SetTokenCookie(c, token)
// Redirect to homepage.
c.Redirect(http.StatusFound, "/")
})
// Create new group
r.POST("/groups", func(c *gin.Context) {
views.RequireAuth(c, db)
groupName := c.PostForm("groupName")
_, err := backend.CreateGroup(db, groupName)
if err != nil {
views.ShowError(c, err)
return
}
// Redirect to settings.
c.Redirect(http.StatusFound, "/settings")
})
// Create new link
r.POST("/links", func(c *gin.Context) {
views.RequireAuth(c, db)
linkName := c.PostForm("linkName")
href := c.PostForm("href")
groupID, err := strconv.ParseUint(c.PostForm("groupID"), 10, 32)
if err != nil {
views.ShowError(c, err)
return
}
_, err = backend.CreateLink(db, linkName, href, groupID)
if err != nil {
views.ShowError(c, err)
return
}
// Redirect to settings.
c.Redirect(http.StatusFound, "/settings")
})
// Update link
r.POST("/links/:id/put", func(c *gin.Context) {
views.RequireAuth(c, db)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
views.ShowError(c, err)
return
}
linkName := c.PostForm("linkName")
href := c.PostForm("href")
_, err = backend.UpdateLink(db, id, linkName, href)
if err != nil {
views.ShowError(c, err)
return
}
// Redirect to settings.
c.Redirect(http.StatusFound, "/settings")
})
// Delete link
r.POST("/links/:id/delete", func(c *gin.Context) {
views.RequireAuth(c, db)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
views.ShowError(c, err)
return
}
err = backend.DeleteLink(db, id)
if err != nil {
views.ShowError(c, err)
return
}
// Redirect to settings.
c.Redirect(http.StatusFound, "/settings")
})
r.POST("/signin", func(c *gin.Context) {
// Check credentials.
username := c.PostForm("username")
password := c.PostForm("password")
admin, err := backend.AuthorizeAdmin(db, username, password)
if err != nil {
views.ShowError(c, err)
return
}
// Generate an access token.
token, err := backend.CreateAccessToken(db, admin.ID)
if err != nil {
views.ShowError(c, err)
return
}
backend.SetTokenCookie(c, token)
// Redirect to homepage.
c.Redirect(http.StatusFound, "/")
})
r.Run()
}

7
readme.md Normal file
View file

@ -0,0 +1,7 @@
# Phoenix
Self-hosted start page without the extra stuff.
## Features
- No javascript
- Tiny footprint

28
templates/auth.html.tmpl Normal file
View file

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{template "head"}}
<link rel="stylesheet" href="assets/auth.css" />
</head>
<body>
<div class="page">
<form action="{{.formAction}}" method="POST">
<h1>{{.title}}</h1>
<p>{{.description}}</p>
<input
placeholder="Username"
name="username"
type="text"
required
/>
<input
placeholder="Password"
name="password"
type="password"
required
/>
<button type="submit">{{.button}}</button>
</form>
</div>
</body>
</html>

18
templates/error.html.tmpl Normal file
View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{template "head"}}
<link rel="stylesheet" href="assets/error.css" />
</head>
<body>
<div class="page">
<div class="content">
<h1>Error</h1>
<code>{{.error}}</code>
<p>
<a href="/">Main page</a>
</p>
</div>
</div>
</body>
</html>

6
templates/head.html.tmpl Normal file
View file

@ -0,0 +1,6 @@
{{define "head"}}
<title>Phoenix</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="assets/base.css" />
{{end}}

31
templates/index.html.tmpl Normal file
View file

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{template "head"}}
<link rel="stylesheet" href="assets/index.css" />
</head>
<body>
<div class="page">
<h1>Phoenix</h1>
{{if not .groups}}
<p>
You don't have any links.
Go to <a href="/settings">settings</a> and create one.
</p>
{{else}}
<a href="/settings">Settings</a>
{{end}}
<div class="row">
{{range .groups}}
<div class="group">
<h2>{{.Name}}</h2>
{{range .Links}}
<a href="{{.Href}}" target="_blank" rel="noreferrer">{{.Name}}</a>
{{end}}
</div>
{{end}}
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{template "head"}}
<link rel="stylesheet" href="assets/settings.css" />
</head>
<body>
<h1>Settings</h1>
<a href="/">Main page</a>
{{range .groups}}
<h2>Group "{{.Name}}"</h2>
<form class="row">
<input
value="{{.Name}}"
placeholder="Name"
name="groupName"
required
/>
<button type="submit">
<img src="/assets/svg/floppy-disk-solid.svg" width="16px" height="16px" />
</button>
</form>
{{range .Links}}
<div class="row">
<form method="POST" action="/links/{{.ID}}/put" class="innerForm">
<!-- method: put -->
<input
class="link-name"
value="{{.Name}}"
name="linkName"
placeholder="Name"
required
/>
<input
class="link-href"
value="{{.Href}}"
name="href"
placeholder="Href"
required
/>
<button type="submit">
<img src="/assets/svg/floppy-disk-solid.svg" width="16px" height="16px" />
</button>
</form>
<form method="POST" action="/links/{{.ID}}/delete">
<button type="submit">
<img src="/assets/svg/trash-solid.svg" width="16px" height="16px" />
</button>
</form>
</div>
{{end}}
<form action="/links" method="POST" class="row">
<input
class="link-name"
placeholder="Name"
name="linkName"
required
/>
<input
class="link-href"
placeholder="Href"
name="href"
type="url"
required
/>
<input
type="hidden"
name="groupID"
value="{{.ID}}"
readonly
/>
<button type="submit">
<img src="/assets/svg/plus-solid.svg" width="16px" height="16px" />
</button>
</form>
{{end}}
<h2>New group</h2>
<form method="POST" action="/groups" class="row">
<input
placeholder="Name"
name="groupName"
required
/>
<button type="submit">
<img src="/assets/svg/plus-solid.svg" width="16px" height="16px" />
</button>
</form>
</body>
</html>

52
views/auth.go Normal file
View file

@ -0,0 +1,52 @@
package views
import (
"github.com/gin-gonic/gin"
"github.com/ordinary-dev/phoenix/backend"
"gorm.io/gorm"
"net/http"
)
func ShowRegistrationForm(c *gin.Context) {
c.HTML(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": "/users",
})
}
func ShowLoginForm(c *gin.Context) {
c.HTML(http.StatusOK, "auth.html.tmpl", gin.H{
"title": "Sign in",
"description": "Authorization is required to view this page.",
"button": "Sign in",
"formAction": "/signin",
})
}
// Requires the user to log in before viewing the page.
// If successful, does nothing.
// In case of an error, it shows the login page or the error page.
func RequireAuth(c *gin.Context, db *gorm.DB) {
number_of_accounts := backend.CountAdmins(db)
// First run
if number_of_accounts == 0 {
ShowRegistrationForm(c)
}
tokenValue, err := c.Cookie("phoenix-token")
// Anonymous visitor
if err != nil {
ShowLoginForm(c)
return
}
err = backend.ValidateToken(db, tokenValue)
if err != nil {
ShowError(c, err)
return
}
}

16
views/errors.go Normal file
View file

@ -0,0 +1,16 @@
package views
import (
"github.com/gin-gonic/gin"
"net/http"
)
func ShowError(c *gin.Context, err error) {
c.HTML(
http.StatusBadRequest,
"error.html.tmpl",
gin.H{
"error": err.Error(),
},
)
}