Compare commits
No commits in common. "main" and "feature/accountsettings" have entirely different histories.
main
...
feature/ac
|
@ -1,7 +0,0 @@
|
||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
end_of_line = lf
|
|
||||||
insert_final_newline = true
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 4
|
|
1
.gitignore
vendored
|
@ -8,4 +8,3 @@ docker-compose*.yml
|
||||||
!docker-compose.example.yml
|
!docker-compose.example.yml
|
||||||
config*.toml
|
config*.toml
|
||||||
arimelody-web
|
arimelody-web
|
||||||
arimelody-web.tar.gz
|
|
||||||
|
|
22
LICENSE.md
|
@ -1,22 +0,0 @@
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2025-present ari melody
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
|
|
12
Makefile
|
@ -1,12 +0,0 @@
|
||||||
EXEC = arimelody-web
|
|
||||||
|
|
||||||
.PHONY: $(EXEC)
|
|
||||||
|
|
||||||
$(EXEC):
|
|
||||||
GOOS=linux GOARCH=amd64 go build -o $(EXEC)
|
|
||||||
|
|
||||||
bundle: $(EXEC)
|
|
||||||
tar czf $(EXEC).tar.gz $(EXEC) admin/components/ admin/views/ admin/static/ views/ public/ schema-migration/
|
|
||||||
|
|
||||||
clean:
|
|
||||||
rm $(EXEC) $(EXEC).tar.gz
|
|
|
@ -42,11 +42,7 @@ need to be up for this, making this ideal for some offline maintenance.
|
||||||
- `listTOTP <username>`: Lists an account's TOTP methods.
|
- `listTOTP <username>`: Lists an account's TOTP methods.
|
||||||
- `deleteTOTP <username> <name>`: Deletes an account's TOTP method.
|
- `deleteTOTP <username> <name>`: Deletes an account's TOTP method.
|
||||||
- `testTOTP <username> <name>`: Generates the code for an account's TOTP method.
|
- `testTOTP <username> <name>`: Generates the code for an account's TOTP method.
|
||||||
- `cleanTOTP`: Cleans up unconfirmed (dangling) TOTP methods.
|
|
||||||
- `createInvite`: Creates an invite code to register new accounts.
|
- `createInvite`: Creates an invite code to register new accounts.
|
||||||
- `purgeInvites`: Deletes all available invite codes.
|
- `purgeInvites`: Deletes all available invite codes.
|
||||||
- `listAccounts`: Lists all active accounts.
|
- `listAccounts`: Lists all active accounts.
|
||||||
- `deleteAccount <username>`: Deletes an account with a given `username`.
|
- `deleteAccount <username>`: Deletes an account with a given `username`.
|
||||||
- `lockAccount <username>`: Locks the account under `username`.
|
|
||||||
- `unlockAccount <username>`: Unlocks the account under `username`.
|
|
||||||
- `logs`: Shows system logs.
|
|
||||||
|
|
|
@ -1,17 +1,16 @@
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"fmt"
|
||||||
"fmt"
|
"net/http"
|
||||||
"net/http"
|
"net/url"
|
||||||
"net/url"
|
"os"
|
||||||
"os"
|
"time"
|
||||||
|
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
"arimelody-web/log"
|
"arimelody-web/model"
|
||||||
"arimelody-web/model"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func accountHandler(app *model.AppState) http.Handler {
|
func accountHandler(app *model.AppState) http.Handler {
|
||||||
|
@ -64,7 +63,7 @@ func accountIndexHandler(app *model.AppState) http.Handler {
|
||||||
session.Message = sessionMessage
|
session.Message = sessionMessage
|
||||||
session.Error = sessionError
|
session.Error = sessionError
|
||||||
|
|
||||||
err = accountTemplate.Execute(w, accountResponse{
|
err = pages["account"].Execute(w, accountResponse{
|
||||||
Session: session,
|
Session: session,
|
||||||
TOTPs: totps,
|
TOTPs: totps,
|
||||||
})
|
})
|
||||||
|
@ -115,8 +114,6 @@ func changePasswordHandler(app *model.AppState) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" changed password by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r))
|
|
||||||
|
|
||||||
controller.SetSessionError(app.DB, session, "")
|
controller.SetSessionError(app.DB, session, "")
|
||||||
controller.SetSessionMessage(app.DB, session, "Password updated successfully.")
|
controller.SetSessionMessage(app.DB, session, "Password updated successfully.")
|
||||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
||||||
|
@ -136,7 +133,7 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !r.Form.Has("password") {
|
if !r.Form.Has("password") || !r.Form.Has("totp") {
|
||||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -145,12 +142,33 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
|
||||||
|
|
||||||
// check password
|
// check password
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(session.Account.Password), []byte(r.Form.Get("password"))); err != nil {
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, "Account \"%s\" attempted account deletion with incorrect password. (%s)", session.Account.Username, controller.ResolveIP(app, r))
|
fmt.Printf(
|
||||||
|
"[%s] WARN: Account \"%s\" attempted account deletion with incorrect password.\n",
|
||||||
|
time.Now().Format(time.UnixDate),
|
||||||
|
session.Account.Username,
|
||||||
|
)
|
||||||
controller.SetSessionError(app.DB, session, "Incorrect password.")
|
controller.SetSessionError(app.DB, session, "Incorrect password.")
|
||||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.Account.ID, r.Form.Get("totp"))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to fetch account: %v\n", err)
|
||||||
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
|
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if totpMethod == nil {
|
||||||
|
fmt.Printf(
|
||||||
|
"[%s] WARN: Account \"%s\" attempted account deletion with incorrect TOTP.\n",
|
||||||
|
time.Now().Format(time.UnixDate),
|
||||||
|
session.Account.Username,
|
||||||
|
)
|
||||||
|
controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
|
||||||
|
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
err = controller.DeleteAccount(app.DB, session.Account.ID)
|
err = controller.DeleteAccount(app.DB, session.Account.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Failed to delete account: %v\n", err)
|
||||||
|
@ -159,7 +177,11 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" deleted by user request. (%s)", session.Account.Username, controller.ResolveIP(app, r))
|
fmt.Printf(
|
||||||
|
"[%s] INFO: Account \"%s\" deleted by user request.\n",
|
||||||
|
time.Now().Format(time.UnixDate),
|
||||||
|
session.Account.Username,
|
||||||
|
)
|
||||||
|
|
||||||
controller.SetSessionAccount(app.DB, session, nil)
|
controller.SetSessionAccount(app.DB, session, nil)
|
||||||
controller.SetSessionError(app.DB, session, "")
|
controller.SetSessionError(app.DB, session, "")
|
||||||
|
@ -168,13 +190,6 @@ func deleteAccountHandler(app *model.AppState) http.Handler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type totpConfirmData struct {
|
|
||||||
Session *model.Session
|
|
||||||
TOTP *model.TOTP
|
|
||||||
NameEscaped string
|
|
||||||
QRBase64Image string
|
|
||||||
}
|
|
||||||
|
|
||||||
func totpSetupHandler(app *model.AppState) http.Handler {
|
func totpSetupHandler(app *model.AppState) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
|
@ -184,7 +199,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
|
||||||
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
session := r.Context().Value("session").(*model.Session)
|
||||||
|
|
||||||
err := totpSetupTemplate.Execute(w, totpSetupData{ Session: session })
|
err := pages["totp-setup"].Execute(w, totpSetupData{ Session: session })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
|
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
@ -197,6 +212,12 @@ func totpSetupHandler(app *model.AppState) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type totpSetupData struct {
|
||||||
|
Session *model.Session
|
||||||
|
TOTP *model.TOTP
|
||||||
|
NameEscaped string
|
||||||
|
}
|
||||||
|
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
@ -221,7 +242,7 @@ func totpSetupHandler(app *model.AppState) http.Handler {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to create TOTP method: %s\n", err)
|
fmt.Printf("WARN: Failed to create TOTP method: %s\n", err)
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
err := totpSetupTemplate.Execute(w, totpConfirmData{ Session: session })
|
err := pages["totp-setup"].Execute(w, totpSetupData{ Session: session })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
|
fmt.Printf("WARN: Failed to render TOTP setup page: %s\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
@ -229,17 +250,10 @@ func totpSetupHandler(app *model.AppState) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
qrBase64Image, err := controller.GenerateQRCode(
|
err = pages["totp-confirm"].Execute(w, totpSetupData{
|
||||||
controller.GenerateTOTPURI(session.Account.Username, totp.Secret))
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = totpConfirmTemplate.Execute(w, totpConfirmData{
|
|
||||||
Session: session,
|
Session: session,
|
||||||
TOTP: &totp,
|
TOTP: &totp,
|
||||||
NameEscaped: url.PathEscape(totp.Name),
|
NameEscaped: url.PathEscape(totp.Name),
|
||||||
QRBase64Image: qrBase64Image,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to render TOTP confirm page: %s\n", err)
|
fmt.Printf("WARN: Failed to render TOTP confirm page: %s\n", err)
|
||||||
|
@ -255,6 +269,11 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type totpConfirmData struct {
|
||||||
|
Session *model.Session
|
||||||
|
TOTP *model.TOTP
|
||||||
|
}
|
||||||
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
session := r.Context().Value("session").(*model.Session)
|
||||||
|
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
|
@ -275,7 +294,7 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
|
||||||
|
|
||||||
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
|
totp, err := controller.GetTOTP(app.DB, session.Account.ID, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to fetch TOTP method: %v\n", err)
|
fmt.Printf("WARN: Failed to fetch TOTP method: %s\n", err)
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
||||||
return
|
return
|
||||||
|
@ -285,41 +304,19 @@ func totpConfirmHandler(app *model.AppState) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
qrBase64Image, err := controller.GenerateQRCode(
|
|
||||||
controller.GenerateTOTPURI(session.Account.Username, totp.Secret))
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to generate TOTP QR code: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmCode := controller.GenerateTOTP(totp.Secret, 0)
|
confirmCode := controller.GenerateTOTP(totp.Secret, 0)
|
||||||
if code != confirmCode {
|
if code != confirmCode {
|
||||||
confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1)
|
confirmCodeOffset := controller.GenerateTOTP(totp.Secret, 1)
|
||||||
if code != confirmCodeOffset {
|
if code != confirmCodeOffset {
|
||||||
session.Error = sql.NullString{ Valid: true, String: "Incorrect TOTP code. Please try again." }
|
controller.SetSessionError(app.DB, session, "Incorrect TOTP code. Please try again.")
|
||||||
err = totpConfirmTemplate.Execute(w, totpConfirmData{
|
err = pages["totp-confirm"].Execute(w, totpConfirmData{
|
||||||
Session: session,
|
Session: session,
|
||||||
TOTP: totp,
|
TOTP: totp,
|
||||||
NameEscaped: url.PathEscape(totp.Name),
|
|
||||||
QRBase64Image: qrBase64Image,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render TOTP setup page: %v\n", err)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = controller.ConfirmTOTP(app.DB, session.Account.ID, name)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("WARN: Failed to confirm TOTP method: %s\n", err)
|
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
|
||||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" created TOTP method \"%s\".", session.Account.Username, totp.Name)
|
|
||||||
|
|
||||||
controller.SetSessionError(app.DB, session, "")
|
controller.SetSessionError(app.DB, session, "")
|
||||||
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" created successfully.", totp.Name))
|
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" created successfully.", totp.Name))
|
||||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
||||||
|
@ -333,11 +330,12 @@ func totpDeleteHandler(app *model.AppState) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(r.URL.Path) < 2 {
|
name := r.URL.Path
|
||||||
|
fmt.Printf("%s\n", name);
|
||||||
|
if len(name) == 0 {
|
||||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
name := r.URL.Path[1:]
|
|
||||||
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
session := r.Context().Value("session").(*model.Session)
|
||||||
|
|
||||||
|
@ -361,8 +359,6 @@ func totpDeleteHandler(app *model.AppState) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" deleted TOTP method \"%s\".", session.Account.Username, totp.Name)
|
|
||||||
|
|
||||||
controller.SetSessionError(app.DB, session, "")
|
controller.SetSessionError(app.DB, session, "")
|
||||||
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" deleted successfully.", totp.Name))
|
controller.SetSessionMessage(app.DB, session, fmt.Sprintf("TOTP method \"%s\" deleted successfully.", totp.Name))
|
||||||
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
http.Redirect(w, r, "/admin/account", http.StatusFound)
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
|
@ -39,7 +39,7 @@ func serveArtist(app *model.AppState) http.Handler {
|
||||||
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
session := r.Context().Value("session").(*model.Session)
|
||||||
|
|
||||||
err = artistTemplate.Execute(w, ArtistResponse{
|
err = pages["artist"].Execute(w, ArtistResponse{
|
||||||
Session: session,
|
Session: session,
|
||||||
Artist: artist,
|
Artist: artist,
|
||||||
Credits: credits,
|
Credits: credits,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<dialog id="addcredit">
|
<dialog id="addcredit">
|
||||||
<header>
|
<header>
|
||||||
<h2>Add Artist Credit</h2>
|
<h2>Add artist credit</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
<h3 class="release-title">
|
<h3 class="release-title">
|
||||||
<a href="/admin/release/{{.ID}}">{{.Title}}</a>
|
<a href="/admin/release/{{.ID}}">{{.Title}}</a>
|
||||||
<small>
|
<small>
|
||||||
<span title="{{.PrintReleaseDate}}">{{.ReleaseDate.Year}}</span>
|
<span title="{{.PrintReleaseDate}}">{{.GetReleaseYear}}</span>
|
||||||
{{if not .Visible}}(hidden){{end}}
|
{{if not .Visible}}(hidden){{end}}
|
||||||
</small>
|
</small>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<dialog id="addtrack">
|
<dialog id="addtrack">
|
||||||
<header>
|
<header>
|
||||||
<h2>Add Track</h2>
|
<h2>Add track</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
|
|
321
admin/http.go
|
@ -1,54 +1,39 @@
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
"arimelody-web/log"
|
"arimelody-web/model"
|
||||||
"arimelody-web/model"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Handler(app *model.AppState) http.Handler {
|
func Handler(app *model.AppState) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
mux.Handle("/qr-test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
qrB64Img, err := controller.GenerateQRCode("super epic mega gaming test message. be sure to buy free2play on bandcamp so i can put food on my family")
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to generate QR code: %v\n", err)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Write([]byte("<html><img style=\"image-rendering:pixelated;width:100%;height:100%;object-fit:contain\" src=\"" + qrB64Img + "\"/></html>"))
|
|
||||||
}))
|
|
||||||
|
|
||||||
mux.Handle("/login", loginHandler(app))
|
mux.Handle("/login", loginHandler(app))
|
||||||
mux.Handle("/totp", loginTOTPHandler(app))
|
mux.Handle("/logout", requireAccount(app, logoutHandler(app)))
|
||||||
mux.Handle("/logout", requireAccount(logoutHandler(app)))
|
|
||||||
|
|
||||||
mux.Handle("/register", registerAccountHandler(app))
|
mux.Handle("/register", registerAccountHandler(app))
|
||||||
|
|
||||||
mux.Handle("/account", requireAccount(accountIndexHandler(app)))
|
mux.Handle("/account", requireAccount(app, accountIndexHandler(app)))
|
||||||
mux.Handle("/account/", requireAccount(http.StripPrefix("/account", accountHandler(app))))
|
mux.Handle("/account/", requireAccount(app, http.StripPrefix("/account", accountHandler(app))))
|
||||||
|
|
||||||
mux.Handle("/logs", requireAccount(logsHandler(app)))
|
mux.Handle("/release/", requireAccount(app, http.StripPrefix("/release", serveRelease(app))))
|
||||||
|
mux.Handle("/artist/", requireAccount(app, http.StripPrefix("/artist", serveArtist(app))))
|
||||||
mux.Handle("/release/", requireAccount(http.StripPrefix("/release", serveRelease(app))))
|
mux.Handle("/track/", requireAccount(app, http.StripPrefix("/track", serveTrack(app))))
|
||||||
mux.Handle("/artist/", requireAccount(http.StripPrefix("/artist", serveArtist(app))))
|
|
||||||
mux.Handle("/track/", requireAccount(http.StripPrefix("/track", serveTrack(app))))
|
|
||||||
|
|
||||||
mux.Handle("/static/", http.StripPrefix("/static", staticHandler()))
|
mux.Handle("/static/", http.StripPrefix("/static", staticHandler()))
|
||||||
|
|
||||||
mux.Handle("/", requireAccount(AdminIndexHandler(app)))
|
mux.Handle("/", requireAccount(app, AdminIndexHandler(app)))
|
||||||
|
|
||||||
// response wrapper to make sure a session cookie exists
|
// response wrapper to make sure a session cookie exists
|
||||||
return enforceSession(app, mux)
|
return enforceSession(app, mux)
|
||||||
|
@ -91,7 +76,7 @@ func AdminIndexHandler(app *model.AppState) http.Handler {
|
||||||
Tracks []*model.Track
|
Tracks []*model.Track
|
||||||
}
|
}
|
||||||
|
|
||||||
err = indexTemplate.Execute(w, IndexData{
|
err = pages["index"].Execute(w, IndexData{
|
||||||
Session: session,
|
Session: session,
|
||||||
Releases: releases,
|
Releases: releases,
|
||||||
Artists: artists,
|
Artists: artists,
|
||||||
|
@ -120,7 +105,7 @@ func registerAccountHandler(app *model.AppState) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
render := func() {
|
render := func() {
|
||||||
err := registerTemplate.Execute(w, registerData{ Session: session })
|
err := pages["register"].Execute(w, registerData{ Session: session })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Error rendering create account page: %s\n", err)
|
fmt.Printf("WARN: Error rendering create account page: %s\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
@ -201,12 +186,15 @@ func registerAccountHandler(app *model.AppState) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "Account \"%s\" (%s) created using invite \"%s\". (%s)", account.Username, account.ID, invite.Code, controller.ResolveIP(app, r))
|
fmt.Printf(
|
||||||
|
"[%s]: Account registered: %s (%s)\n",
|
||||||
|
time.Now().Format(time.UnixDate),
|
||||||
|
account.Username,
|
||||||
|
account.ID,
|
||||||
|
)
|
||||||
|
|
||||||
err = controller.DeleteInvite(app.DB, invite.Code)
|
err = controller.DeleteInvite(app.DB, invite.Code)
|
||||||
if err != nil {
|
if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) }
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete expired invite \"%s\": %v", invite.Code, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// registration success!
|
// registration success!
|
||||||
controller.SetSessionAccount(app.DB, session, &account)
|
controller.SetSessionAccount(app.DB, session, &account)
|
||||||
|
@ -230,7 +218,7 @@ func loginHandler(app *model.AppState) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
render := func() {
|
render := func() {
|
||||||
err := loginTemplate.Execute(w, loginData{ Session: session })
|
err := pages["login"].Execute(w, loginData{ Session: session })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
|
fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
@ -244,6 +232,7 @@ func loginHandler(app *model.AppState) http.Handler {
|
||||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
render()
|
render()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -254,15 +243,23 @@ func loginHandler(app *model.AppState) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !r.Form.Has("username") || !r.Form.Has("password") {
|
// new accounts won't have TOTP methods at first. there should be a
|
||||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
// second phase of login that prompts the user for a TOTP *only*
|
||||||
return
|
// if that account has a TOTP method.
|
||||||
|
// TODO: login phases (username & password -> TOTP)
|
||||||
|
|
||||||
|
type LoginRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
TOTP string `json:"totp"`
|
||||||
|
}
|
||||||
|
credentials := LoginRequest{
|
||||||
|
Username: r.Form.Get("username"),
|
||||||
|
Password: r.Form.Get("password"),
|
||||||
|
TOTP: r.Form.Get("totp"),
|
||||||
}
|
}
|
||||||
|
|
||||||
username := r.FormValue("username")
|
account, err := controller.GetAccountByUsername(app.DB, credentials.Username)
|
||||||
password := r.FormValue("password")
|
|
||||||
|
|
||||||
account, err := controller.GetAccountByUsername(app.DB, username)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err)
|
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account for login: %v\n", err)
|
||||||
controller.SetSessionError(app.DB, session, "Invalid username or password.")
|
controller.SetSessionError(app.DB, session, "Invalid username or password.")
|
||||||
|
@ -274,145 +271,81 @@ func loginHandler(app *model.AppState) http.Handler {
|
||||||
render()
|
render()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if account.Locked {
|
|
||||||
controller.SetSessionError(app.DB, session, "This account is locked.")
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
|
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r))
|
fmt.Printf(
|
||||||
if locked := handleFailedLogin(app, account, r); locked {
|
"[%s] INFO: Account \"%s\" attempted login with incorrect password.\n",
|
||||||
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
|
time.Now().Format(time.UnixDate),
|
||||||
} else {
|
account.Username,
|
||||||
controller.SetSessionError(app.DB, session, "Invalid username or password.")
|
)
|
||||||
}
|
controller.SetSessionError(app.DB, session, "Invalid username or password.")
|
||||||
render()
|
render()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
|
var totpMethod *model.TOTP
|
||||||
if err != nil {
|
if len(credentials.TOTP) == 0 {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
|
// check if user has TOTP
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(totps) > 0 {
|
|
||||||
err = controller.SetSessionAttemptAccount(app.DB, session, account)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to set attempt session: %v\n", err)
|
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
render()
|
render()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
controller.SetSessionMessage(app.DB, session, "")
|
|
||||||
controller.SetSessionError(app.DB, session, "")
|
|
||||||
http.Redirect(w, r, "/admin/totp", http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// login success!
|
if len(totps) > 0 {
|
||||||
// TODO: log login activity to user
|
type loginTOTPData struct {
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in. (%s)", account.Username, controller.ResolveIP(app, r))
|
Session *model.Session
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" does not have any TOTP methods assigned.", account.Username)
|
Username string
|
||||||
|
Password string
|
||||||
err = controller.SetSessionAccount(app.DB, session, account)
|
}
|
||||||
if err != nil {
|
err = pages["login-totp"].Execute(w, loginTOTPData{
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
|
Session: session,
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
Username: credentials.Username,
|
||||||
render()
|
Password: credentials.Password,
|
||||||
return
|
})
|
||||||
}
|
if err != nil {
|
||||||
controller.SetSessionMessage(app.DB, session, "")
|
fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
|
||||||
controller.SetSessionError(app.DB, session, "")
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
return
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
func loginTOTPHandler(app *model.AppState) http.Handler {
|
totpMethod, err = controller.CheckTOTPForAccount(app.DB, account.ID, credentials.TOTP)
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
|
||||||
|
|
||||||
if session.AttemptAccount == nil {
|
|
||||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type loginTOTPData struct {
|
|
||||||
Session *model.Session
|
|
||||||
}
|
|
||||||
|
|
||||||
render := func() {
|
|
||||||
err := loginTOTPTemplate.Execute(w, loginTOTPData{ Session: session })
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render login TOTP page: %v\n", err)
|
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if totpMethod == nil {
|
||||||
|
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
|
||||||
|
render()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == http.MethodGet {
|
if totpMethod != nil {
|
||||||
render()
|
fmt.Printf(
|
||||||
return
|
"[%s] INFO: Account \"%s\" logged in with method \"%s\"\n",
|
||||||
|
time.Now().Format(time.UnixDate),
|
||||||
|
account.Username,
|
||||||
|
totpMethod.Name,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(
|
||||||
|
"[%s] INFO: Account \"%s\" logged in\n",
|
||||||
|
time.Now().Format(time.UnixDate),
|
||||||
|
account.Username,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method != http.MethodPost {
|
// TODO: log login activity to user
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
r.ParseForm()
|
// login success!
|
||||||
|
controller.SetSessionAccount(app.DB, session, account)
|
||||||
if !r.Form.Has("totp") {
|
|
||||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
totpCode := r.FormValue("totp")
|
|
||||||
|
|
||||||
if len(totpCode) != controller.TOTP_CODE_LENGTH {
|
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
|
|
||||||
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
totpMethod, err := controller.CheckTOTPForAccount(app.DB, session.AttemptAccount.ID, totpCode)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to check TOTPs: %v\n", err)
|
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if totpMethod == nil {
|
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
|
|
||||||
if locked := handleFailedLogin(app, session.AttemptAccount, r); locked {
|
|
||||||
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
|
|
||||||
controller.SetSessionAttemptAccount(app.DB, session, nil)
|
|
||||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
|
||||||
} else {
|
|
||||||
controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
|
|
||||||
}
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "\"%s\" logged in with TOTP method \"%s\". (%s)", session.AttemptAccount.Username, totpMethod.Name, controller.ResolveIP(app, r))
|
|
||||||
|
|
||||||
err = controller.SetSessionAccount(app.DB, session, session.AttemptAccount)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to set session account: %v\n", err)
|
|
||||||
controller.SetSessionError(app.DB, session, "Something went wrong. Please try again.")
|
|
||||||
render()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = controller.SetSessionAttemptAccount(app.DB, session, nil)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to clear attempt session: %v\n", err)
|
|
||||||
}
|
|
||||||
controller.SetSessionMessage(app.DB, session, "")
|
controller.SetSessionMessage(app.DB, session, "")
|
||||||
controller.SetSessionError(app.DB, session, "")
|
controller.SetSessionError(app.DB, session, "")
|
||||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||||
|
@ -440,7 +373,7 @@ func logoutHandler(app *model.AppState) http.Handler {
|
||||||
Path: "/",
|
Path: "/",
|
||||||
})
|
})
|
||||||
|
|
||||||
err = logoutTemplate.Execute(w, nil)
|
err = pages["logout"].Execute(w, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err)
|
fmt.Fprintf(os.Stderr, "WARN: Failed to render logout page: %v\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
@ -448,7 +381,7 @@ func logoutHandler(app *model.AppState) http.Handler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func requireAccount(next http.Handler) http.HandlerFunc {
|
func requireAccount(app *model.AppState, next http.Handler) http.HandlerFunc {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*model.Session)
|
session := r.Context().Value("session").(*model.Session)
|
||||||
if session.Account == nil {
|
if session.Account == nil {
|
||||||
|
@ -483,13 +416,30 @@ func staticHandler() http.Handler {
|
||||||
|
|
||||||
func enforceSession(app *model.AppState, next http.Handler) http.Handler {
|
func enforceSession(app *model.AppState, next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
session, err := controller.GetSessionFromRequest(app, r)
|
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
|
||||||
if err != nil {
|
if err != nil && err != http.ErrNoCookie {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
|
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session cookie: %v\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var session *model.Session
|
||||||
|
|
||||||
|
if sessionCookie != nil {
|
||||||
|
// fetch existing session
|
||||||
|
session, err = controller.GetSession(app.DB, sessionCookie.Value)
|
||||||
|
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "no rows") {
|
||||||
|
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
|
||||||
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session != nil {
|
||||||
|
// TODO: consider running security checks here (i.e. user agent mismatches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if session == nil {
|
if session == nil {
|
||||||
// create a new session
|
// create a new session
|
||||||
session, err = controller.CreateSession(app.DB, r.UserAgent())
|
session, err = controller.CreateSession(app.DB, r.UserAgent())
|
||||||
|
@ -513,30 +463,3 @@ func enforceSession(app *model.AppState, next http.Handler) http.Handler {
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleFailedLogin(app *model.AppState, account *model.Account, r *http.Request) bool {
|
|
||||||
locked, err := controller.IncrementAccountFails(app.DB, account.ID)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(
|
|
||||||
os.Stderr,
|
|
||||||
"WARN: Failed to increment login failures for \"%s\": %v\n",
|
|
||||||
account.Username,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
app.Log.Warn(
|
|
||||||
log.TYPE_ACCOUNT,
|
|
||||||
"Failed to increment login failures for \"%s\"",
|
|
||||||
account.Username,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if locked {
|
|
||||||
app.Log.Warn(
|
|
||||||
log.TYPE_ACCOUNT,
|
|
||||||
"Account \"%s\" was locked: %d failed login attempts (IP: %s)",
|
|
||||||
account.Username,
|
|
||||||
model.MAX_LOGIN_FAIL_ATTEMPTS,
|
|
||||||
controller.ResolveIP(app, r),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return locked
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,67 +0,0 @@
|
||||||
package admin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"arimelody-web/log"
|
|
||||||
"arimelody-web/model"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func logsHandler(app *model.AppState) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
|
||||||
|
|
||||||
levelFilter := []log.LogLevel{}
|
|
||||||
typeFilter := []string{}
|
|
||||||
|
|
||||||
query := r.URL.Query().Get("q")
|
|
||||||
|
|
||||||
for key, value := range r.URL.Query() {
|
|
||||||
if strings.HasPrefix(key, "level-") && value[0] == "on" {
|
|
||||||
m := map[string]log.LogLevel{
|
|
||||||
"info": log.LEVEL_INFO,
|
|
||||||
"warn": log.LEVEL_WARN,
|
|
||||||
}
|
|
||||||
level, ok := m[strings.TrimPrefix(key, "level-")]
|
|
||||||
if ok {
|
|
||||||
levelFilter = append(levelFilter, level)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(key, "type-") && value[0] == "on" {
|
|
||||||
typeFilter = append(typeFilter, string(strings.TrimPrefix(key, "type-")))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logs, err := app.Log.Search(levelFilter, typeFilter, query, 100, 0)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to fetch audit logs: %v\n", err)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type LogsResponse struct {
|
|
||||||
Session *model.Session
|
|
||||||
Logs []*log.Log
|
|
||||||
}
|
|
||||||
|
|
||||||
err = logsTemplate.Execute(w, LogsResponse{
|
|
||||||
Session: session,
|
|
||||||
Logs: logs,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render audit logs page: %v\n", err)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,12 +1,12 @@
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func serveRelease(app *model.AppState) http.Handler {
|
func serveRelease(app *model.AppState) http.Handler {
|
||||||
|
@ -22,7 +22,7 @@ func serveRelease(app *model.AppState) http.Handler {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Printf("WARN: Failed to pull full release data for %s: %s\n", releaseID, err)
|
fmt.Printf("FATAL: Failed to pull full release data for %s: %s\n", releaseID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -60,7 +60,7 @@ func serveRelease(app *model.AppState) http.Handler {
|
||||||
Release *model.Release
|
Release *model.Release
|
||||||
}
|
}
|
||||||
|
|
||||||
err = releaseTemplate.Execute(w, ReleaseResponse{
|
err = pages["release"].Execute(w, ReleaseResponse{
|
||||||
Session: session,
|
Session: session,
|
||||||
Release: release,
|
Release: release,
|
||||||
})
|
})
|
||||||
|
@ -74,7 +74,7 @@ func serveRelease(app *model.AppState) http.Handler {
|
||||||
func serveEditCredits(release *model.Release) http.Handler {
|
func serveEditCredits(release *model.Release) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
err := editCreditsTemplate.Execute(w, release)
|
err := components["editcredits"].Execute(w, release)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error rendering edit credits component for %s: %s\n", release.ID, err)
|
fmt.Printf("Error rendering edit credits component for %s: %s\n", release.ID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
@ -86,7 +86,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID)
|
artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to pull artists not on %s: %s\n", release.ID, err)
|
fmt.Printf("FATAL: Failed to pull artists not on %s: %s\n", release.ID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -97,7 +97,7 @@ func serveAddCredit(app *model.AppState, release *model.Release) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
err = addCreditTemplate.Execute(w, response{
|
err = components["addcredit"].Execute(w, response{
|
||||||
ReleaseID: release.ID,
|
ReleaseID: release.ID,
|
||||||
Artists: artists,
|
Artists: artists,
|
||||||
})
|
})
|
||||||
|
@ -113,7 +113,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
|
||||||
artistID := strings.Split(r.URL.Path, "/")[3]
|
artistID := strings.Split(r.URL.Path, "/")[3]
|
||||||
artist, err := controller.GetArtist(app.DB, artistID)
|
artist, err := controller.GetArtist(app.DB, artistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to pull artists %s: %s\n", artistID, err)
|
fmt.Printf("FATAL: Failed to pull artists %s: %s\n", artistID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -123,7 +123,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
err = newCreditTemplate.Execute(w, artist)
|
err = components["newcredit"].Execute(w, artist)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error rendering new credit component for %s: %s\n", artist.ID, err)
|
fmt.Printf("Error rendering new credit component for %s: %s\n", artist.ID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
@ -134,7 +134,7 @@ func serveNewCredit(app *model.AppState) http.Handler {
|
||||||
func serveEditLinks(release *model.Release) http.Handler {
|
func serveEditLinks(release *model.Release) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
err := editLinksTemplate.Execute(w, release)
|
err := components["editlinks"].Execute(w, release)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error rendering edit links component for %s: %s\n", release.ID, err)
|
fmt.Printf("Error rendering edit links component for %s: %s\n", release.ID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
@ -151,7 +151,7 @@ func serveEditTracks(release *model.Release) http.Handler {
|
||||||
Add func(a int, b int) int
|
Add func(a int, b int) int
|
||||||
}
|
}
|
||||||
|
|
||||||
err := editTracksTemplate.Execute(w, editTracksData{
|
err := components["edittracks"].Execute(w, editTracksData{
|
||||||
Release: release,
|
Release: release,
|
||||||
Add: func(a, b int) int { return a + b },
|
Add: func(a, b int) int { return a + b },
|
||||||
})
|
})
|
||||||
|
@ -166,7 +166,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID)
|
tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to pull tracks not on %s: %s\n", release.ID, err)
|
fmt.Printf("FATAL: Failed to pull tracks not on %s: %s\n", release.ID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -177,7 +177,7 @@ func serveAddTrack(app *model.AppState, release *model.Release) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
err = addTrackTemplate.Execute(w, response{
|
err = components["addtrack"].Execute(w, response{
|
||||||
ReleaseID: release.ID,
|
ReleaseID: release.ID,
|
||||||
Tracks: tracks,
|
Tracks: tracks,
|
||||||
})
|
})
|
||||||
|
@ -204,7 +204,7 @@ func serveNewTrack(app *model.AppState) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
err = newTrackTemplate.Execute(w, track)
|
err = components["newtrack"].Execute(w, track)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error rendering new track component for %s: %s\n", track.ID, err)
|
fmt.Printf("Error rendering new track component for %s: %s\n", track.ID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
|
|
@ -1,86 +0,0 @@
|
||||||
main {
|
|
||||||
width: min(1080px, calc(100% - 2em))!important
|
|
||||||
}
|
|
||||||
|
|
||||||
form {
|
|
||||||
margin: 1em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
div#search {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
#search input {
|
|
||||||
margin: 0;
|
|
||||||
flex-grow: 1;
|
|
||||||
|
|
||||||
border-right: none;
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
border-bottom-right-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#search button {
|
|
||||||
padding: 0 .5em;
|
|
||||||
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-bottom-left-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
form #filters p {
|
|
||||||
margin: .5em 0 0 0;
|
|
||||||
}
|
|
||||||
form #filters label {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
form #filters input {
|
|
||||||
margin-right: 1em;
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
|
|
||||||
#logs {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
#logs tr {
|
|
||||||
}
|
|
||||||
|
|
||||||
#logs tr td {
|
|
||||||
border-bottom: 1px solid #8888;
|
|
||||||
}
|
|
||||||
|
|
||||||
#logs tr td:nth-child(even) {
|
|
||||||
background: #00000004;
|
|
||||||
}
|
|
||||||
|
|
||||||
#logs th, #logs td {
|
|
||||||
padding: .4em .8em;
|
|
||||||
}
|
|
||||||
|
|
||||||
td, th {
|
|
||||||
width: 1%;
|
|
||||||
text-align: left;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
td.log-level,
|
|
||||||
th.log-level,
|
|
||||||
td.log-type,
|
|
||||||
th.log-type {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
td.log-content,
|
|
||||||
td.log-content {
|
|
||||||
width: 100%;
|
|
||||||
white-space: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log:hover {
|
|
||||||
background: #fff8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log.warn {
|
|
||||||
background: #ffe86a;
|
|
||||||
}
|
|
||||||
.log.warn:hover {
|
|
||||||
background: #ffec81;
|
|
||||||
}
|
|
|
@ -1,125 +1,80 @@
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"arimelody-web/log"
|
"html/template"
|
||||||
"fmt"
|
"path/filepath"
|
||||||
"html/template"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var indexTemplate = template.Must(template.ParseFiles(
|
var pages = map[string]*template.Template{
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
"index": template.Must(template.ParseFiles(
|
||||||
filepath.Join("views", "prideflag.html"),
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
filepath.Join("admin", "components", "release", "release-list-item.html"),
|
filepath.Join("views", "prideflag.html"),
|
||||||
filepath.Join("admin", "views", "index.html"),
|
filepath.Join("admin", "components", "release", "release-list-item.html"),
|
||||||
))
|
filepath.Join("admin", "views", "index.html"),
|
||||||
|
)),
|
||||||
|
|
||||||
var loginTemplate = template.Must(template.ParseFiles(
|
"login": template.Must(template.ParseFiles(
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
filepath.Join("views", "prideflag.html"),
|
filepath.Join("views", "prideflag.html"),
|
||||||
filepath.Join("admin", "views", "login.html"),
|
filepath.Join("admin", "views", "login.html"),
|
||||||
))
|
)),
|
||||||
var loginTOTPTemplate = template.Must(template.ParseFiles(
|
"login-totp": template.Must(template.ParseFiles(
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
filepath.Join("views", "prideflag.html"),
|
filepath.Join("views", "prideflag.html"),
|
||||||
filepath.Join("admin", "views", "login-totp.html"),
|
filepath.Join("admin", "views", "login-totp.html"),
|
||||||
))
|
)),
|
||||||
var registerTemplate = template.Must(template.ParseFiles(
|
"register": template.Must(template.ParseFiles(
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
filepath.Join("views", "prideflag.html"),
|
filepath.Join("views", "prideflag.html"),
|
||||||
filepath.Join("admin", "views", "register.html"),
|
filepath.Join("admin", "views", "register.html"),
|
||||||
))
|
)),
|
||||||
var logoutTemplate = template.Must(template.ParseFiles(
|
"logout": template.Must(template.ParseFiles(
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
filepath.Join("views", "prideflag.html"),
|
filepath.Join("views", "prideflag.html"),
|
||||||
filepath.Join("admin", "views", "logout.html"),
|
filepath.Join("admin", "views", "logout.html"),
|
||||||
))
|
)),
|
||||||
var accountTemplate = template.Must(template.ParseFiles(
|
"account": template.Must(template.ParseFiles(
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
filepath.Join("views", "prideflag.html"),
|
filepath.Join("views", "prideflag.html"),
|
||||||
filepath.Join("admin", "views", "edit-account.html"),
|
filepath.Join("admin", "views", "edit-account.html"),
|
||||||
))
|
)),
|
||||||
var totpSetupTemplate = template.Must(template.ParseFiles(
|
"totp-setup": template.Must(template.ParseFiles(
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
filepath.Join("views", "prideflag.html"),
|
filepath.Join("views", "prideflag.html"),
|
||||||
filepath.Join("admin", "views", "totp-setup.html"),
|
filepath.Join("admin", "views", "totp-setup.html"),
|
||||||
))
|
)),
|
||||||
var totpConfirmTemplate = template.Must(template.ParseFiles(
|
"totp-confirm": template.Must(template.ParseFiles(
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
filepath.Join("views", "prideflag.html"),
|
filepath.Join("views", "prideflag.html"),
|
||||||
filepath.Join("admin", "views", "totp-confirm.html"),
|
filepath.Join("admin", "views", "totp-confirm.html"),
|
||||||
))
|
)),
|
||||||
|
|
||||||
var logsTemplate = template.Must(template.New("layout.html").Funcs(template.FuncMap{
|
"release": template.Must(template.ParseFiles(
|
||||||
"parseLevel": func(level log.LogLevel) string {
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
switch level {
|
filepath.Join("views", "prideflag.html"),
|
||||||
case log.LEVEL_INFO:
|
filepath.Join("admin", "views", "edit-release.html"),
|
||||||
return "INFO"
|
)),
|
||||||
case log.LEVEL_WARN:
|
"artist": template.Must(template.ParseFiles(
|
||||||
return "WARN"
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
}
|
filepath.Join("views", "prideflag.html"),
|
||||||
return fmt.Sprintf("%d?", level)
|
filepath.Join("admin", "views", "edit-artist.html"),
|
||||||
},
|
)),
|
||||||
"titleCase": func(logType string) string {
|
"track": template.Must(template.ParseFiles(
|
||||||
runes := []rune(logType)
|
filepath.Join("admin", "views", "layout.html"),
|
||||||
for i, r := range runes {
|
filepath.Join("views", "prideflag.html"),
|
||||||
if (i == 0 || runes[i - 1] == ' ') && r >= 'a' && r <= 'z' {
|
filepath.Join("admin", "components", "release", "release-list-item.html"),
|
||||||
runes[i] = r + ('A' - 'a')
|
filepath.Join("admin", "views", "edit-track.html"),
|
||||||
}
|
)),
|
||||||
}
|
}
|
||||||
return string(runes)
|
|
||||||
},
|
|
||||||
"lower": func(str string) string { return strings.ToLower(str) },
|
|
||||||
"prettyTime": func(t time.Time) string {
|
|
||||||
// return t.Format("2006-01-02 15:04:05")
|
|
||||||
// return t.Format("15:04:05, 2 Jan 2006")
|
|
||||||
return t.Format("02 Jan 2006, 15:04:05")
|
|
||||||
},
|
|
||||||
}).ParseFiles(
|
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
|
||||||
filepath.Join("views", "prideflag.html"),
|
|
||||||
filepath.Join("admin", "views", "logs.html"),
|
|
||||||
))
|
|
||||||
|
|
||||||
var releaseTemplate = template.Must(template.ParseFiles(
|
var components = map[string]*template.Template{
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
"editcredits": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "editcredits.html"))),
|
||||||
filepath.Join("views", "prideflag.html"),
|
"addcredit": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "addcredit.html"))),
|
||||||
filepath.Join("admin", "views", "edit-release.html"),
|
"newcredit": template.Must(template.ParseFiles(filepath.Join("admin", "components", "credits", "newcredit.html"))),
|
||||||
))
|
|
||||||
var artistTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
|
||||||
filepath.Join("views", "prideflag.html"),
|
|
||||||
filepath.Join("admin", "views", "edit-artist.html"),
|
|
||||||
))
|
|
||||||
var trackTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "views", "layout.html"),
|
|
||||||
filepath.Join("views", "prideflag.html"),
|
|
||||||
filepath.Join("admin", "components", "release", "release-list-item.html"),
|
|
||||||
filepath.Join("admin", "views", "edit-track.html"),
|
|
||||||
))
|
|
||||||
|
|
||||||
var editCreditsTemplate = template.Must(template.ParseFiles(
|
"editlinks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "links", "editlinks.html"))),
|
||||||
filepath.Join("admin", "components", "credits", "editcredits.html"),
|
|
||||||
))
|
|
||||||
var addCreditTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "components", "credits", "addcredit.html"),
|
|
||||||
))
|
|
||||||
var newCreditTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "components", "credits", "newcredit.html"),
|
|
||||||
))
|
|
||||||
|
|
||||||
var editLinksTemplate = template.Must(template.ParseFiles(
|
"edittracks": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "edittracks.html"))),
|
||||||
filepath.Join("admin", "components", "links", "editlinks.html"),
|
"addtrack": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "addtrack.html"))),
|
||||||
))
|
"newtrack": template.Must(template.ParseFiles(filepath.Join("admin", "components", "tracks", "newtrack.html"))),
|
||||||
|
}
|
||||||
var editTracksTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "components", "tracks", "edittracks.html"),
|
|
||||||
))
|
|
||||||
var addTrackTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "components", "tracks", "addtrack.html"),
|
|
||||||
))
|
|
||||||
var newTrackTemplate = template.Must(template.ParseFiles(
|
|
||||||
filepath.Join("admin", "components", "tracks", "newtrack.html"),
|
|
||||||
))
|
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
|
@ -39,7 +39,7 @@ func serveTrack(app *model.AppState) http.Handler {
|
||||||
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
session := r.Context().Value("session").(*model.Session)
|
||||||
|
|
||||||
err = trackTemplate.Execute(w, TrackResponse{
|
err = pages["track"].Execute(w, TrackResponse{
|
||||||
Session: session,
|
Session: session,
|
||||||
Track: track,
|
Track: track,
|
||||||
Releases: releases,
|
Releases: releases,
|
||||||
|
|
|
@ -23,14 +23,7 @@
|
||||||
<div class="nav-item">
|
<div class="nav-item">
|
||||||
<a href="/admin">home</a>
|
<a href="/admin">home</a>
|
||||||
</div>
|
</div>
|
||||||
{{if .Session.Account}}
|
|
||||||
<div class="nav-item">
|
|
||||||
<a href="/admin/logs">logs</a>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<div class="flex-fill"></div>
|
<div class="flex-fill"></div>
|
||||||
|
|
||||||
{{if .Session.Account}}
|
{{if .Session.Account}}
|
||||||
<div class="nav-item">
|
<div class="nav-item">
|
||||||
<a href="/admin/account">account ({{.Session.Account.Username}})</a>
|
<a href="/admin/account">account ({{.Session.Account.Username}})</a>
|
||||||
|
|
|
@ -26,19 +26,14 @@ input {
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<main>
|
<main>
|
||||||
{{if .Session.Message.Valid}}
|
<form action="/admin/login" method="POST" id="login-totp">
|
||||||
<p id="message">{{html .Session.Message.String}}</p>
|
|
||||||
{{end}}
|
|
||||||
{{if .Session.Error.Valid}}
|
|
||||||
<p id="error">{{html .Session.Error.String}}</p>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<form action="/admin/totp" method="POST" id="login-totp">
|
|
||||||
<h1>Two-Factor Authentication</h1>
|
<h1>Two-Factor Authentication</h1>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="totp">TOTP</label>
|
<label for="totp">TOTP</label>
|
||||||
<input type="text" name="totp" value="" autocomplete="one-time-code" required autofocus>
|
<input type="text" name="totp" value="" autocomplete="one-time-code" required autofocus>
|
||||||
|
<input type="hidden" name="username" value="{{.Username}}">
|
||||||
|
<input type="hidden" name="password" value="{{.Password}}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="save">Login</button>
|
<button type="submit" class="save">Login</button>
|
||||||
|
|
|
@ -1,68 +0,0 @@
|
||||||
{{define "head"}}
|
|
||||||
<title>Audit Logs - ari melody 💫</title>
|
|
||||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
|
||||||
<link rel="stylesheet" href="/admin/static/admin.css">
|
|
||||||
<link rel="stylesheet" href="/admin/static/logs.css">
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "content"}}
|
|
||||||
<main>
|
|
||||||
<h1>Audit Logs</h1>
|
|
||||||
|
|
||||||
<form action="/admin/logs" method="GET">
|
|
||||||
<div id="search">
|
|
||||||
<input type="text" name="q" value="" placeholder="Filter by message...">
|
|
||||||
<button type="submit" class="save">Search</button>
|
|
||||||
</div>
|
|
||||||
<div id="filters">
|
|
||||||
<div>
|
|
||||||
<p>Level:</p>
|
|
||||||
<label for="level-info">Info</label>
|
|
||||||
<input type="checkbox" name="level-info" id="level-info">
|
|
||||||
<label for="level-warn">Warning</label>
|
|
||||||
<input type="checkbox" name="level-warn" id="level-warn">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p>Type:</p>
|
|
||||||
<label for="type-account">Account</label>
|
|
||||||
<input type="checkbox" name="type-account" id="type-account">
|
|
||||||
<label for="type-music">Music</label>
|
|
||||||
<input type="checkbox" name="type-music" id="type-music">
|
|
||||||
<label for="type-artist">Artist</label>
|
|
||||||
<input type="checkbox" name="type-artist" id="type-artist">
|
|
||||||
<label for="type-blog">Blog</label>
|
|
||||||
<input type="checkbox" name="type-blog" id="type-blog">
|
|
||||||
<label for="type-artwork">Artwork</label>
|
|
||||||
<input type="checkbox" name="type-artwork" id="type-artwork">
|
|
||||||
<label for="type-files">Files</label>
|
|
||||||
<input type="checkbox" name="type-files" id="type-files">
|
|
||||||
<label for="type-misc">Misc</label>
|
|
||||||
<input type="checkbox" name="type-misc" id="type-misc">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<table id="logs">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="log-time">Time</th>
|
|
||||||
<th class="log-level">Level</th>
|
|
||||||
<th class="log-type">Type</th>
|
|
||||||
<th class="log-content">Message</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Logs}}
|
|
||||||
<tr class="log {{lower (parseLevel .Level)}}">
|
|
||||||
<td class="log-time">{{prettyTime .CreatedAt}}</td>
|
|
||||||
<td class="log-level">{{parseLevel .Level}}</td>
|
|
||||||
<td class="log-type">{{titleCase .Type}}</td>
|
|
||||||
<td class="log-content">{{.Content}}</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</main>
|
|
||||||
{{end}}
|
|
|
@ -3,9 +3,6 @@
|
||||||
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
|
||||||
<link rel="stylesheet" href="/admin/static/admin.css">
|
<link rel="stylesheet" href="/admin/static/admin.css">
|
||||||
<style>
|
<style>
|
||||||
.qr-code {
|
|
||||||
border: 1px solid #8888;
|
|
||||||
}
|
|
||||||
code {
|
code {
|
||||||
user-select: all;
|
user-select: all;
|
||||||
}
|
}
|
||||||
|
@ -19,26 +16,15 @@ code {
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form action="/admin/account/totp-confirm?totp-name={{.NameEscaped}}" method="POST" id="totp-setup">
|
<form action="/admin/account/totp-confirm?totp-name={{.NameEscaped}}" method="POST" id="totp-setup">
|
||||||
{{if .QRBase64Image}}
|
<p><strong>Your TOTP secret: </strong><code>{{.TOTP.Secret}}</code></p>
|
||||||
<img src="data:image/png;base64,{{.QRBase64Image}}" alt="" class="qr-code">
|
|
||||||
|
<!-- TODO: TOTP secret QR codes -->
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Scan the QR code above into your authentication app or password manager,
|
Please store this into your two-factor authentication app or
|
||||||
then enter your 2FA code below.
|
password manager, then enter your code below:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
|
||||||
If the QR code does not work, you may also enter this secret code:
|
|
||||||
</p>
|
|
||||||
{{else}}
|
|
||||||
<p>
|
|
||||||
Paste the below secret code into your authentication app or password manager,
|
|
||||||
then enter your 2FA code below:
|
|
||||||
</p>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<p><code>{{.TOTP.Secret}}</code></p>
|
|
||||||
|
|
||||||
<label for="totp">TOTP:</label>
|
<label for="totp">TOTP:</label>
|
||||||
<input type="text" name="totp" value="" autocomplete="one-time-code" required autofocus>
|
<input type="text" name="totp" value="" autocomplete="one-time-code" required autofocus>
|
||||||
|
|
||||||
|
|
58
api/api.go
|
@ -1,15 +1,15 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Handler(app *model.AppState) http.Handler {
|
func Handler(app *model.AppState) http.Handler {
|
||||||
|
@ -27,7 +27,7 @@ func Handler(app *model.AppState) http.Handler {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Printf("WARN: Error while retrieving artist %s: %s\n", artistID, err)
|
fmt.Printf("FATAL: Error while retrieving artist %s: %s\n", artistID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -38,10 +38,10 @@ func Handler(app *model.AppState) http.Handler {
|
||||||
ServeArtist(app, artist).ServeHTTP(w, r)
|
ServeArtist(app, artist).ServeHTTP(w, r)
|
||||||
case http.MethodPut:
|
case http.MethodPut:
|
||||||
// PUT /api/v1/artist/{id} (admin)
|
// PUT /api/v1/artist/{id} (admin)
|
||||||
requireAccount(UpdateArtist(app, artist)).ServeHTTP(w, r)
|
requireAccount(app, UpdateArtist(app, artist)).ServeHTTP(w, r)
|
||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
// DELETE /api/v1/artist/{id} (admin)
|
// DELETE /api/v1/artist/{id} (admin)
|
||||||
requireAccount(DeleteArtist(app, artist)).ServeHTTP(w, r)
|
requireAccount(app, DeleteArtist(app, artist)).ServeHTTP(w, r)
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
|
@ -53,7 +53,7 @@ func Handler(app *model.AppState) http.Handler {
|
||||||
ServeAllArtists(app).ServeHTTP(w, r)
|
ServeAllArtists(app).ServeHTTP(w, r)
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
// POST /api/v1/artist (admin)
|
// POST /api/v1/artist (admin)
|
||||||
requireAccount(CreateArtist(app)).ServeHTTP(w, r)
|
requireAccount(app, CreateArtist(app)).ServeHTTP(w, r)
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
|
@ -69,7 +69,7 @@ func Handler(app *model.AppState) http.Handler {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Printf("WARN: Error while retrieving release %s: %s\n", releaseID, err)
|
fmt.Printf("FATAL: Error while retrieving release %s: %s\n", releaseID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -80,10 +80,10 @@ func Handler(app *model.AppState) http.Handler {
|
||||||
ServeRelease(app, release).ServeHTTP(w, r)
|
ServeRelease(app, release).ServeHTTP(w, r)
|
||||||
case http.MethodPut:
|
case http.MethodPut:
|
||||||
// PUT /api/v1/music/{id} (admin)
|
// PUT /api/v1/music/{id} (admin)
|
||||||
requireAccount(UpdateRelease(app, release)).ServeHTTP(w, r)
|
requireAccount(app, UpdateRelease(app, release)).ServeHTTP(w, r)
|
||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
// DELETE /api/v1/music/{id} (admin)
|
// DELETE /api/v1/music/{id} (admin)
|
||||||
requireAccount(DeleteRelease(app, release)).ServeHTTP(w, r)
|
requireAccount(app, DeleteRelease(app, release)).ServeHTTP(w, r)
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
|
@ -95,7 +95,7 @@ func Handler(app *model.AppState) http.Handler {
|
||||||
ServeCatalog(app).ServeHTTP(w, r)
|
ServeCatalog(app).ServeHTTP(w, r)
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
// POST /api/v1/music (admin)
|
// POST /api/v1/music (admin)
|
||||||
requireAccount(CreateRelease(app)).ServeHTTP(w, r)
|
requireAccount(app, CreateRelease(app)).ServeHTTP(w, r)
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
|
@ -111,7 +111,7 @@ func Handler(app *model.AppState) http.Handler {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Printf("WARN: Error while retrieving track %s: %s\n", trackID, err)
|
fmt.Printf("FATAL: Error while retrieving track %s: %s\n", trackID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -119,13 +119,13 @@ func Handler(app *model.AppState) http.Handler {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
// GET /api/v1/track/{id} (admin)
|
// GET /api/v1/track/{id} (admin)
|
||||||
requireAccount(ServeTrack(app, track)).ServeHTTP(w, r)
|
requireAccount(app, ServeTrack(app, track)).ServeHTTP(w, r)
|
||||||
case http.MethodPut:
|
case http.MethodPut:
|
||||||
// PUT /api/v1/track/{id} (admin)
|
// PUT /api/v1/track/{id} (admin)
|
||||||
requireAccount(UpdateTrack(app, track)).ServeHTTP(w, r)
|
requireAccount(app, UpdateTrack(app, track)).ServeHTTP(w, r)
|
||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
// DELETE /api/v1/track/{id} (admin)
|
// DELETE /api/v1/track/{id} (admin)
|
||||||
requireAccount(DeleteTrack(app, track)).ServeHTTP(w, r)
|
requireAccount(app, DeleteTrack(app, track)).ServeHTTP(w, r)
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
|
@ -134,15 +134,19 @@ func Handler(app *model.AppState) http.Handler {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
// GET /api/v1/track (admin)
|
// GET /api/v1/track (admin)
|
||||||
requireAccount(ServeAllTracks(app)).ServeHTTP(w, r)
|
requireAccount(app, ServeAllTracks(app)).ServeHTTP(w, r)
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
// POST /api/v1/track (admin)
|
// POST /api/v1/track (admin)
|
||||||
requireAccount(CreateTrack(app)).ServeHTTP(w, r)
|
requireAccount(app, CreateTrack(app)).ServeHTTP(w, r)
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireAccount(app *model.AppState, next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
session, err := getSession(app, r)
|
session, err := getSession(app, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -150,15 +154,7 @@ func Handler(app *model.AppState) http.Handler {
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := context.WithValue(r.Context(), "session", session)
|
if session.Account == nil {
|
||||||
mux.ServeHTTP(w, r.WithContext(ctx))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireAccount(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
|
||||||
if session == nil || session.Account == nil {
|
|
||||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,66 +1,65 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
"arimelody-web/log"
|
"arimelody-web/model"
|
||||||
"arimelody-web/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func ServeAllArtists(app *model.AppState) http.Handler {
|
func ServeAllArtists(app *model.AppState) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
var artists = []*model.Artist{}
|
var artists = []*model.Artist{}
|
||||||
artists, err := controller.GetAllArtists(app.DB)
|
artists, err := controller.GetAllArtists(app.DB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to serve all artists: %s\n", err)
|
fmt.Printf("WARN: Failed to serve all artists: %s\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Add("Content-Type", "application/json")
|
w.Header().Add("Content-Type", "application/json")
|
||||||
encoder := json.NewEncoder(w)
|
encoder := json.NewEncoder(w)
|
||||||
encoder.SetIndent("", "\t")
|
encoder.SetIndent("", "\t")
|
||||||
err = encoder.Encode(artists)
|
err = encoder.Encode(artists)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
type (
|
type (
|
||||||
creditJSON struct {
|
creditJSON struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
ReleaseDate time.Time `json:"releaseDate" db:"release_date"`
|
ReleaseDate time.Time `json:"releaseDate" db:"release_date"`
|
||||||
Artwork string `json:"artwork"`
|
Artwork string `json:"artwork"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Primary bool `json:"primary"`
|
Primary bool `json:"primary"`
|
||||||
}
|
}
|
||||||
artistJSON struct {
|
artistJSON struct {
|
||||||
*model.Artist
|
*model.Artist
|
||||||
Credits map[string]creditJSON `json:"credits"`
|
Credits map[string]creditJSON `json:"credits"`
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
session := r.Context().Value("session").(*model.Session)
|
||||||
show_hidden_releases := session != nil && session.Account != nil
|
show_hidden_releases := session != nil && session.Account != nil
|
||||||
|
|
||||||
dbCredits, err := controller.GetArtistCredits(app.DB, artist.ID, show_hidden_releases)
|
dbCredits, err := controller.GetArtistCredits(app.DB, artist.ID, show_hidden_releases)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err)
|
fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var credits = map[string]creditJSON{}
|
var credits = map[string]creditJSON{}
|
||||||
for _, credit := range dbCredits {
|
for _, credit := range dbCredits {
|
||||||
|
@ -74,23 +73,21 @@ func ServeArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Add("Content-Type", "application/json")
|
w.Header().Add("Content-Type", "application/json")
|
||||||
encoder := json.NewEncoder(w)
|
encoder := json.NewEncoder(w)
|
||||||
encoder.SetIndent("", "\t")
|
encoder.SetIndent("", "\t")
|
||||||
err = encoder.Encode(artistJSON{
|
err = encoder.Encode(artistJSON{
|
||||||
Artist: artist,
|
Artist: artist,
|
||||||
Credits: credits,
|
Credits: credits,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateArtist(app *model.AppState) http.Handler {
|
func CreateArtist(app *model.AppState) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*model.Session)
|
|
||||||
|
|
||||||
var artist model.Artist
|
var artist model.Artist
|
||||||
err := json.NewDecoder(r.Body).Decode(&artist)
|
err := json.NewDecoder(r.Body).Decode(&artist)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -115,16 +112,12 @@ func CreateArtist(app *model.AppState) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" created by \"%s\".", artist.Name, session.Account.Username)
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*model.Session)
|
|
||||||
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&artist)
|
err := json.NewDecoder(r.Body).Decode(&artist)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to update artist: %s\n", err)
|
fmt.Printf("WARN: Failed to update artist: %s\n", err)
|
||||||
|
@ -165,15 +158,11 @@ func UpdateArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
||||||
fmt.Printf("WARN: Failed to update artist %s: %s\n", artist.ID, err)
|
fmt.Printf("WARN: Failed to update artist %s: %s\n", artist.ID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" updated by \"%s\".", artist.Name, session.Account.Username)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func DeleteArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
func DeleteArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*model.Session)
|
|
||||||
|
|
||||||
err := controller.DeleteArtist(app.DB, artist.ID)
|
err := controller.DeleteArtist(app.DB, artist.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "no rows") {
|
if strings.Contains(err.Error(), "no rows") {
|
||||||
|
@ -183,7 +172,5 @@ func DeleteArtist(app *model.AppState, artist *model.Artist) http.Handler {
|
||||||
fmt.Printf("WARN: Failed to delete artist %s: %s\n", artist.ID, err)
|
fmt.Printf("WARN: Failed to delete artist %s: %s\n", artist.ID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ARTIST, "Artist \"%s\" deleted by \"%s\".", artist.Name, session.Account.Username)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,32 +1,25 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
"arimelody-web/log"
|
"arimelody-web/model"
|
||||||
"arimelody-web/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
|
func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// only allow authorised users to view hidden releases
|
// only allow authorised users to view hidden releases
|
||||||
privileged := false
|
privileged := false
|
||||||
if !release.Visible {
|
if !release.Visible {
|
||||||
session, err := controller.GetSessionFromRequest(app, r)
|
session := r.Context().Value("session").(*model.Session)
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if session != nil && session.Account != nil {
|
if session != nil && session.Account != nil {
|
||||||
// TODO: check privilege on release
|
// TODO: check privilege on release
|
||||||
privileged = true
|
privileged = true
|
||||||
|
@ -116,15 +109,15 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Add("Content-Type", "application/json")
|
w.Header().Add("Content-Type", "application/json")
|
||||||
encoder := json.NewEncoder(w)
|
encoder := json.NewEncoder(w)
|
||||||
encoder.SetIndent("", "\t")
|
encoder.SetIndent("", "\t")
|
||||||
err := encoder.Encode(response)
|
err := encoder.Encode(response)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func ServeCatalog(app *model.AppState) http.Handler {
|
func ServeCatalog(app *model.AppState) http.Handler {
|
||||||
|
@ -190,7 +183,10 @@ func ServeCatalog(app *model.AppState) http.Handler {
|
||||||
|
|
||||||
func CreateRelease(app *model.AppState) http.Handler {
|
func CreateRelease(app *model.AppState) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*model.Session)
|
if r.Method != http.MethodPost {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var release model.Release
|
var release model.Release
|
||||||
err := json.NewDecoder(r.Body).Decode(&release)
|
err := json.NewDecoder(r.Body).Decode(&release)
|
||||||
|
@ -224,8 +220,6 @@ func CreateRelease(app *model.AppState) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" created by \"%s\".", release.ID, session.Account.Username)
|
|
||||||
|
|
||||||
w.Header().Add("Content-Type", "application/json")
|
w.Header().Add("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
encoder := json.NewEncoder(w)
|
encoder := json.NewEncoder(w)
|
||||||
|
@ -240,8 +234,6 @@ func CreateRelease(app *model.AppState) http.Handler {
|
||||||
|
|
||||||
func UpdateRelease(app *model.AppState, release *model.Release) http.Handler {
|
func UpdateRelease(app *model.AppState, release *model.Release) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*model.Session)
|
|
||||||
|
|
||||||
if r.URL.Path == "/" {
|
if r.URL.Path == "/" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
|
@ -306,15 +298,11 @@ func UpdateRelease(app *model.AppState, release *model.Release) http.Handler {
|
||||||
fmt.Printf("WARN: Failed to update release %s: %s\n", release.ID, err)
|
fmt.Printf("WARN: Failed to update release %s: %s\n", release.ID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handler {
|
func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*model.Session)
|
|
||||||
|
|
||||||
var trackIDs = []string{}
|
var trackIDs = []string{}
|
||||||
err := json.NewDecoder(r.Body).Decode(&trackIDs)
|
err := json.NewDecoder(r.Body).Decode(&trackIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -331,15 +319,11 @@ func UpdateReleaseTracks(app *model.AppState, release *model.Release) http.Handl
|
||||||
fmt.Printf("WARN: Failed to update tracks for %s: %s\n", release.ID, err)
|
fmt.Printf("WARN: Failed to update tracks for %s: %s\n", release.ID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_MUSIC, "Tracklist for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Handler {
|
func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*model.Session)
|
|
||||||
|
|
||||||
type creditJSON struct {
|
type creditJSON struct {
|
||||||
Artist string
|
Artist string
|
||||||
Role string
|
Role string
|
||||||
|
@ -376,14 +360,15 @@ func UpdateReleaseCredits(app *model.AppState, release *model.Release) http.Hand
|
||||||
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
|
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_MUSIC, "Credits for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handler {
|
func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*model.Session)
|
if r.Method != http.MethodPut {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var links = []*model.Link{}
|
var links = []*model.Link{}
|
||||||
err := json.NewDecoder(r.Body).Decode(&links)
|
err := json.NewDecoder(r.Body).Decode(&links)
|
||||||
|
@ -401,15 +386,11 @@ func UpdateReleaseLinks(app *model.AppState, release *model.Release) http.Handle
|
||||||
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
|
fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_MUSIC, "Links for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func DeleteRelease(app *model.AppState, release *model.Release) http.Handler {
|
func DeleteRelease(app *model.AppState, release *model.Release) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*model.Session)
|
|
||||||
|
|
||||||
err := controller.DeleteRelease(app.DB, release.ID)
|
err := controller.DeleteRelease(app.DB, release.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "no rows") {
|
if strings.Contains(err.Error(), "no rows") {
|
||||||
|
@ -419,7 +400,5 @@ func DeleteRelease(app *model.AppState, release *model.Release) http.Handler {
|
||||||
fmt.Printf("WARN: Failed to delete release %s: %s\n", release.ID, err)
|
fmt.Printf("WARN: Failed to delete release %s: %s\n", release.ID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_MUSIC, "Release \"%s\" deleted by \"%s\".", release.ID, session.Account.Username)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
54
api/track.go
|
@ -1,13 +1,12 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
"arimelody-web/log"
|
"arimelody-web/model"
|
||||||
"arimelody-web/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
|
@ -29,7 +28,7 @@ func ServeAllTracks(app *model.AppState) http.Handler {
|
||||||
dbTracks, err := controller.GetAllTracks(app.DB)
|
dbTracks, err := controller.GetAllTracks(app.DB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err)
|
fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, track := range dbTracks {
|
for _, track := range dbTracks {
|
||||||
|
@ -39,23 +38,23 @@ func ServeAllTracks(app *model.AppState) http.Handler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Add("Content-Type", "application/json")
|
w.Header().Add("Content-Type", "application/json")
|
||||||
encoder := json.NewEncoder(w)
|
encoder := json.NewEncoder(w)
|
||||||
encoder.SetIndent("", "\t")
|
encoder.SetIndent("", "\t")
|
||||||
err = encoder.Encode(tracks)
|
err = encoder.Encode(tracks)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to serve all tracks: %s\n", err)
|
fmt.Printf("WARN: Failed to serve all tracks: %s\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func ServeTrack(app *model.AppState, track *model.Track) http.Handler {
|
func ServeTrack(app *model.AppState, track *model.Track) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
dbReleases, err := controller.GetTrackReleases(app.DB, track.ID, false)
|
dbReleases, err := controller.GetTrackReleases(app.DB, track.ID, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to pull track releases for %s from DB: %s\n", track.ID, err)
|
fmt.Printf("WARN: Failed to pull track releases for %s from DB: %s\n", track.ID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
releases := []string{}
|
releases := []string{}
|
||||||
|
@ -63,20 +62,23 @@ func ServeTrack(app *model.AppState, track *model.Track) http.Handler {
|
||||||
releases = append(releases, release.ID)
|
releases = append(releases, release.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Add("Content-Type", "application/json")
|
w.Header().Add("Content-Type", "application/json")
|
||||||
encoder := json.NewEncoder(w)
|
encoder := json.NewEncoder(w)
|
||||||
encoder.SetIndent("", "\t")
|
encoder.SetIndent("", "\t")
|
||||||
err = encoder.Encode(Track{ track, releases })
|
err = encoder.Encode(Track{ track, releases })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to serve track %s: %s\n", track.ID, err)
|
fmt.Printf("WARN: Failed to serve track %s: %s\n", track.ID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateTrack(app *model.AppState) http.Handler {
|
func CreateTrack(app *model.AppState) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*model.Session)
|
if r.Method != http.MethodPost {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var track model.Track
|
var track model.Track
|
||||||
err := json.NewDecoder(r.Body).Decode(&track)
|
err := json.NewDecoder(r.Body).Decode(&track)
|
||||||
|
@ -97,8 +99,6 @@ func CreateTrack(app *model.AppState) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_MUSIC, "Track \"%s\" (%s) created by \"%s\".", track.Title, track.ID, session.Account.Username)
|
|
||||||
|
|
||||||
w.Header().Add("Content-Type", "text/plain")
|
w.Header().Add("Content-Type", "text/plain")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
w.Write([]byte(id))
|
w.Write([]byte(id))
|
||||||
|
@ -107,13 +107,11 @@ func CreateTrack(app *model.AppState) http.Handler {
|
||||||
|
|
||||||
func UpdateTrack(app *model.AppState, track *model.Track) http.Handler {
|
func UpdateTrack(app *model.AppState, track *model.Track) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path == "/" {
|
if r.Method != http.MethodPut || r.URL.Path == "/" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
|
||||||
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&track)
|
err := json.NewDecoder(r.Body).Decode(&track)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||||
|
@ -132,8 +130,6 @@ func UpdateTrack(app *model.AppState, track *model.Track) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_MUSIC, "Track \"%s\" (%s) updated by \"%s\".", track.Title, track.ID, session.Account.Username)
|
|
||||||
|
|
||||||
w.Header().Add("Content-Type", "application/json")
|
w.Header().Add("Content-Type", "application/json")
|
||||||
encoder := json.NewEncoder(w)
|
encoder := json.NewEncoder(w)
|
||||||
encoder.SetIndent("", "\t")
|
encoder.SetIndent("", "\t")
|
||||||
|
@ -146,20 +142,16 @@ func UpdateTrack(app *model.AppState, track *model.Track) http.Handler {
|
||||||
|
|
||||||
func DeleteTrack(app *model.AppState, track *model.Track) http.Handler {
|
func DeleteTrack(app *model.AppState, track *model.Track) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path == "/" {
|
if r.Method != http.MethodDelete || r.URL.Path == "/" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
session := r.Context().Value("session").(*model.Session)
|
|
||||||
|
|
||||||
var trackID = r.URL.Path[1:]
|
var trackID = r.URL.Path[1:]
|
||||||
err := controller.DeleteTrack(app.DB, trackID)
|
err := controller.DeleteTrack(app.DB, trackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to delete track %s: %s\n", trackID, err)
|
fmt.Printf("WARN: Failed to delete track %s: %s\n", trackID, err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_MUSIC, "Track \"%s\" (%s) deleted by \"%s\".", track.Title, track.ID, session.Account.Username)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,56 +1,53 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"arimelody-web/log"
|
"arimelody-web/model"
|
||||||
"arimelody-web/model"
|
"bufio"
|
||||||
"bufio"
|
"encoding/base64"
|
||||||
"encoding/base64"
|
"errors"
|
||||||
"errors"
|
"fmt"
|
||||||
"fmt"
|
"os"
|
||||||
"os"
|
"path/filepath"
|
||||||
"path/filepath"
|
"strings"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func HandleImageUpload(app *model.AppState, data *string, directory string, filename string) (string, error) {
|
func HandleImageUpload(app *model.AppState, data *string, directory string, filename string) (string, error) {
|
||||||
split := strings.Split(*data, ";base64,")
|
split := strings.Split(*data, ";base64,")
|
||||||
header := split[0]
|
header := split[0]
|
||||||
imageData, err := base64.StdEncoding.DecodeString(split[1])
|
imageData, err := base64.StdEncoding.DecodeString(split[1])
|
||||||
ext, _ := strings.CutPrefix(header, "data:image/")
|
ext, _ := strings.CutPrefix(header, "data:image/")
|
||||||
directory = filepath.Join(app.Config.DataDirectory, directory)
|
directory = filepath.Join(app.Config.DataDirectory, directory)
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case "png":
|
case "png":
|
||||||
case "jpg":
|
case "jpg":
|
||||||
case "jpeg":
|
case "jpeg":
|
||||||
default:
|
default:
|
||||||
return "", errors.New("Invalid image type. Allowed: .png, .jpg, .jpeg")
|
return "", errors.New("Invalid image type. Allowed: .png, .jpg, .jpeg")
|
||||||
}
|
}
|
||||||
filename = fmt.Sprintf("%s.%s", filename, ext)
|
filename = fmt.Sprintf("%s.%s", filename, ext)
|
||||||
|
|
||||||
// ensure directory exists
|
// ensure directory exists
|
||||||
os.MkdirAll(directory, os.ModePerm)
|
os.MkdirAll(directory, os.ModePerm)
|
||||||
|
|
||||||
imagePath := filepath.Join(directory, filename)
|
imagePath := filepath.Join(directory, filename)
|
||||||
file, err := os.Create(imagePath)
|
file, err := os.Create(imagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// TODO: generate compressed versions of image (512x512?)
|
// TODO: generate compressed versions of image (512x512?)
|
||||||
|
|
||||||
buffer := bufio.NewWriter(file)
|
buffer := bufio.NewWriter(file)
|
||||||
_, err = buffer.Write(imageData)
|
_, err = buffer.Write(imageData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := buffer.Flush(); err != nil {
|
if err := buffer.Flush(); err != nil {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_FILES, "\"%s/%s.%s\" created.", directory, filename, ext)
|
return filename, nil
|
||||||
|
|
||||||
return filename, nil
|
|
||||||
}
|
}
|
||||||
|
|
9
bundle.sh
Executable file
|
@ -0,0 +1,9 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# simple script to pack up arimelody.me for production distribution
|
||||||
|
|
||||||
|
if [ ! -f arimelody-web ]; then
|
||||||
|
echo "[FATAL] ./arimelody-web not found! please run \`go build -o arimelody-web\` first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
tar czvf arimelody-web.tar.gz arimelody-web admin/components/ admin/views/ admin/static/ views/ public/
|
|
@ -1,10 +1,11 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
"strings"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetAllAccounts(db *sqlx.DB) ([]model.Account, error) {
|
func GetAllAccounts(db *sqlx.DB) ([]model.Account, error) {
|
||||||
|
@ -76,6 +77,19 @@ func GetAccountBySession(db *sqlx.DB, sessionToken string) (*model.Account, erro
|
||||||
return &account, nil
|
return &account, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetSessionFromRequest(db *sqlx.DB, r *http.Request) string {
|
||||||
|
tokenStr := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||||
|
if len(tokenStr) > 0 {
|
||||||
|
return tokenStr
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie, err := r.Cookie(model.COOKIE_TOKEN)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return cookie.Value
|
||||||
|
}
|
||||||
|
|
||||||
func CreateAccount(db *sqlx.DB, account *model.Account) error {
|
func CreateAccount(db *sqlx.DB, account *model.Account) error {
|
||||||
err := db.Get(
|
err := db.Get(
|
||||||
&account.ID,
|
&account.ID,
|
||||||
|
@ -110,26 +124,3 @@ func DeleteAccount(db *sqlx.DB, accountID string) error {
|
||||||
_, err := db.Exec("DELETE FROM account WHERE id=$1", accountID)
|
_, err := db.Exec("DELETE FROM account WHERE id=$1", accountID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func IncrementAccountFails(db *sqlx.DB, accountID string) (bool, error) {
|
|
||||||
failAttempts := 0
|
|
||||||
err := db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", accountID)
|
|
||||||
if err != nil { return false, err }
|
|
||||||
locked := false
|
|
||||||
if failAttempts >= model.MAX_LOGIN_FAIL_ATTEMPTS {
|
|
||||||
err = LockAccount(db, accountID)
|
|
||||||
if err != nil { return false, err }
|
|
||||||
locked = true
|
|
||||||
}
|
|
||||||
return locked, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func LockAccount(db *sqlx.DB, accountID string) error {
|
|
||||||
_, err := db.Exec("UPDATE account SET locked = true WHERE id=$1", accountID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func UnlockAccount(db *sqlx.DB, accountID string) error {
|
|
||||||
_, err := db.Exec("UPDATE account SET locked = false, fail_attempts = 0 WHERE id=$1", accountID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,48 +1,48 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DATABASE
|
// DATABASE
|
||||||
|
|
||||||
func GetArtist(db *sqlx.DB, id string) (*model.Artist, error) {
|
func GetArtist(db *sqlx.DB, id string) (*model.Artist, error) {
|
||||||
var artist = model.Artist{}
|
var artist = model.Artist{}
|
||||||
|
|
||||||
err := db.Get(&artist, "SELECT * FROM artist WHERE id=$1", id)
|
err := db.Get(&artist, "SELECT * FROM artist WHERE id=$1", id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &artist, nil
|
return &artist, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAllArtists(db *sqlx.DB) ([]*model.Artist, error) {
|
func GetAllArtists(db *sqlx.DB) ([]*model.Artist, error) {
|
||||||
var artists = []*model.Artist{}
|
var artists = []*model.Artist{}
|
||||||
|
|
||||||
err := db.Select(&artists, "SELECT * FROM artist")
|
err := db.Select(&artists, "SELECT * FROM artist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return artists, nil
|
return artists, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetArtistsNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Artist, error) {
|
func GetArtistsNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Artist, error) {
|
||||||
var artists = []*model.Artist{}
|
var artists = []*model.Artist{}
|
||||||
|
|
||||||
err := db.Select(&artists,
|
err := db.Select(&artists,
|
||||||
"SELECT * FROM artist "+
|
"SELECT * FROM artist "+
|
||||||
"WHERE id NOT IN "+
|
"WHERE id NOT IN "+
|
||||||
"(SELECT artist FROM musiccredit WHERE release=$1)",
|
"(SELECT artist FROM musiccredit WHERE release=$1)",
|
||||||
releaseID)
|
releaseID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return artists, nil
|
return artists, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model.Credit, error) {
|
func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model.Credit, error) {
|
||||||
|
@ -54,9 +54,9 @@ func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model.
|
||||||
if !show_hidden { query += "AND visible=true " }
|
if !show_hidden { query += "AND visible=true " }
|
||||||
query += "ORDER BY release_date DESC"
|
query += "ORDER BY release_date DESC"
|
||||||
rows, err := db.Query(query, artistID)
|
rows, err := db.Query(query, artistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
type NamePrimary struct {
|
type NamePrimary struct {
|
||||||
|
@ -102,13 +102,13 @@ func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model.
|
||||||
|
|
||||||
func CreateArtist(db *sqlx.DB, artist *model.Artist) error {
|
func CreateArtist(db *sqlx.DB, artist *model.Artist) error {
|
||||||
_, err := db.Exec(
|
_, err := db.Exec(
|
||||||
"INSERT INTO artist (id, name, website, avatar) "+
|
"INSERT INTO artist (id, name, website, avatar) "+
|
||||||
"VALUES ($1, $2, $3, $4)",
|
"VALUES ($1, $2, $3, $4)",
|
||||||
artist.ID,
|
artist.ID,
|
||||||
artist.Name,
|
artist.Name,
|
||||||
artist.Website,
|
artist.Website,
|
||||||
artist.Avatar,
|
artist.Avatar,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
|
|
||||||
"github.com/pelletier/go-toml/v2"
|
"github.com/pelletier/go-toml/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetConfig() model.Config {
|
func GetConfig() model.Config {
|
||||||
|
@ -21,7 +21,6 @@ func GetConfig() model.Config {
|
||||||
BaseUrl: "https://arimelody.me",
|
BaseUrl: "https://arimelody.me",
|
||||||
Host: "0.0.0.0",
|
Host: "0.0.0.0",
|
||||||
Port: 8080,
|
Port: 8080,
|
||||||
TrustedProxies: []string{ "127.0.0.1" },
|
|
||||||
DB: model.DBConfig{
|
DB: model.DBConfig{
|
||||||
Host: "127.0.0.1",
|
Host: "127.0.0.1",
|
||||||
Port: 5432,
|
Port: 5432,
|
||||||
|
@ -77,9 +76,5 @@ func handleConfigOverrides(config *model.Config) error {
|
||||||
if env, has := os.LookupEnv("ARIMELODY_DISCORD_CLIENT_ID"); has { config.Discord.ClientID = env }
|
if env, has := os.LookupEnv("ARIMELODY_DISCORD_CLIENT_ID"); has { config.Discord.ClientID = env }
|
||||||
if env, has := os.LookupEnv("ARIMELODY_DISCORD_SECRET"); has { config.Discord.Secret = env }
|
if env, has := os.LookupEnv("ARIMELODY_DISCORD_SECRET"); has { config.Discord.Secret = env }
|
||||||
|
|
||||||
if env, has := os.LookupEnv("ARIMELODY_TWITCH_BROADCASTER"); has { config.Twitch.Broadcaster = env }
|
|
||||||
if env, has := os.LookupEnv("ARIMELODY_TWITCH_CLIENT_ID"); has { config.Twitch.ClientID = env }
|
|
||||||
if env, has := os.LookupEnv("ARIMELODY_TWITCH_SECRET"); has { config.Twitch.Secret = env }
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
var inviteChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"arimelody-web/model"
|
|
||||||
"net/http"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Returns the request's original IP address, resolving the `x-forwarded-for`
|
|
||||||
// header if the request originates from a trusted proxy.
|
|
||||||
func ResolveIP(app *model.AppState, r *http.Request) string {
|
|
||||||
addr := strings.Split(r.RemoteAddr, ":")[0]
|
|
||||||
if slices.Contains(app.Config.TrustedProxies, addr) {
|
|
||||||
forwardedFor := r.Header.Get("x-forwarded-for")
|
|
||||||
if len(forwardedFor) > 0 {
|
|
||||||
// discard extra IPs; cloudflare tends to append their nodes
|
|
||||||
forwardedFor = strings.Split(forwardedFor, ", ")[0]
|
|
||||||
return forwardedFor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return addr
|
|
||||||
}
|
|
|
@ -1,14 +1,14 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DB_VERSION int = 4
|
const DB_VERSION int = 2
|
||||||
|
|
||||||
func CheckDBVersionAndMigrate(db *sqlx.DB) {
|
func CheckDBVersionAndMigrate(db *sqlx.DB) {
|
||||||
db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody")
|
db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody")
|
||||||
|
@ -41,14 +41,6 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) {
|
||||||
ApplyMigration(db, "001-pre-versioning")
|
ApplyMigration(db, "001-pre-versioning")
|
||||||
oldDBVersion = 2
|
oldDBVersion = 2
|
||||||
|
|
||||||
case 2:
|
|
||||||
ApplyMigration(db, "002-audit-logs")
|
|
||||||
oldDBVersion = 3
|
|
||||||
|
|
||||||
case 3:
|
|
||||||
ApplyMigration(db, "003-fail-lock")
|
|
||||||
oldDBVersion = 4
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,7 +50,7 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) {
|
||||||
func ApplyMigration(db *sqlx.DB, scriptFile string) {
|
func ApplyMigration(db *sqlx.DB, scriptFile string) {
|
||||||
fmt.Printf("Applying schema migration %s...\n", scriptFile)
|
fmt.Printf("Applying schema migration %s...\n", scriptFile)
|
||||||
|
|
||||||
bytes, err := os.ReadFile("schema-migration/" + scriptFile + ".sql")
|
bytes, err := os.ReadFile("schema_migration/" + scriptFile + ".sql")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to open schema file \"%s\": %v\n", scriptFile, err)
|
fmt.Fprintf(os.Stderr, "FATAL: Failed to open schema file \"%s\": %v\n", scriptFile, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
|
@ -1,53 +0,0 @@
|
||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"image"
|
|
||||||
"image/color"
|
|
||||||
|
|
||||||
"github.com/skip2/go-qrcode"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GenerateQRCode(data string) (string, error) {
|
|
||||||
imgBytes, err := qrcode.Encode(data, qrcode.Medium, 256)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
base64Img := base64.StdEncoding.EncodeToString(imgBytes)
|
|
||||||
return base64Img, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// vvv DEPRECATED vvv
|
|
||||||
|
|
||||||
const margin = 4
|
|
||||||
|
|
||||||
type QRCodeECCLevel int64
|
|
||||||
const (
|
|
||||||
LOW QRCodeECCLevel = iota
|
|
||||||
MEDIUM
|
|
||||||
QUARTILE
|
|
||||||
HIGH
|
|
||||||
)
|
|
||||||
|
|
||||||
func drawLargeAlignmentSquare(x int, y int, img *image.Gray) {
|
|
||||||
for yi := range 7 {
|
|
||||||
for xi := range 7 {
|
|
||||||
if (xi == 0 || xi == 6) || (yi == 0 || yi == 6) {
|
|
||||||
img.Set(x + xi, y + yi, color.Black)
|
|
||||||
} else if (xi > 1 && xi < 5) && (yi > 1 && yi < 5) {
|
|
||||||
img.Set(x + xi, y + yi, color.Black)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func drawSmallAlignmentSquare(x int, y int, img *image.Gray) {
|
|
||||||
for yi := range 5 {
|
|
||||||
for xi := range 5 {
|
|
||||||
if (xi == 0 || xi == 4) || (yi == 0 || yi == 4) {
|
|
||||||
img.Set(x + xi, y + yi, color.Black)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
img.Set(x + 2, y + 2, color.Black)
|
|
||||||
}
|
|
|
@ -1,12 +1,12 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {
|
func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {
|
||||||
|
|
|
@ -1,57 +1,16 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"time"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"arimelody-web/log"
|
"arimelody-web/model"
|
||||||
"arimelody-web/model"
|
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
const TOKEN_LEN = 64
|
const TOKEN_LEN = 64
|
||||||
|
|
||||||
func GetSessionFromRequest(app *model.AppState, r *http.Request) (*model.Session, error) {
|
|
||||||
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
|
|
||||||
if err != nil && err != http.ErrNoCookie {
|
|
||||||
return nil, errors.New(fmt.Sprintf("Failed to retrieve session cookie: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
var session *model.Session
|
|
||||||
|
|
||||||
if sessionCookie != nil {
|
|
||||||
// fetch existing session
|
|
||||||
session, err = GetSession(app.DB, sessionCookie.Value)
|
|
||||||
|
|
||||||
if err != nil && !strings.Contains(err.Error(), "no rows") {
|
|
||||||
return nil, errors.New(fmt.Sprintf("Failed to retrieve session: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
if session != nil {
|
|
||||||
if session.UserAgent != r.UserAgent() {
|
|
||||||
msg := "Session user agent mismatch. A cookie may have been hijacked!"
|
|
||||||
if session.Account != nil {
|
|
||||||
account, _ := GetAccountByID(app.DB, session.Account.ID)
|
|
||||||
msg += " (Account \"" + account.Username + "\")"
|
|
||||||
}
|
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, msg)
|
|
||||||
err = DeleteSession(app.DB, session.Token)
|
|
||||||
if err != nil {
|
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete affected session")
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return session, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateSession(db *sqlx.DB, userAgent string) (*model.Session, error) {
|
func CreateSession(db *sqlx.DB, userAgent string) (*model.Session, error) {
|
||||||
tokenString := GenerateAlnumString(TOKEN_LEN)
|
tokenString := GenerateAlnumString(TOKEN_LEN)
|
||||||
|
|
||||||
|
@ -90,17 +49,6 @@ func CreateSession(db *sqlx.DB, userAgent string) (*model.Session, error) {
|
||||||
// return err
|
// return err
|
||||||
// }
|
// }
|
||||||
|
|
||||||
func SetSessionAttemptAccount(db *sqlx.DB, session *model.Session, account *model.Account) error {
|
|
||||||
var err error
|
|
||||||
session.AttemptAccount = account
|
|
||||||
if account == nil {
|
|
||||||
_, err = db.Exec("UPDATE session SET attempt_account=NULL WHERE token=$1", session.Token)
|
|
||||||
} else {
|
|
||||||
_, err = db.Exec("UPDATE session SET attempt_account=$2 WHERE token=$1", session.Token, account.ID)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetSessionAccount(db *sqlx.DB, session *model.Session, account *model.Account) error {
|
func SetSessionAccount(db *sqlx.DB, session *model.Session, account *model.Account) error {
|
||||||
var err error
|
var err error
|
||||||
session.Account = account
|
session.Account = account
|
||||||
|
@ -141,8 +89,7 @@ func SetSessionError(db *sqlx.DB, session *model.Session, message string) error
|
||||||
func GetSession(db *sqlx.DB, token string) (*model.Session, error) {
|
func GetSession(db *sqlx.DB, token string) (*model.Session, error) {
|
||||||
type dbSession struct {
|
type dbSession struct {
|
||||||
model.Session
|
model.Session
|
||||||
AttemptAccountID sql.NullString `db:"attempt_account"`
|
AccountID sql.NullString `db:"account"`
|
||||||
AccountID sql.NullString `db:"account"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
session := dbSession{}
|
session := dbSession{}
|
||||||
|
@ -162,13 +109,6 @@ func GetSession(db *sqlx.DB, token string) (*model.Session, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if session.AttemptAccountID.Valid {
|
|
||||||
session.AttemptAccount, err = GetAccountByID(db, session.AttemptAccountID.String)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &session.Session, err
|
return &session.Session, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -188,7 +128,3 @@ func DeleteSession(db *sqlx.DB, token string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func DeleteExpiredSessions(db *sqlx.DB) error {
|
|
||||||
_, err := db.Exec("DELETE FROM session WHERE expires_at<current_timestamp")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,20 +1,20 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"encoding/base32"
|
"encoding/base32"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
const TOTP_SECRET_LENGTH = 32
|
const TOTP_SECRET_LENGTH = 32
|
||||||
|
@ -64,9 +64,9 @@ func GenerateTOTPURI(username string, secret string) string {
|
||||||
query := url.Query()
|
query := url.Query()
|
||||||
query.Set("secret", secret)
|
query.Set("secret", secret)
|
||||||
query.Set("issuer", "arimelody.me")
|
query.Set("issuer", "arimelody.me")
|
||||||
// query.Set("algorithm", "SHA1")
|
query.Set("algorithm", "SHA1")
|
||||||
// query.Set("digits", fmt.Sprintf("%d", TOTP_CODE_LENGTH))
|
query.Set("digits", fmt.Sprintf("%d", TOTP_CODE_LENGTH))
|
||||||
// query.Set("period", fmt.Sprintf("%d", TOTP_TIME_STEP))
|
query.Set("period", fmt.Sprintf("%d", TOTP_TIME_STEP))
|
||||||
url.RawQuery = query.Encode()
|
url.RawQuery = query.Encode()
|
||||||
|
|
||||||
return url.String()
|
return url.String()
|
||||||
|
@ -78,7 +78,7 @@ func GetTOTPsForAccount(db *sqlx.DB, accountID string) ([]model.TOTP, error) {
|
||||||
err := db.Select(
|
err := db.Select(
|
||||||
&totps,
|
&totps,
|
||||||
"SELECT * FROM totp " +
|
"SELECT * FROM totp " +
|
||||||
"WHERE account=$1 AND confirmed=true " +
|
"WHERE account=$1 " +
|
||||||
"ORDER BY created_at ASC",
|
"ORDER BY created_at ASC",
|
||||||
accountID,
|
accountID,
|
||||||
)
|
)
|
||||||
|
@ -116,9 +116,8 @@ func GetTOTP(db *sqlx.DB, accountID string, name string) (*model.TOTP, error) {
|
||||||
err := db.Get(
|
err := db.Get(
|
||||||
&totp,
|
&totp,
|
||||||
"SELECT * FROM totp " +
|
"SELECT * FROM totp " +
|
||||||
"WHERE account=$1 AND name=$2",
|
"WHERE account=$1",
|
||||||
accountID,
|
accountID,
|
||||||
name,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "no rows") {
|
if strings.Contains(err.Error(), "no rows") {
|
||||||
|
@ -130,15 +129,6 @@ func GetTOTP(db *sqlx.DB, accountID string, name string) (*model.TOTP, error) {
|
||||||
return &totp, nil
|
return &totp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ConfirmTOTP(db *sqlx.DB, accountID string, name string) error {
|
|
||||||
_, err := db.Exec(
|
|
||||||
"UPDATE totp SET confirmed=true WHERE account=$1 AND name=$2",
|
|
||||||
accountID,
|
|
||||||
name,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateTOTP(db *sqlx.DB, totp *model.TOTP) error {
|
func CreateTOTP(db *sqlx.DB, totp *model.TOTP) error {
|
||||||
_, err := db.Exec(
|
_, err := db.Exec(
|
||||||
"INSERT INTO totp (account, name, secret) " +
|
"INSERT INTO totp (account, name, secret) " +
|
||||||
|
@ -158,8 +148,3 @@ func DeleteTOTP(db *sqlx.DB, accountID string, name string) error {
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func DeleteUnconfirmedTOTPs(db *sqlx.DB) error {
|
|
||||||
_, err := db.Exec("DELETE FROM totp WHERE confirmed=false")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DATABASE
|
// DATABASE
|
||||||
|
@ -13,19 +13,19 @@ func GetTrack(db *sqlx.DB, id string) (*model.Track, error) {
|
||||||
|
|
||||||
stmt, _ := db.Preparex("SELECT * FROM musictrack WHERE id=$1")
|
stmt, _ := db.Preparex("SELECT * FROM musictrack WHERE id=$1")
|
||||||
err := stmt.Get(&track, id)
|
err := stmt.Get(&track, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &track, nil
|
return &track, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAllTracks(db *sqlx.DB) ([]*model.Track, error) {
|
func GetAllTracks(db *sqlx.DB) ([]*model.Track, error) {
|
||||||
var tracks = []*model.Track{}
|
var tracks = []*model.Track{}
|
||||||
|
|
||||||
err := db.Select(&tracks, "SELECT * FROM musictrack")
|
err := db.Select(&tracks, "SELECT * FROM musictrack")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tracks, nil
|
return tracks, nil
|
||||||
}
|
}
|
||||||
|
@ -33,33 +33,33 @@ func GetAllTracks(db *sqlx.DB) ([]*model.Track, error) {
|
||||||
func GetOrphanTracks(db *sqlx.DB) ([]*model.Track, error) {
|
func GetOrphanTracks(db *sqlx.DB) ([]*model.Track, error) {
|
||||||
var tracks = []*model.Track{}
|
var tracks = []*model.Track{}
|
||||||
|
|
||||||
err := db.Select(&tracks, "SELECT * FROM musictrack WHERE id NOT IN (SELECT track FROM musicreleasetrack)")
|
err := db.Select(&tracks, "SELECT * FROM musictrack WHERE id NOT IN (SELECT track FROM musicreleasetrack)")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tracks, nil
|
return tracks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetTracksNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Track, error) {
|
func GetTracksNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Track, error) {
|
||||||
var tracks = []*model.Track{}
|
var tracks = []*model.Track{}
|
||||||
|
|
||||||
err := db.Select(&tracks,
|
err := db.Select(&tracks,
|
||||||
"SELECT * FROM musictrack "+
|
"SELECT * FROM musictrack "+
|
||||||
"WHERE id NOT IN "+
|
"WHERE id NOT IN "+
|
||||||
"(SELECT track FROM musicreleasetrack WHERE release=$1)",
|
"(SELECT track FROM musicreleasetrack WHERE release=$1)",
|
||||||
releaseID)
|
releaseID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tracks, nil
|
return tracks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetTrackReleases(db *sqlx.DB, trackID string, full bool) ([]*model.Release, error) {
|
func GetTrackReleases(db *sqlx.DB, trackID string, full bool) ([]*model.Release, error) {
|
||||||
var releases = []*model.Release{}
|
var releases = []*model.Release{}
|
||||||
|
|
||||||
err := db.Select(&releases,
|
err := db.Select(&releases,
|
||||||
"SELECT id,title,type,release_date,artwork,buylink "+
|
"SELECT id,title,type,release_date,artwork,buylink "+
|
||||||
"FROM musicrelease "+
|
"FROM musicrelease "+
|
||||||
"JOIN musicreleasetrack ON release=id "+
|
"JOIN musicreleasetrack ON release=id "+
|
||||||
|
@ -67,9 +67,9 @@ func GetTrackReleases(db *sqlx.DB, trackID string, full bool) ([]*model.Release,
|
||||||
"ORDER BY release_date",
|
"ORDER BY release_date",
|
||||||
trackID,
|
trackID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
type NamePrimary struct {
|
type NamePrimary struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
@ -114,14 +114,14 @@ func GetTrackReleases(db *sqlx.DB, trackID string, full bool) ([]*model.Release,
|
||||||
func PullOrphanTracks(db *sqlx.DB) ([]*model.Track, error) {
|
func PullOrphanTracks(db *sqlx.DB) ([]*model.Track, error) {
|
||||||
var tracks = []*model.Track{}
|
var tracks = []*model.Track{}
|
||||||
|
|
||||||
err := db.Select(&tracks,
|
err := db.Select(&tracks,
|
||||||
"SELECT id, title, description, lyrics, preview_url FROM musictrack "+
|
"SELECT id, title, description, lyrics, preview_url FROM musictrack "+
|
||||||
"WHERE id NOT IN "+
|
"WHERE id NOT IN "+
|
||||||
"(SELECT track FROM musicreleasetrack)",
|
"(SELECT track FROM musicreleasetrack)",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tracks, nil
|
return tracks, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,94 +0,0 @@
|
||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"arimelody-web/model"
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const TWITCH_API_BASE = "https://api.twitch.tv/helix/"
|
|
||||||
|
|
||||||
func TwitchSetup(app *model.AppState) error {
|
|
||||||
app.Twitch = &model.TwitchState{}
|
|
||||||
err := RefreshTwitchToken(app)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func RefreshTwitchToken(app *model.AppState) error {
|
|
||||||
if app.Twitch != nil && app.Twitch.Token != nil && time.Now().UTC().After(app.Twitch.Token.ExpiresAt) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
requestUrl, _ := url.Parse("https://id.twitch.tv/oauth2/token")
|
|
||||||
req, _ := http.NewRequest(http.MethodPost, requestUrl.String(), bytes.NewBuffer([]byte(url.Values{
|
|
||||||
"client_id": []string{ app.Config.Twitch.ClientID },
|
|
||||||
"client_secret": []string{ app.Config.Twitch.Secret },
|
|
||||||
"grant_type": []string{ "client_credentials" },
|
|
||||||
}.Encode())))
|
|
||||||
|
|
||||||
res, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
type TwitchOAuthToken struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
ExpiresIn int `json:"expires_in"`
|
|
||||||
TokenType string `json:"token_type"`
|
|
||||||
}
|
|
||||||
oauthResponse := TwitchOAuthToken{}
|
|
||||||
err = json.NewDecoder(res.Body).Decode(&oauthResponse)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
app.Twitch.Token = &model.TwitchOAuthToken{
|
|
||||||
AccessToken: oauthResponse.AccessToken,
|
|
||||||
ExpiresAt: time.Now().UTC().Add(time.Second * time.Duration(oauthResponse.ExpiresIn)).UTC(),
|
|
||||||
TokenType: oauthResponse.TokenType,
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastStreamState *model.TwitchStreamInfo
|
|
||||||
var lastStreamStateAt time.Time
|
|
||||||
|
|
||||||
func GetTwitchStatus(app *model.AppState, broadcaster string) (*model.TwitchStreamInfo, error) {
|
|
||||||
if lastStreamState != nil && time.Now().UTC().Before(lastStreamStateAt.Add(time.Minute)) {
|
|
||||||
return lastStreamState, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
requestUrl, _ := url.Parse(TWITCH_API_BASE + "streams")
|
|
||||||
requestUrl.RawQuery = url.Values{
|
|
||||||
"user_login": []string{ broadcaster },
|
|
||||||
}.Encode()
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, requestUrl.String(), nil)
|
|
||||||
req.Header.Set("Client-Id", app.Config.Twitch.ClientID)
|
|
||||||
req.Header.Set("Authorization", "Bearer " + app.Twitch.Token.AccessToken)
|
|
||||||
|
|
||||||
res, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
type StreamsResponse struct {
|
|
||||||
Data []model.TwitchStreamInfo `json:"data"`
|
|
||||||
}
|
|
||||||
streamInfo := StreamsResponse{}
|
|
||||||
err = json.NewDecoder(res.Body).Decode(&streamInfo)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(streamInfo.Data) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
lastStreamState = &streamInfo.Data[0]
|
|
||||||
lastStreamStateAt = time.Now().UTC()
|
|
||||||
return lastStreamState, nil
|
|
||||||
}
|
|
201
cursor/cursor.go
|
@ -1,201 +0,0 @@
|
||||||
package cursor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"arimelody-web/model"
|
|
||||||
"fmt"
|
|
||||||
"math/rand"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CursorClient struct {
|
|
||||||
ID int32
|
|
||||||
Conn *websocket.Conn
|
|
||||||
Route string
|
|
||||||
X float32
|
|
||||||
Y float32
|
|
||||||
Click bool
|
|
||||||
Disconnected bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type CursorMessage struct {
|
|
||||||
Data []byte
|
|
||||||
Route string
|
|
||||||
Exclude []*CursorClient
|
|
||||||
}
|
|
||||||
|
|
||||||
func (client *CursorClient) Send(data []byte) {
|
|
||||||
err := client.Conn.WriteMessage(websocket.TextMessage, data)
|
|
||||||
if err != nil {
|
|
||||||
client.Disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (client *CursorClient) Disconnect() {
|
|
||||||
client.Disconnected = true
|
|
||||||
broadcast <- CursorMessage{
|
|
||||||
[]byte(fmt.Sprintf("leave:%d", client.ID)),
|
|
||||||
client.Route,
|
|
||||||
[]*CursorClient{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var clients = make(map[int32]*CursorClient)
|
|
||||||
var broadcast = make(chan CursorMessage)
|
|
||||||
var mutex = &sync.Mutex{}
|
|
||||||
|
|
||||||
func StartCursor(app *model.AppState) {
|
|
||||||
var includes = func (clients []*CursorClient, client *CursorClient) bool {
|
|
||||||
for _, c := range clients {
|
|
||||||
if c.ID == client.ID { return true }
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
log("Cursor message handler ready!")
|
|
||||||
|
|
||||||
for {
|
|
||||||
message := <-broadcast
|
|
||||||
mutex.Lock()
|
|
||||||
for _, client := range clients {
|
|
||||||
if client.Route != message.Route { continue }
|
|
||||||
if includes(message.Exclude, client) { continue }
|
|
||||||
client.Send(message.Data)
|
|
||||||
}
|
|
||||||
mutex.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleClient(client *CursorClient) {
|
|
||||||
msgType, message, err := client.Conn.ReadMessage()
|
|
||||||
if err != nil {
|
|
||||||
client.Disconnect()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if msgType != websocket.TextMessage { return }
|
|
||||||
|
|
||||||
args := strings.Split(string(message), ":")
|
|
||||||
if len(args) == 0 { return }
|
|
||||||
switch args[0] {
|
|
||||||
case "loc":
|
|
||||||
if len(args) < 2 { return }
|
|
||||||
|
|
||||||
client.Route = args[1]
|
|
||||||
|
|
||||||
mutex.Lock()
|
|
||||||
for otherClientID, otherClient := range clients {
|
|
||||||
if otherClientID == client.ID || otherClient.Route != client.Route { continue }
|
|
||||||
client.Send([]byte(fmt.Sprintf("join:%d", otherClientID)))
|
|
||||||
client.Send([]byte(fmt.Sprintf("pos:%d:%f:%f", otherClientID, otherClient.X, otherClient.Y)))
|
|
||||||
}
|
|
||||||
mutex.Unlock()
|
|
||||||
|
|
||||||
broadcast <- CursorMessage{
|
|
||||||
[]byte(fmt.Sprintf("join:%d", client.ID)),
|
|
||||||
client.Route,
|
|
||||||
[]*CursorClient{ client },
|
|
||||||
}
|
|
||||||
case "char":
|
|
||||||
if len(args) < 2 { return }
|
|
||||||
// haha, turns out using ':' as a separator means you can't type ':'s
|
|
||||||
// i should really be writing byte packets, not this nonsense
|
|
||||||
msg := byte(':')
|
|
||||||
if len(args[1]) > 0 {
|
|
||||||
msg = args[1][0]
|
|
||||||
}
|
|
||||||
broadcast <- CursorMessage{
|
|
||||||
[]byte(fmt.Sprintf("char:%d:%c", client.ID, msg)),
|
|
||||||
client.Route,
|
|
||||||
[]*CursorClient{ client },
|
|
||||||
}
|
|
||||||
case "nochar":
|
|
||||||
broadcast <- CursorMessage{
|
|
||||||
[]byte(fmt.Sprintf("nochar:%d", client.ID)),
|
|
||||||
client.Route,
|
|
||||||
[]*CursorClient{ client },
|
|
||||||
}
|
|
||||||
case "click":
|
|
||||||
if len(args) < 2 { return }
|
|
||||||
click := 0
|
|
||||||
if args[1][0] == '1' {
|
|
||||||
click = 1
|
|
||||||
}
|
|
||||||
broadcast <- CursorMessage{
|
|
||||||
[]byte(fmt.Sprintf("click:%d:%d", client.ID, click)),
|
|
||||||
client.Route,
|
|
||||||
[]*CursorClient{ client },
|
|
||||||
}
|
|
||||||
case "pos":
|
|
||||||
if len(args) < 3 { return }
|
|
||||||
x, err := strconv.ParseFloat(args[1], 32)
|
|
||||||
y, err := strconv.ParseFloat(args[2], 32)
|
|
||||||
if err != nil { return }
|
|
||||||
client.X = float32(x)
|
|
||||||
client.Y = float32(y)
|
|
||||||
broadcast <- CursorMessage{
|
|
||||||
[]byte(fmt.Sprintf("pos:%d:%f:%f", client.ID, client.X, client.Y)),
|
|
||||||
client.Route,
|
|
||||||
[]*CursorClient{ client },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Handler(app *model.AppState) http.HandlerFunc {
|
|
||||||
var upgrader = websocket.Upgrader{
|
|
||||||
CheckOrigin: func (r *http.Request) bool {
|
|
||||||
origin := r.Header.Get("Origin")
|
|
||||||
return origin == app.Config.BaseUrl
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
conn, err := upgrader.Upgrade(w, r, nil)
|
|
||||||
if err != nil {
|
|
||||||
log("Failed to upgrade to WebSocket connection: %v\n", err)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
client := CursorClient{
|
|
||||||
ID: rand.Int31(),
|
|
||||||
Conn: conn,
|
|
||||||
X: 0.0,
|
|
||||||
Y: 0.0,
|
|
||||||
Disconnected: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = client.Conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("id:%d", client.ID)))
|
|
||||||
if err != nil {
|
|
||||||
client.Conn.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
mutex.Lock()
|
|
||||||
clients[client.ID] = &client
|
|
||||||
mutex.Unlock()
|
|
||||||
|
|
||||||
// log("Client connected: %s (%s)", fmt.Sprintf("0x%08x", client.ID), client.Conn.RemoteAddr().String())
|
|
||||||
|
|
||||||
for {
|
|
||||||
if client.Disconnected {
|
|
||||||
mutex.Lock()
|
|
||||||
delete(clients, client.ID)
|
|
||||||
client.Conn.Close()
|
|
||||||
mutex.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handleClient(&client)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func log(format string, args ...any) {
|
|
||||||
logString := fmt.Sprintf(format, args...)
|
|
||||||
fmt.Printf("[%s] [CURSOR] %s\n", time.Now().Format(time.UnixDate), logString)
|
|
||||||
}
|
|
|
@ -1,13 +1,13 @@
|
||||||
package discord
|
package discord
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const API_ENDPOINT = "https://discord.com/api/v10"
|
const API_ENDPOINT = "https://discord.com/api/v10"
|
||||||
|
|
7
go.mod
|
@ -8,9 +8,4 @@ require (
|
||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/crypto v0.27.0 // indirect
|
require golang.org/x/crypto v0.27.0 // indirect
|
||||||
|
require github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||||
require (
|
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
|
||||||
)
|
|
||||||
|
|
8
go.sum
|
@ -2,17 +2,13 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
|
||||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
|
||||||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||||
|
|
143
log/log.go
|
@ -1,143 +0,0 @@
|
||||||
package log
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type (
|
|
||||||
Logger struct {
|
|
||||||
DB *sqlx.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
Log struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
Level LogLevel `json:"level" db:"level"`
|
|
||||||
Type string `json:"type" db:"type"`
|
|
||||||
Content string `json:"content" db:"content"`
|
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
TYPE_ACCOUNT string = "account"
|
|
||||||
TYPE_MUSIC string = "music"
|
|
||||||
TYPE_ARTIST string = "artist"
|
|
||||||
TYPE_BLOG string = "blog"
|
|
||||||
TYPE_ARTWORK string = "artwork"
|
|
||||||
TYPE_FILES string = "files"
|
|
||||||
TYPE_MISC string = "misc"
|
|
||||||
TYPE_CURSOR string = "cursor"
|
|
||||||
)
|
|
||||||
|
|
||||||
type LogLevel int
|
|
||||||
const (
|
|
||||||
LEVEL_INFO LogLevel = 0
|
|
||||||
LEVEL_WARN LogLevel = 1
|
|
||||||
)
|
|
||||||
|
|
||||||
const DEFAULT_LOG_PAGE_LENGTH = 25
|
|
||||||
|
|
||||||
func (self *Logger) Info(logType string, format string, args ...any) {
|
|
||||||
logString := fmt.Sprintf(format, args...)
|
|
||||||
fmt.Printf("[%s] [%s] INFO: %s\n", time.Now().Format(time.UnixDate), logType, logString)
|
|
||||||
err := createLog(self.DB, LEVEL_INFO, logType, logString)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to push log to database: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (self *Logger) Warn(logType string, format string, args ...any) {
|
|
||||||
logString := fmt.Sprintf(format, args...)
|
|
||||||
fmt.Fprintf(os.Stderr, "[%s] [%s] WARN: %s\n", time.Now().Format(time.UnixDate), logType, logString)
|
|
||||||
err := createLog(self.DB, LEVEL_WARN, logType, logString)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to push log to database: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (self *Logger) Fetch(id string) (*Log, error) {
|
|
||||||
log := Log{}
|
|
||||||
err := self.DB.Get(&log, "SELECT * FROM auditlog WHERE id=$1", id)
|
|
||||||
return &log, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (self *Logger) Search(levelFilters []LogLevel, typeFilters []string, content string, limit int, offset int) ([]*Log, error) {
|
|
||||||
logs := []*Log{}
|
|
||||||
|
|
||||||
params := []any{ limit, offset }
|
|
||||||
conditions := ""
|
|
||||||
|
|
||||||
if len(content) > 0 {
|
|
||||||
content = "%" + content + "%"
|
|
||||||
conditions += " WHERE content LIKE $3"
|
|
||||||
params = append(params, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(levelFilters) > 0 {
|
|
||||||
if len(conditions) > 0 {
|
|
||||||
conditions += " AND level IN ("
|
|
||||||
} else {
|
|
||||||
conditions += " WHERE level IN ("
|
|
||||||
}
|
|
||||||
for i := range levelFilters {
|
|
||||||
conditions += fmt.Sprintf("$%d", len(params) + 1)
|
|
||||||
if i < len(levelFilters) - 1 {
|
|
||||||
conditions += ","
|
|
||||||
}
|
|
||||||
params = append(params, levelFilters[i])
|
|
||||||
}
|
|
||||||
conditions += ")"
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(typeFilters) > 0 {
|
|
||||||
if len(conditions) > 0 {
|
|
||||||
conditions += " AND type IN ("
|
|
||||||
} else {
|
|
||||||
conditions += " WHERE type IN ("
|
|
||||||
}
|
|
||||||
for i := range typeFilters {
|
|
||||||
conditions += fmt.Sprintf("$%d", len(params) + 1)
|
|
||||||
if i < len(typeFilters) - 1 {
|
|
||||||
conditions += ","
|
|
||||||
}
|
|
||||||
params = append(params, typeFilters[i])
|
|
||||||
}
|
|
||||||
conditions += ")"
|
|
||||||
}
|
|
||||||
|
|
||||||
query := fmt.Sprintf(
|
|
||||||
"SELECT * FROM auditlog%s ORDER BY created_at DESC LIMIT $1 OFFSET $2",
|
|
||||||
conditions,
|
|
||||||
)
|
|
||||||
|
|
||||||
/*
|
|
||||||
fmt.Printf("%s (", query)
|
|
||||||
for i, param := range params {
|
|
||||||
fmt.Print(param)
|
|
||||||
if i < len(params) - 1 {
|
|
||||||
fmt.Print(", ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Print(")\n")
|
|
||||||
*/
|
|
||||||
|
|
||||||
err := self.DB.Select(&logs, query, params...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return logs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func createLog(db *sqlx.DB, logLevel LogLevel, logType string, content string) error {
|
|
||||||
_, err := db.Exec(
|
|
||||||
"INSERT INTO auditlog (level, type, content) VALUES ($1,$2,$3)",
|
|
||||||
logLevel,
|
|
||||||
logType,
|
|
||||||
content,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
236
main.go
|
@ -1,32 +1,29 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"errors"
|
||||||
"errors"
|
"fmt"
|
||||||
"fmt"
|
"log"
|
||||||
stdLog "log"
|
"math"
|
||||||
"math"
|
"math/rand"
|
||||||
"math/rand"
|
"net/http"
|
||||||
"net"
|
"os"
|
||||||
"net/http"
|
"path/filepath"
|
||||||
"os"
|
"strconv"
|
||||||
"path/filepath"
|
"strings"
|
||||||
"strconv"
|
"time"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"arimelody-web/admin"
|
"arimelody-web/admin"
|
||||||
"arimelody-web/api"
|
"arimelody-web/api"
|
||||||
"arimelody-web/colour"
|
"arimelody-web/colour"
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
"arimelody-web/cursor"
|
"arimelody-web/model"
|
||||||
"arimelody-web/log"
|
"arimelody-web/templates"
|
||||||
"arimelody-web/model"
|
"arimelody-web/view"
|
||||||
"arimelody-web/view"
|
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
_ "github.com/lib/pq"
|
_ "github.com/lib/pq"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// used for database migrations
|
// used for database migrations
|
||||||
|
@ -39,7 +36,6 @@ func main() {
|
||||||
|
|
||||||
app := model.AppState{
|
app := model.AppState{
|
||||||
Config: controller.GetConfig(),
|
Config: controller.GetConfig(),
|
||||||
Twitch: nil,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// initialise database connection
|
// initialise database connection
|
||||||
|
@ -81,8 +77,6 @@ func main() {
|
||||||
app.DB.SetMaxIdleConns(10)
|
app.DB.SetMaxIdleConns(10)
|
||||||
defer app.DB.Close()
|
defer app.DB.Close()
|
||||||
|
|
||||||
app.Log = log.Logger{ DB: app.DB }
|
|
||||||
|
|
||||||
// handle command arguments
|
// handle command arguments
|
||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
arg := os.Args[1]
|
arg := os.Args[1]
|
||||||
|
@ -95,6 +89,7 @@ func main() {
|
||||||
}
|
}
|
||||||
username := os.Args[2]
|
username := os.Args[2]
|
||||||
totpName := os.Args[3]
|
totpName := os.Args[3]
|
||||||
|
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
|
||||||
|
|
||||||
account, err := controller.GetAccountByUsername(app.DB, username)
|
account, err := controller.GetAccountByUsername(app.DB, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -107,7 +102,6 @@ func main() {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
|
|
||||||
totp := model.TOTP {
|
totp := model.TOTP {
|
||||||
AccountID: account.ID,
|
AccountID: account.ID,
|
||||||
Name: totpName,
|
Name: totpName,
|
||||||
|
@ -124,7 +118,6 @@ func main() {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "TOTP method \"%s\" for \"%s\" created via config utility.", totp.Name, account.Username)
|
|
||||||
url := controller.GenerateTOTPURI(account.Username, totp.Secret)
|
url := controller.GenerateTOTPURI(account.Username, totp.Secret)
|
||||||
fmt.Printf("%s\n", url)
|
fmt.Printf("%s\n", url)
|
||||||
return
|
return
|
||||||
|
@ -154,7 +147,6 @@ func main() {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "TOTP method \"%s\" for \"%s\" deleted via config utility.", totpName, account.Username)
|
|
||||||
fmt.Printf("TOTP method \"%s\" deleted.\n", totpName)
|
fmt.Printf("TOTP method \"%s\" deleted.\n", totpName)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -224,16 +216,6 @@ func main() {
|
||||||
fmt.Printf("%s\n", code)
|
fmt.Printf("%s\n", code)
|
||||||
return
|
return
|
||||||
|
|
||||||
case "cleanTOTP":
|
|
||||||
err := controller.DeleteUnconfirmedTOTPs(app.DB)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to clean up TOTP methods: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "TOTP methods pruned via config utility.")
|
|
||||||
fmt.Printf("Cleaned up dangling TOTP methods successfully.\n")
|
|
||||||
return
|
|
||||||
|
|
||||||
case "createInvite":
|
case "createInvite":
|
||||||
fmt.Printf("Creating invite...\n")
|
fmt.Printf("Creating invite...\n")
|
||||||
invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24)
|
invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24)
|
||||||
|
@ -242,7 +224,6 @@ func main() {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "Invite generted via config utility (%s).", invite.Code)
|
|
||||||
fmt.Printf(
|
fmt.Printf(
|
||||||
"Here you go! This code expires in %d hours: %s\n",
|
"Here you go! This code expires in %d hours: %s\n",
|
||||||
int(math.Ceil(invite.ExpiresAt.Sub(invite.CreatedAt).Hours())),
|
int(math.Ceil(invite.ExpiresAt.Sub(invite.CreatedAt).Hours())),
|
||||||
|
@ -258,7 +239,6 @@ func main() {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "Invites purged via config utility.")
|
|
||||||
fmt.Printf("Invites deleted successfully.\n")
|
fmt.Printf("Invites deleted successfully.\n")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -276,13 +256,11 @@ func main() {
|
||||||
"User: %s\n" +
|
"User: %s\n" +
|
||||||
"\tID: %s\n" +
|
"\tID: %s\n" +
|
||||||
"\tEmail: %s\n" +
|
"\tEmail: %s\n" +
|
||||||
"\tCreated: %s\n" +
|
"\tCreated: %s\n",
|
||||||
"\tLocked: %t\n",
|
|
||||||
account.Username,
|
account.Username,
|
||||||
account.ID,
|
account.ID,
|
||||||
email,
|
email,
|
||||||
account.CreatedAt,
|
account.CreatedAt,
|
||||||
account.Locked,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
@ -313,12 +291,11 @@ func main() {
|
||||||
account.Password = string(hashedPassword)
|
account.Password = string(hashedPassword)
|
||||||
err = controller.UpdateAccount(app.DB, account)
|
err = controller.UpdateAccount(app.DB, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to update password: %v\n", err)
|
fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "Password for '%s' updated via config utility.", account.Username)
|
fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
|
||||||
fmt.Printf("Password for \"%s\" updated successfully.\n", account.Username)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
case "deleteAccount":
|
case "deleteAccount":
|
||||||
|
@ -346,95 +323,16 @@ func main() {
|
||||||
if !strings.HasPrefix(res, "y") {
|
if !strings.HasPrefix(res, "y") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = controller.DeleteAccount(app.DB, account.ID)
|
err = controller.DeleteAccount(app.DB, account.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err)
|
fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' deleted via config utility.", account.Username)
|
|
||||||
fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
|
fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
|
||||||
return
|
return
|
||||||
|
|
||||||
case "lockAccount":
|
|
||||||
if len(os.Args) < 3 {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for lockAccount\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
username := os.Args[2]
|
|
||||||
fmt.Printf("Unlocking account \"%s\"...\n", username)
|
|
||||||
|
|
||||||
account, err := controller.GetAccountByUsername(app.DB, username)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if account == nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = controller.LockAccount(app.DB, account.ID)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to lock account: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' locked via config utility.", account.Username)
|
|
||||||
fmt.Printf("Account \"%s\" locked successfully.\n", account.Username)
|
|
||||||
return
|
|
||||||
|
|
||||||
case "unlockAccount":
|
|
||||||
if len(os.Args) < 3 {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for unlockAccount\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
username := os.Args[2]
|
|
||||||
fmt.Printf("Unlocking account \"%s\"...\n", username)
|
|
||||||
|
|
||||||
account, err := controller.GetAccountByUsername(app.DB, username)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if account == nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = controller.UnlockAccount(app.DB, account.ID)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to unlock account: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' unlocked via config utility.", account.Username)
|
|
||||||
fmt.Printf("Account \"%s\" unlocked successfully.\n", account.Username)
|
|
||||||
return
|
|
||||||
|
|
||||||
case "logs":
|
|
||||||
// TODO: add log search parameters
|
|
||||||
logs, err := app.Log.Search([]log.LogLevel{}, []string{}, "", 100, 0)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch logs: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
for _, item := range(logs) {
|
|
||||||
levelStr := ""
|
|
||||||
switch item.Level {
|
|
||||||
case log.LEVEL_INFO:
|
|
||||||
levelStr = "INFO"
|
|
||||||
case log.LEVEL_WARN:
|
|
||||||
levelStr = "WARN"
|
|
||||||
default:
|
|
||||||
levelStr = fmt.Sprintf("? (%d)", item.Level)
|
|
||||||
}
|
|
||||||
fmt.Printf("[%s] %s:\n\t[%s] %s: %s\n", item.CreatedAt.Format(time.UnixDate), item.ID, item.Type, levelStr, item.Content)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// command help
|
// command help
|
||||||
|
@ -444,15 +342,11 @@ func main() {
|
||||||
"listTOTP <username>:\n\tLists an account's TOTP methods.\n" +
|
"listTOTP <username>:\n\tLists an account's TOTP methods.\n" +
|
||||||
"deleteTOTP <username> <name>:\n\tDeletes an account's TOTP method.\n" +
|
"deleteTOTP <username> <name>:\n\tDeletes an account's TOTP method.\n" +
|
||||||
"testTOTP <username> <name>:\n\tGenerates the code for an account's TOTP method.\n" +
|
"testTOTP <username> <name>:\n\tGenerates the code for an account's TOTP method.\n" +
|
||||||
"cleanTOTP:\n\tCleans up unconfirmed (dangling) TOTP methods.\n" +
|
|
||||||
"\n" +
|
"\n" +
|
||||||
"createInvite:\n\tCreates an invite code to register new accounts.\n" +
|
"createInvite:\n\tCreates an invite code to register new accounts.\n" +
|
||||||
"purgeInvites:\n\tDeletes all available invite codes.\n" +
|
"purgeInvites:\n\tDeletes all available invite codes.\n" +
|
||||||
"listAccounts:\n\tLists all active accounts.\n",
|
"listAccounts:\n\tLists all active accounts.\n",
|
||||||
"deleteAccount <username>:\n\tDeletes the account under `username`.\n",
|
"deleteAccount <username>:\n\tDeletes an account with a given `username`.\n",
|
||||||
"lockAccount <username>:\n\tLocks the account under `username`.\n",
|
|
||||||
"unlockAccount <username>:\n\tUnlocks the account under `username`.\n",
|
|
||||||
"logs:\n\tShows system logs.\n",
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -460,13 +354,6 @@ func main() {
|
||||||
// handle DB migrations
|
// handle DB migrations
|
||||||
controller.CheckDBVersionAndMigrate(app.DB)
|
controller.CheckDBVersionAndMigrate(app.DB)
|
||||||
|
|
||||||
if app.Config.Twitch != nil {
|
|
||||||
err = controller.TwitchSetup(&app)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to set up Twitch integration: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// initial invite code
|
// initial invite code
|
||||||
accountsCount := 0
|
accountsCount := 0
|
||||||
err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account")
|
err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account")
|
||||||
|
@ -487,13 +374,6 @@ func main() {
|
||||||
fmt.Printf("No accounts exist! Generated invite code: %s\n", invite.Code)
|
fmt.Printf("No accounts exist! Generated invite code: %s\n", invite.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// delete expired sessions
|
|
||||||
err = controller.DeleteExpiredSessions(app.DB)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired sessions: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// delete expired invites
|
// delete expired invites
|
||||||
err = controller.DeleteExpiredInvites(app.DB)
|
err = controller.DeleteExpiredInvites(app.DB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -501,19 +381,10 @@ func main() {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// clean up unconfirmed TOTP methods
|
|
||||||
err = controller.DeleteUnconfirmedTOTPs(app.DB)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to clean up unconfirmed TOTP methods: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
go cursor.StartCursor(&app)
|
|
||||||
|
|
||||||
// start the web server!
|
// start the web server!
|
||||||
mux := createServeMux(&app)
|
mux := createServeMux(&app)
|
||||||
fmt.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port)
|
fmt.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port)
|
||||||
stdLog.Fatal(
|
log.Fatal(
|
||||||
http.ListenAndServe(fmt.Sprintf("%s:%d", app.Config.Host, app.Config.Port),
|
http.ListenAndServe(fmt.Sprintf("%s:%d", app.Config.Host, app.Config.Port),
|
||||||
HTTPLog(DefaultHeaders(mux)),
|
HTTPLog(DefaultHeaders(mux)),
|
||||||
))
|
))
|
||||||
|
@ -525,13 +396,48 @@ func createServeMux(app *model.AppState) *http.ServeMux {
|
||||||
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app)))
|
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app)))
|
||||||
mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app)))
|
mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app)))
|
||||||
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app)))
|
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app)))
|
||||||
mux.Handle("/uploads/", http.StripPrefix("/uploads", view.StaticHandler(filepath.Join(app.Config.DataDirectory, "uploads"))))
|
mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(app.Config.DataDirectory, "uploads"))))
|
||||||
mux.Handle("/cursor-ws", cursor.Handler(app))
|
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
mux.Handle("/", view.IndexHandler(app))
|
if r.Method == http.MethodHead {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||||
|
err := templates.Pages["index"].Execute(w, nil)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
staticHandler("public").ServeHTTP(w, r)
|
||||||
|
}))
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func staticHandler(directory string) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
info, err := os.Stat(filepath.Join(directory, filepath.Clean(r.URL.Path)))
|
||||||
|
|
||||||
|
// does the file exist?
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// is thjs a directory? (forbidden)
|
||||||
|
if info.IsDir() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.FileServer(http.Dir(directory)).ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var PoweredByStrings = []string{
|
var PoweredByStrings = []string{
|
||||||
"nerd rage",
|
"nerd rage",
|
||||||
"estrogen",
|
"estrogen",
|
||||||
|
@ -583,14 +489,6 @@ type LoggingResponseWriter struct {
|
||||||
Status int
|
Status int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (lrw *LoggingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|
||||||
hijack, ok := lrw.ResponseWriter.(http.Hijacker)
|
|
||||||
if !ok {
|
|
||||||
return nil, nil, errors.New("Server does not support hijacking\n")
|
|
||||||
}
|
|
||||||
return hijack.Hijack()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lrw *LoggingResponseWriter) WriteHeader(status int) {
|
func (lrw *LoggingResponseWriter) WriteHeader(status int) {
|
||||||
lrw.Status = status
|
lrw.Status = status
|
||||||
lrw.ResponseWriter.WriteHeader(status)
|
lrw.ResponseWriter.WriteHeader(status)
|
||||||
|
|
|
@ -1,23 +1,20 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const COOKIE_TOKEN string = "AM_SESSION"
|
const COOKIE_TOKEN string = "AM_SESSION"
|
||||||
const MAX_LOGIN_FAIL_ATTEMPTS int = 3
|
|
||||||
|
|
||||||
type (
|
type (
|
||||||
Account struct {
|
Account struct {
|
||||||
ID string `json:"id" db:"id"`
|
ID string `json:"id" db:"id"`
|
||||||
Username string `json:"username" db:"username"`
|
Username string `json:"username" db:"username"`
|
||||||
Password string `json:"password" db:"password"`
|
Password string `json:"password" db:"password"`
|
||||||
Email sql.NullString `json:"email" db:"email"`
|
Email sql.NullString `json:"email" db:"email"`
|
||||||
AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"`
|
AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
FailAttempts int `json:"fail_attempts" db:"fail_attempts"`
|
|
||||||
Locked bool `json:"locked" db:"locked"`
|
|
||||||
|
|
||||||
Privileges []AccountPrivilege `json:"privileges"`
|
Privileges []AccountPrivilege `json:"privileges"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,6 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import "github.com/jmoiron/sqlx"
|
||||||
"github.com/jmoiron/sqlx"
|
|
||||||
|
|
||||||
"arimelody-web/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
type (
|
type (
|
||||||
DBConfig struct {
|
DBConfig struct {
|
||||||
|
@ -21,27 +17,17 @@ type (
|
||||||
Secret string `toml:"secret"`
|
Secret string `toml:"secret"`
|
||||||
}
|
}
|
||||||
|
|
||||||
TwitchConfig struct {
|
|
||||||
Broadcaster string `toml:"broadcaster"`
|
|
||||||
ClientID string `toml:"client_id"`
|
|
||||||
Secret string `toml:"secret"`
|
|
||||||
}
|
|
||||||
|
|
||||||
Config struct {
|
Config struct {
|
||||||
BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."`
|
BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."`
|
||||||
Host string `toml:"host"`
|
Host string `toml:"host"`
|
||||||
Port int64 `toml:"port"`
|
Port int64 `toml:"port"`
|
||||||
DataDirectory string `toml:"data_dir"`
|
DataDirectory string `toml:"data_dir"`
|
||||||
TrustedProxies []string `toml:"trusted_proxies"`
|
|
||||||
DB DBConfig `toml:"db"`
|
DB DBConfig `toml:"db"`
|
||||||
Discord *DiscordConfig `toml:"discord"`
|
Discord DiscordConfig `toml:"discord"`
|
||||||
Twitch *TwitchConfig `toml:"twitch"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AppState struct {
|
AppState struct {
|
||||||
DB *sqlx.DB
|
DB *sqlx.DB
|
||||||
Config Config
|
Config Config
|
||||||
Log log.Logger
|
|
||||||
Twitch *TwitchState
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,17 +1,21 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
type (
|
type (
|
||||||
Artist struct {
|
Artist struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Website string `json:"website"`
|
Website string `json:"website"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func (artist Artist) GetAvatar() string {
|
func (artist Artist) GetWebsite() string {
|
||||||
if artist.Avatar == "" {
|
return artist.Website
|
||||||
return "/img/default-avatar.png"
|
}
|
||||||
}
|
|
||||||
return artist.Avatar
|
func (artist Artist) GetAvatar() string {
|
||||||
|
if artist.Avatar == "" {
|
||||||
|
return "/img/default-avatar.png"
|
||||||
|
}
|
||||||
|
return artist.Avatar
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Test_Artist_GetAvatar(t *testing.T) {
|
|
||||||
want := "testavatar.png"
|
|
||||||
artist := Artist{ Avatar: want }
|
|
||||||
|
|
||||||
got := artist.GetAvatar()
|
|
||||||
if want != got {
|
|
||||||
t.Errorf(`correct value not returned when avatar is populated (want "%s", got "%s")`, want, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
artist = Artist{}
|
|
||||||
|
|
||||||
want = "/img/default-avatar.png"
|
|
||||||
got = artist.GetAvatar()
|
|
||||||
if want != got {
|
|
||||||
t.Errorf(`default value not returned when avatar is empty (want "%s", got "%s")`, want, got)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +1,16 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Link struct {
|
type Link struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (link Link) NormaliseName() string {
|
func (link Link) NormaliseName() string {
|
||||||
rgx := regexp.MustCompile(`[^a-z0-9\-]`)
|
rgx := regexp.MustCompile(`[^a-z0-9]`)
|
||||||
return rgx.ReplaceAllString(strings.ToLower(link.Name), "")
|
return strings.ToLower(rgx.ReplaceAllString(link.Name, ""))
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Test_Link_NormaliseName(t *testing.T) {
|
|
||||||
link := Link{
|
|
||||||
Name: "!c@o#o$l%-^a&w*e(s)o_m=e+-[l{i]n}k-0123456789ABCDEF",
|
|
||||||
}
|
|
||||||
|
|
||||||
want := "cool-awesome-link-0123456789abcdef"
|
|
||||||
got := link.NormaliseName()
|
|
||||||
if want != got {
|
|
||||||
t.Errorf(`name with invalid characters not properly formatted (want "%s", got "%s")`, want, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
link.Name = want
|
|
||||||
got = link.NormaliseName()
|
|
||||||
if want != got {
|
|
||||||
t.Errorf(`valid name mangled by formatter (want "%s", got "%s")`, want, got)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +1,9 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
|
@ -24,7 +24,6 @@ type (
|
||||||
Tracks []*Track `json:"tracks"`
|
Tracks []*Track `json:"tracks"`
|
||||||
Credits []*Credit `json:"credits"`
|
Credits []*Credit `json:"credits"`
|
||||||
Links []*Link `json:"links"`
|
Links []*Link `json:"links"`
|
||||||
CreatedAt time.Time `json:"-" db:"created_at"`
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -50,6 +49,10 @@ func (release Release) PrintReleaseDate() string {
|
||||||
return release.ReleaseDate.Format("02 January 2006")
|
return release.ReleaseDate.Format("02 January 2006")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (release Release) GetReleaseYear() int {
|
||||||
|
return release.ReleaseDate.Year()
|
||||||
|
}
|
||||||
|
|
||||||
func (release Release) GetArtwork() string {
|
func (release Release) GetArtwork() string {
|
||||||
if release.Artwork == "" {
|
if release.Artwork == "" {
|
||||||
return "/img/default-cover-art.png"
|
return "/img/default-cover-art.png"
|
||||||
|
@ -73,23 +76,23 @@ func (release Release) GetUniqueArtistNames(only_primary bool) []string {
|
||||||
names = append(names, credit.Artist.Name)
|
names = append(names, credit.Artist.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
func (release Release) PrintArtists(only_primary bool, ampersand bool) string {
|
func (release Release) PrintArtists(only_primary bool, ampersand bool) string {
|
||||||
names := release.GetUniqueArtistNames(only_primary)
|
names := release.GetUniqueArtistNames(only_primary)
|
||||||
|
|
||||||
if len(names) == 0 {
|
if len(names) == 0 {
|
||||||
return "Unknown Artist"
|
return "Unknown Artist"
|
||||||
} else if len(names) == 1 {
|
} else if len(names) == 1 {
|
||||||
return names[0]
|
return names[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
if ampersand {
|
if ampersand {
|
||||||
res := strings.Join(names[:len(names)-1], ", ")
|
res := strings.Join(names[:len(names)-1], ", ")
|
||||||
res += " & " + names[len(names)-1]
|
res += " & " + names[len(names)-1]
|
||||||
return res
|
return res
|
||||||
} else {
|
} else {
|
||||||
return strings.Join(names[:], ", ")
|
return strings.Join(names[:], ", ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,157 +0,0 @@
|
||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Test_Release_DescriptionHTML(t *testing.T) {
|
|
||||||
release := Release{
|
|
||||||
Description: "this is\na test\n<strong>description!</strong>",
|
|
||||||
}
|
|
||||||
|
|
||||||
// descriptions are set by privileged users,
|
|
||||||
// so we'll allow HTML injection here
|
|
||||||
want := "this is<br>a test<br><strong>description!</strong>"
|
|
||||||
got := release.GetDescriptionHTML()
|
|
||||||
if want != string(got) {
|
|
||||||
t.Errorf(`release description incorrectly formatted (want "%s", got "%s")`, want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_Release_ReleaseDate(t *testing.T) {
|
|
||||||
release := Release{
|
|
||||||
ReleaseDate: time.Date(2025, time.July, 26, 16, 0, 0, 0, time.UTC),
|
|
||||||
}
|
|
||||||
|
|
||||||
want := "2025-07-26T16:00"
|
|
||||||
got := release.TextReleaseDate()
|
|
||||||
if want != got {
|
|
||||||
t.Errorf(`release date incorrectly formatted (want "%s", got "%s")`, want, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
want = "26 July 2025"
|
|
||||||
got = release.PrintReleaseDate()
|
|
||||||
if want != got {
|
|
||||||
t.Errorf(`release date (print) incorrectly formatted (want "%s", got "%s")`, want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_Release_Artwork(t *testing.T) {
|
|
||||||
want := "testartwork.png"
|
|
||||||
release := Release{ Artwork: want }
|
|
||||||
|
|
||||||
got := release.GetArtwork()
|
|
||||||
if want != got {
|
|
||||||
t.Errorf(`correct value not returned when artwork is populated (want "%s", got "%s")`, want, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
release = Release{}
|
|
||||||
|
|
||||||
want = "/img/default-cover-art.png"
|
|
||||||
got = release.GetArtwork()
|
|
||||||
if want != got {
|
|
||||||
t.Errorf(`default value not returned when artwork is empty (want "%s", got "%s")`, want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_Release_IsSingle(t *testing.T) {
|
|
||||||
release := Release{
|
|
||||||
Tracks: []*Track{},
|
|
||||||
}
|
|
||||||
|
|
||||||
if release.IsSingle() {
|
|
||||||
t.Errorf("IsSingle() == true when no tracks are present")
|
|
||||||
}
|
|
||||||
|
|
||||||
release.Tracks = append(release.Tracks, &Track{})
|
|
||||||
if !release.IsSingle() {
|
|
||||||
t.Errorf("IsSingle() == false when one track is present")
|
|
||||||
}
|
|
||||||
|
|
||||||
release.Tracks = append(release.Tracks, &Track{})
|
|
||||||
if release.IsSingle() {
|
|
||||||
t.Errorf("IsSingle() == true when >1 tracks are present")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_Release_IsReleased(t *testing.T) {
|
|
||||||
release := Release {
|
|
||||||
ReleaseDate: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if !release.IsReleased() {
|
|
||||||
t.Errorf("IsRelease() == false when release date in the past")
|
|
||||||
}
|
|
||||||
|
|
||||||
release.ReleaseDate = time.Now().Add(time.Hour)
|
|
||||||
if release.IsReleased() {
|
|
||||||
t.Errorf("IsRelease() == true when release date in the future")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_Release_PrintArtists(t *testing.T) {
|
|
||||||
artist1 := "ari melody"
|
|
||||||
artist2 := "aridoodle"
|
|
||||||
artist3 := "idk"
|
|
||||||
artist4 := "guest"
|
|
||||||
|
|
||||||
release := Release {
|
|
||||||
Credits: []*Credit{
|
|
||||||
{ Artist: Artist{ Name: artist1 }, Primary: true },
|
|
||||||
{ Artist: Artist{ Name: artist2 }, Primary: true },
|
|
||||||
{ Artist: Artist{ Name: artist3 }, Primary: false },
|
|
||||||
{ Artist: Artist{ Name: artist4 }, Primary: true },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
want := []string{ artist1, artist2, artist4 }
|
|
||||||
got := release.GetUniqueArtistNames(true)
|
|
||||||
if len(want) != len(got) {
|
|
||||||
t.Errorf(`len(GetUniqueArtistNames) (primary only) == %d, want %d`, len(got), len(want))
|
|
||||||
}
|
|
||||||
for i := range got {
|
|
||||||
if want[i] != got[i] {
|
|
||||||
t.Errorf(`GetUniqueArtistNames[%d] (primary only) == %s, want %s`, i, got[i], want[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
want = []string{ artist1, artist2, artist3, artist4 }
|
|
||||||
got = release.GetUniqueArtistNames(false)
|
|
||||||
if len(want) != len(got) {
|
|
||||||
t.Errorf(`len(GetUniqueArtistNames) == %d, want %d`, len(got), len(want))
|
|
||||||
}
|
|
||||||
for i := range got {
|
|
||||||
if want[i] != got[i] {
|
|
||||||
t.Errorf(`GetUniqueArtistNames[%d] == %s, want %s`, i, got[i], want[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
want := "ari melody, aridoodle & guest"
|
|
||||||
got := release.PrintArtists(true, true)
|
|
||||||
if want != got {
|
|
||||||
t.Errorf(`PrintArtists (primary only, ampersand) == "%s", want "%s"`, want, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
want = "ari melody, aridoodle, guest"
|
|
||||||
got = release.PrintArtists(true, false)
|
|
||||||
if want != got {
|
|
||||||
t.Errorf(`PrintArtists (primary only) == "%s", want "%s"`, want, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
want = "ari melody, aridoodle, idk & guest"
|
|
||||||
got = release.PrintArtists(false, true)
|
|
||||||
if want != got {
|
|
||||||
t.Errorf(`PrintArtists (all, ampersand) == "%s", want "%s"`, want, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
want = "ari melody, aridoodle, idk, guest"
|
|
||||||
got = release.PrintArtists(false, false)
|
|
||||||
if want != got {
|
|
||||||
t.Errorf(`PrintArtists (all) == "%s", want "%s"`, want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,18 +1,17 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
Token string `json:"-" db:"token"`
|
Token string `json:"token" db:"token"`
|
||||||
UserAgent string `json:"user_agent" db:"user_agent"`
|
UserAgent string `json:"user_agent" db:"user_agent"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
ExpiresAt time.Time `json:"-" db:"expires_at"`
|
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
|
||||||
|
|
||||||
Account *Account `json:"-" db:"-"`
|
Account *Account `json:"-" db:"account"`
|
||||||
AttemptAccount *Account `json:"-" db:"-"`
|
|
||||||
Message sql.NullString `json:"-" db:"message"`
|
Message sql.NullString `json:"-" db:"message"`
|
||||||
Error sql.NullString `json:"-" db:"error"`
|
Error sql.NullString `json:"-" db:"error"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TOTP struct {
|
type TOTP struct {
|
||||||
|
@ -9,5 +9,4 @@ type TOTP struct {
|
||||||
AccountID string `json:"accountID" db:"account"`
|
AccountID string `json:"accountID" db:"account"`
|
||||||
Secret string `json:"-" db:"secret"`
|
Secret string `json:"-" db:"secret"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
Confirmed bool `json:"-" db:"confirmed"`
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,20 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
Track struct {
|
Track struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Lyrics string `json:"lyrics" db:"lyrics"`
|
Lyrics string `json:"lyrics" db:"lyrics"`
|
||||||
PreviewURL string `json:"previewURL" db:"preview_url"`
|
PreviewURL string `json:"previewURL" db:"preview_url"`
|
||||||
|
|
||||||
Number int
|
Number int
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func (track Track) GetDescriptionHTML() template.HTML {
|
func (track Track) GetDescriptionHTML() template.HTML {
|
||||||
|
|
|
@ -1,43 +0,0 @@
|
||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Test_Track_DescriptionHTML(t *testing.T) {
|
|
||||||
track := Track{
|
|
||||||
Description: "this is\na test\n<strong>description!</strong>",
|
|
||||||
}
|
|
||||||
|
|
||||||
// descriptions are set by privileged users,
|
|
||||||
// so we'll allow HTML injection here
|
|
||||||
want := "this is<br>a test<br><strong>description!</strong>"
|
|
||||||
got := track.GetDescriptionHTML()
|
|
||||||
if want != string(got) {
|
|
||||||
t.Errorf(`track description incorrectly formatted (want "%s", got "%s")`, want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_Track_LyricsHTML(t *testing.T) {
|
|
||||||
track := Track{
|
|
||||||
Lyrics: "these are\ntest\n<strong>lyrics!</strong>",
|
|
||||||
}
|
|
||||||
|
|
||||||
// lyrics are set by privileged users,
|
|
||||||
// so we'll allow HTML injection here
|
|
||||||
want := "these are<br>test<br><strong>lyrics!</strong>"
|
|
||||||
got := track.GetLyricsHTML()
|
|
||||||
if want != string(got) {
|
|
||||||
t.Errorf(`track lyrics incorrectly formatted (want "%s", got "%s")`, want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_Track_Add(t *testing.T) {
|
|
||||||
track := Track{}
|
|
||||||
|
|
||||||
want := 4
|
|
||||||
got := track.Add(2, 2)
|
|
||||||
if want != got {
|
|
||||||
t.Errorf(`somehow, we screwed up addition. (want %d, got %d)`, want, got)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,43 +0,0 @@
|
||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type (
|
|
||||||
TwitchOAuthToken struct {
|
|
||||||
AccessToken string
|
|
||||||
ExpiresAt time.Time
|
|
||||||
TokenType string
|
|
||||||
}
|
|
||||||
|
|
||||||
TwitchState struct {
|
|
||||||
Token *TwitchOAuthToken
|
|
||||||
}
|
|
||||||
|
|
||||||
TwitchStreamInfo struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
UserLogin string `json:"user_login"`
|
|
||||||
UserName string `json:"user_name"`
|
|
||||||
GameID string `json:"game_id"`
|
|
||||||
GameName string `json:"game_name"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
ViewerCount int `json:"viewer_count"`
|
|
||||||
StartedAt string `json:"started_at"`
|
|
||||||
Language string `json:"language"`
|
|
||||||
ThumbnailURL string `json:"thumbnail_url"`
|
|
||||||
TagIDs []string `json:"tag_ids"`
|
|
||||||
Tags []string `json:"tags"`
|
|
||||||
IsMature bool `json:"is_mature"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
func (info *TwitchStreamInfo) Thumbnail(width int, height int) string {
|
|
||||||
res := strings.Replace(info.ThumbnailURL, "{width}", fmt.Sprintf("%d", width), 1)
|
|
||||||
res = strings.Replace(res, "{height}", fmt.Sprintf("%d", height), 1)
|
|
||||||
return res
|
|
||||||
}
|
|
|
@ -1,10 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<g>
|
|
||||||
<path d="M256,512C397.384,512 512,397.385 512,256C512,114.616 397.384,0 256,0C114.615,0 0,114.616 0,256C0,397.385 114.615,512 256,512Z" style="fill:rgb(35,159,194);"/>
|
|
||||||
<path d="M324.857,238.405C306.507,238.405 297.131,252.847 297.131,274.605C297.131,295.171 307.269,310.609 324.857,310.609C344.746,310.609 352.202,292.407 352.202,274.605C352.188,256.015 342.817,238.405 324.851,238.405L324.857,238.405ZM276.1,184.409L297.896,184.409L297.896,236.624L298.282,236.624C304.209,226.737 316.637,220.603 327.728,220.603C358.89,220.603 374.001,245.137 374.001,275.007C374.001,302.492 360.618,328.405 331.358,328.405C317.974,328.405 303.633,325.051 297.13,311.596L296.752,311.596L296.752,325.647L276.098,325.647L276.098,184.412L276.1,184.409Z" style="fill:white;"/>
|
|
||||||
<path d="M454.389,257.598C452.667,245.136 443.874,238.406 431.827,238.406C420.54,238.406 404.674,244.541 404.674,275.598C404.674,292.61 411.938,310.613 430.87,310.613C443.488,310.613 452.281,301.899 454.389,287.262L476.185,287.262C472.169,313.768 456.302,328.405 430.87,328.405C399.893,328.405 382.876,305.663 382.876,275.598C382.876,244.742 399.129,220.609 431.635,220.609C454.579,220.609 474.089,232.476 476.185,257.6L454.425,257.6L454.389,257.598Z" style="fill:white;"/>
|
|
||||||
<path d="M199.895,325.339L36.407,325.339L112.753,184.409L276.242,184.409L199.895,325.339Z" style="fill:white;"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.8 KiB |
|
@ -1,10 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 568 501" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<g transform="matrix(1,0,0,1,-228,-281.442)">
|
|
||||||
<path d="M351.121,315.106C416.241,363.994 486.281,463.123 512,516.315C537.719,463.123 607.759,363.994 672.879,315.106C719.866,279.83 796,252.536 796,339.388C796,356.734 786.055,485.101 780.222,505.943C759.947,578.396 686.067,596.876 620.347,585.691C735.222,605.242 764.444,670.002 701.333,734.762C581.473,857.754 529.061,703.903 515.631,664.481C513.169,657.254 512.017,653.873 512,656.748C511.983,653.873 510.831,657.254 508.369,664.481C494.939,703.903 442.527,857.754 322.667,734.762C259.556,670.002 288.778,605.242 403.653,585.691C337.933,596.876 264.053,578.396 243.778,505.943C237.945,485.101 228,356.734 228,339.388C228,252.536 304.134,279.83 351.121,315.106Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(3.06233e-14,500.117,-500.117,3.06233e-14,512,281.442)"><stop offset="0" style="stop-color:rgb(10,122,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(89,185,255);stop-opacity:1"/></linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.6 KiB |
|
@ -1,164 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 4.2333332 4.2333335"
|
|
||||||
version="1.1"
|
|
||||||
id="svg1468"
|
|
||||||
sodipodi:docname="codeberg-logo_icon_blue.svg"
|
|
||||||
inkscape:version="1.2-alpha1 (b6a15bb, 2022-02-23)"
|
|
||||||
inkscape:export-filename="/home/mray/Projects/Codeberg/logo/icon/png/codeberg-logo_icon_blue.png"
|
|
||||||
inkscape:export-xdpi="384"
|
|
||||||
inkscape:export-ydpi="384"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
|
||||||
xmlns:cc="http://creativecommons.org/ns#"
|
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
|
||||||
<title
|
|
||||||
id="title16">Codeberg logo</title>
|
|
||||||
<defs
|
|
||||||
id="defs1462">
|
|
||||||
<linearGradient
|
|
||||||
xlink:href="#linearGradient6924"
|
|
||||||
id="linearGradient6918"
|
|
||||||
x1="42519.285"
|
|
||||||
y1="-7078.7891"
|
|
||||||
x2="42575.336"
|
|
||||||
y2="-6966.9307"
|
|
||||||
gradientUnits="userSpaceOnUse" />
|
|
||||||
<linearGradient
|
|
||||||
id="linearGradient6924">
|
|
||||||
<stop
|
|
||||||
style="stop-color:#2185d0;stop-opacity:0"
|
|
||||||
offset="0"
|
|
||||||
id="stop6920" />
|
|
||||||
<stop
|
|
||||||
id="stop6926"
|
|
||||||
offset="0.49517274"
|
|
||||||
style="stop-color:#2185d0;stop-opacity:0.48923996" />
|
|
||||||
<stop
|
|
||||||
style="stop-color:#2185d0;stop-opacity:0.63279623"
|
|
||||||
offset="1"
|
|
||||||
id="stop6922" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient
|
|
||||||
xlink:href="#linearGradient6924-6"
|
|
||||||
id="linearGradient6918-3"
|
|
||||||
x1="42519.285"
|
|
||||||
y1="-7078.7891"
|
|
||||||
x2="42575.336"
|
|
||||||
y2="-6966.9307"
|
|
||||||
gradientUnits="userSpaceOnUse" />
|
|
||||||
<linearGradient
|
|
||||||
id="linearGradient6924-6">
|
|
||||||
<stop
|
|
||||||
style="stop-color:#2185d0;stop-opacity:0;"
|
|
||||||
offset="0"
|
|
||||||
id="stop6920-7" />
|
|
||||||
<stop
|
|
||||||
id="stop6926-5"
|
|
||||||
offset="0.49517274"
|
|
||||||
style="stop-color:#2185d0;stop-opacity:0.30000001;" />
|
|
||||||
<stop
|
|
||||||
style="stop-color:#2185d0;stop-opacity:0.30000001;"
|
|
||||||
offset="1"
|
|
||||||
id="stop6922-3" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<sodipodi:namedview
|
|
||||||
showborder="false"
|
|
||||||
id="base"
|
|
||||||
pagecolor="#ffffff"
|
|
||||||
bordercolor="#666666"
|
|
||||||
borderopacity="1.0"
|
|
||||||
inkscape:pageopacity="0.0"
|
|
||||||
inkscape:pageshadow="2"
|
|
||||||
inkscape:zoom="22.627417"
|
|
||||||
inkscape:cx="12.948893"
|
|
||||||
inkscape:cy="12.661631"
|
|
||||||
inkscape:document-units="px"
|
|
||||||
inkscape:current-layer="svg1468"
|
|
||||||
inkscape:document-rotation="0"
|
|
||||||
showgrid="false"
|
|
||||||
fit-margin-top="0"
|
|
||||||
fit-margin-left="0"
|
|
||||||
fit-margin-right="0"
|
|
||||||
fit-margin-bottom="0"
|
|
||||||
units="px"
|
|
||||||
inkscape:snap-global="false"
|
|
||||||
inkscape:snap-page="true"
|
|
||||||
showguides="false"
|
|
||||||
inkscape:window-width="1531"
|
|
||||||
inkscape:window-height="873"
|
|
||||||
inkscape:window-x="69"
|
|
||||||
inkscape:window-y="27"
|
|
||||||
inkscape:window-maximized="1"
|
|
||||||
inkscape:pagecheckerboard="0"
|
|
||||||
inkscape:deskcolor="#d1d1d1">
|
|
||||||
<inkscape:grid
|
|
||||||
type="xygrid"
|
|
||||||
id="grid2067" />
|
|
||||||
</sodipodi:namedview>
|
|
||||||
<metadata
|
|
||||||
id="metadata1465">
|
|
||||||
<rdf:RDF>
|
|
||||||
<cc:Work
|
|
||||||
rdf:about="">
|
|
||||||
<dc:format>image/svg+xml</dc:format>
|
|
||||||
<dc:type
|
|
||||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
|
||||||
<dc:title>Codeberg logo</dc:title>
|
|
||||||
<cc:license
|
|
||||||
rdf:resource="http://creativecommons.org/publicdomain/zero/1.0/" />
|
|
||||||
<dc:creator>
|
|
||||||
<cc:Agent>
|
|
||||||
<dc:title>Robert Martinez</dc:title>
|
|
||||||
</cc:Agent>
|
|
||||||
</dc:creator>
|
|
||||||
<dc:rights>
|
|
||||||
<cc:Agent>
|
|
||||||
<dc:title>Codeberg and the Codeberg Logo are trademarks of Codeberg e.V.</dc:title>
|
|
||||||
</cc:Agent>
|
|
||||||
</dc:rights>
|
|
||||||
<dc:date>2020-04-09</dc:date>
|
|
||||||
<dc:publisher>
|
|
||||||
<cc:Agent>
|
|
||||||
<dc:title>Codeberg e.V.</dc:title>
|
|
||||||
</cc:Agent>
|
|
||||||
</dc:publisher>
|
|
||||||
<dc:source>codeberg.org</dc:source>
|
|
||||||
</cc:Work>
|
|
||||||
<cc:License
|
|
||||||
rdf:about="http://creativecommons.org/publicdomain/zero/1.0/">
|
|
||||||
<cc:permits
|
|
||||||
rdf:resource="http://creativecommons.org/ns#Reproduction" />
|
|
||||||
<cc:permits
|
|
||||||
rdf:resource="http://creativecommons.org/ns#Distribution" />
|
|
||||||
<cc:permits
|
|
||||||
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
|
|
||||||
</cc:License>
|
|
||||||
</rdf:RDF>
|
|
||||||
</metadata>
|
|
||||||
<g
|
|
||||||
id="g370484"
|
|
||||||
inkscape:label="logo"
|
|
||||||
transform="matrix(0.06551432,0,0,0.06551432,-2.232417,-1.431776)">
|
|
||||||
<path
|
|
||||||
id="path6733-5"
|
|
||||||
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:url(#linearGradient6918-3);fill-opacity:1;stroke:none;stroke-width:3.67846;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:2;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill;stop-color:#000000;stop-opacity:1"
|
|
||||||
d="m 42519.285,-7078.7891 a 0.76086879,0.56791688 0 0 0 -0.738,0.6739 l 33.586,125.8886 a 87.182358,87.182358 0 0 0 39.381,-33.7636 l -71.565,-92.5196 a 0.76086879,0.56791688 0 0 0 -0.664,-0.2793 z"
|
|
||||||
transform="matrix(0.37058478,0,0,0.37058478,-15690.065,2662.0533)"
|
|
||||||
inkscape:label="berg" />
|
|
||||||
<path
|
|
||||||
id="path360787"
|
|
||||||
style="opacity:1;fill:#2185d0;fill-opacity:1;stroke-width:17.0055;paint-order:markers fill stroke;stop-color:#000000"
|
|
||||||
d="m 11249.461,-1883.6961 c -12.74,0 -23.067,10.3275 -23.067,23.0671 0,4.3335 1.22,8.5795 3.522,12.2514 l 19.232,-24.8636 c 0.138,-0.1796 0.486,-0.1796 0.624,0 l 19.233,24.8646 c 2.302,-3.6721 3.523,-7.9185 3.523,-12.2524 0,-12.7396 -10.327,-23.0671 -23.067,-23.0671 z"
|
|
||||||
sodipodi:nodetypes="sccccccs"
|
|
||||||
inkscape:label="sky"
|
|
||||||
transform="matrix(1.4006354,0,0,1.4006354,-15690.065,2662.0533)" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 5.8 KiB |
|
@ -1 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?><svg id="Discord-Logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.644 96"><defs><style>.cls-1{fill:#5865f2;}</style></defs><path id="Discord-Symbol-Blurple" class="cls-1" d="M81.15,0c-1.2376,2.1973-2.3489,4.4704-3.3591,6.794-9.5975-1.4396-19.3718-1.4396-28.9945,0-.985-2.3236-2.1216-4.5967-3.3591-6.794-9.0166,1.5407-17.8059,4.2431-26.1405,8.0568C2.779,32.5304-1.6914,56.3725.5312,79.8863c9.6732,7.1476,20.5083,12.603,32.0505,16.0884,2.6014-3.4854,4.8998-7.1981,6.8698-11.0623-3.738-1.3891-7.3497-3.1318-10.8098-5.1523.9092-.6567,1.7932-1.3386,2.6519-1.9953,20.281,9.547,43.7696,9.547,64.0758,0,.8587.7072,1.7427,1.3891,2.6519,1.9953-3.4601,2.0457-7.0718,3.7632-10.835,5.1776,1.97,3.8642,4.2683,7.5769,6.8698,11.0623,11.5419-3.4854,22.3769-8.9156,32.0509-16.0631,2.626-27.2771-4.496-50.9172-18.817-71.8548C98.9811,4.2684,90.1918,1.5659,81.1752.0505l-.0252-.0505ZM42.2802,65.4144c-6.2383,0-11.4159-5.6575-11.4159-12.6535s4.9755-12.6788,11.3907-12.6788,11.5169,5.708,11.4159,12.6788c-.101,6.9708-5.026,12.6535-11.3907,12.6535ZM84.3576,65.4144c-6.2637,0-11.3907-5.6575-11.3907-12.6535s4.9755-12.6788,11.3907-12.6788,11.4917,5.708,11.3906,12.6788c-.101,6.9708-5.026,12.6535-11.3906,12.6535Z"/></svg>
|
|
Before Width: | Height: | Size: 1.2 KiB |
|
@ -1,21 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Generator: Adobe Illustrator 23.0.6, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
|
||||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
|
||||||
viewBox="0 0 2400 2800" style="enable-background:new 0 0 2400 2800;" xml:space="preserve">
|
|
||||||
<style type="text/css">
|
|
||||||
.st0{fill:#FFFFFF;}
|
|
||||||
.st1{fill:#9146FF;}
|
|
||||||
</style>
|
|
||||||
<title>Asset 2</title>
|
|
||||||
<g>
|
|
||||||
<polygon class="st0" points="2200,1300 1800,1700 1400,1700 1050,2050 1050,1700 600,1700 600,200 2200,200 "/>
|
|
||||||
<g>
|
|
||||||
<g id="Layer_1-2">
|
|
||||||
<path class="st1" d="M500,0L0,500v1800h600v500l500-500h400l900-900V0H500z M2200,1300l-400,400h-400l-350,350v-350H600V200h1600
|
|
||||||
V1300z"/>
|
|
||||||
<rect x="1700" y="550" class="st1" width="200" height="600"/>
|
|
||||||
<rect x="1150" y="550" class="st1" width="200" height="600"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 890 B |
|
@ -1,10 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 507 355" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<g transform="matrix(4.16667,0,0,4.16667,495.608,299.004)">
|
|
||||||
<path d="M0,-58.482C-1.397,-63.709 -5.514,-67.825 -10.741,-69.222C-20.215,-71.761 -58.204,-71.761 -58.204,-71.761C-58.204,-71.761 -96.193,-71.761 -105.667,-69.222C-110.894,-67.825 -115.011,-63.709 -116.408,-58.482C-118.946,-49.008 -118.946,-29.241 -118.946,-29.241C-118.946,-29.241 -118.946,-9.474 -116.408,-0.001C-115.011,5.226 -110.894,9.343 -105.667,10.74C-96.193,13.279 -58.204,13.279 -58.204,13.279C-58.204,13.279 -20.215,13.279 -10.741,10.74C-5.514,9.343 -1.397,5.226 0,-0.001C2.539,-9.474 2.539,-29.241 2.539,-29.241C2.539,-29.241 2.539,-49.008 0,-58.482" style="fill:rgb(255,0,0);fill-rule:nonzero;"/>
|
|
||||||
</g>
|
|
||||||
<g transform="matrix(4.16667,0,0,4.16667,202.472,101.237)">
|
|
||||||
<path d="M0,36.446L31.562,18.223L0,0L0,36.446Z" style="fill:white;fill-rule:nonzero;"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 9.9 KiB |
|
@ -1,26 +1,13 @@
|
||||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
|
|
||||||
mDMEZNW03RYJKwYBBAHaRw8BAQdAuMUNVjXT7m/YisePPnSYY6lc1Xmm3oS79ZEO
|
mDMEZNW03RYJKwYBBAHaRw8BAQdAuMUNVjXT7m/YisePPnSYY6lc1Xmm3oS79ZEO
|
||||||
JriRCZy0HWFyaSBtZWxvZHkgPGFyaUBhcmltZWxvZHkubWU+iJkEExYKAEECGwMF
|
JriRCZy0HWFyaSBtZWxvZHkgPGFyaUBhcmltZWxvZHkubWU+iJMEExYKADsWIQTu
|
||||||
CwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AWIQTujeuNYocuegkeKt/PmYKckmeB
|
jeuNYocuegkeKt/PmYKckmeBiAUCZNW03QIbAwULCQgHAgIiAgYVCgkICwIEFgID
|
||||||
iAUCZ7UqUAUJCIMP8wAKCRDPmYKckmeBiO/NAP0SoJL4aKZqCeYiSoDF/Uw6nMmZ
|
AQIeBwIXgAAKCRDPmYKckmeBiGCbAP4wTcLCU5ZlfSTJrFtGhQKWA6DxtUO7Cegk
|
||||||
+oR1Uig41wQ/IDbhCAEApP2vbjSIu6pcp0AQlL7qcoyPWv+XkqPSFqW9KEZZVwqI
|
Vu8SgkY3KgEA1/YqjZ1vSaqPDN4137vmhkhfduoYOjN0iptNj39u2wG4OARk1bTd
|
||||||
kwQTFgoAOxYhBO6N641ihy56CR4q38+ZgpySZ4GIBQJk1bTdAhsDBQsJCAcCAiIC
|
EgorBgEEAZdVAQUBAQdAnA2drPzQBoXNdwIrFnovuF0CjX+8+8QSugCF4a5ZEXED
|
||||||
BhUKCQgLAgQWAgMBAh4HAheAAAoJEM+ZgpySZ4GIYJsA/jBNwsJTlmV9JMmsW0aF
|
AQgHiHgEGBYKACAWIQTujeuNYocuegkeKt/PmYKckmeBiAUCZNW03QIbDAAKCRDP
|
||||||
ApYDoPG1Q7sJ6CRW7xKCRjcqAQDX9iqNnW9Jqo8M3jXfu+aGSF926hg6M3SKm02P
|
mYKckmeBiC/xAQD1hu4WcstR40lkUxMqhZ44wmizrDA+eGCdh7Ge3Gy79wEAx385
|
||||||
f27bAbgzBGe1JooWCSsGAQQB2kcPAQEHQJbfh5iLHEpZndMgekqYzqTrUoAJ8ZIL
|
GnYoNplMTA4BTGs7orV4WSfSkoBx0+px1UOewgs=
|
||||||
d4WH0dcw9tOaiPUEGBYKACYCGwIWIQTujeuNYocuegkeKt/PmYKckmeBiAUCZ7Uq
|
=M1Bp
|
||||||
VgUJBaOeTACBdiAEGRYKAB0WIQQlu5dWmBR/P3ZxngxgtfA4bj3bfgUCZ7UmigAK
|
|
||||||
CRBgtfA4bj3bfux+AP4y5ydrjnGBMX7GuB2nh55SRdscSiXsZ66ntnjXyQcbWgEA
|
|
||||||
pDuu7FqXzXcnluuZxNFDT740Rnzs60tTeplDqGGWcAQJEM+ZgpySZ4GIc0kA/iSw
|
|
||||||
Nw+r3FC75omwrPpJF13B5fq93FweFx+oSaES6qzkAQDvgCK77qKKbvCju0g8zSsK
|
|
||||||
EZnv6xR4uvtGdVkvLpBdC7gzBGe1JpkWCSsGAQQB2kcPAQEHQGnU4lXFLchhKYkC
|
|
||||||
PshP+jvuRsNoedaDOK2p4dkQC8JuiH4EGBYKACYCGyAWIQTujeuNYocuegkeKt/P
|
|
||||||
mYKckmeBiAUCZ7UqXgUJBaOeRQAKCRDPmYKckmeBiL9KAQCJZIBhuSsoYa61I0XZ
|
|
||||||
cKzGZbB0h9pD6eg1VRswNIgHtQEAwu9Hgs1rs9cySvKbO7WgK6Qh6EfrvGgGOXCO
|
|
||||||
m3wVsg24OARntSo5EgorBgEEAZdVAQUBAQdA+/k586W1OHxndzDJNpbd+wqjyjr0
|
|
||||||
D5IXxfDs00advB0DAQgHiH4EGBYKACYWIQTujeuNYocuegkeKt/PmYKckmeBiAUC
|
|
||||||
Z7UqOQIbDAUJBaOagAAKCRDPmYKckmeBiEFxAQCgziQt2l3u7jnZVij4zop+K2Lv
|
|
||||||
TVFtkbG61tf6brRzBgD/X6c6X5BRyQC51JV1I1RFRBdeMAIXzcLFg2v3WUMccQs=
|
|
||||||
=YmHI
|
|
||||||
-----END PGP PUBLIC KEY BLOCK-----
|
-----END PGP PUBLIC KEY BLOCK-----
|
||||||
|
|
|
@ -1,106 +1,33 @@
|
||||||
const ARIMELODY_CONFIG_NAME = "arimelody.me-config";
|
const DEFAULT_CONFIG = {
|
||||||
|
crt: false
|
||||||
class Config {
|
};
|
||||||
_crt = false;
|
|
||||||
_cursor = false;
|
|
||||||
_cursorFunMode = false;
|
|
||||||
|
|
||||||
/** @type Map<string, Array<Function>> */
|
|
||||||
#listeners = new Map();
|
|
||||||
|
|
||||||
constructor(values) {
|
|
||||||
function thisOrElse(values, name, defaultValue) {
|
|
||||||
if (values === null) return defaultValue;
|
|
||||||
if (values[name] === undefined) return defaultValue;
|
|
||||||
return values[name];
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#listeners.set('crt', new Array());
|
|
||||||
this.crt = thisOrElse(values, 'crt', false);
|
|
||||||
this.#listeners.set('cursor', new Array());
|
|
||||||
this.cursor = thisOrElse(values, 'cursor', false);
|
|
||||||
this.#listeners.set('cursorFunMode', new Array());
|
|
||||||
this.cursorFunMode = thisOrElse(values, 'cursorFunMode', false);
|
|
||||||
this.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appends a listener function to be called when the config value of `name`
|
|
||||||
* is changed.
|
|
||||||
*/
|
|
||||||
addListener(name, callback) {
|
|
||||||
const callbacks = this.#listeners.get(name);
|
|
||||||
if (!callbacks) return;
|
|
||||||
callbacks.push(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes the listener function `callback` from the list of callbacks when
|
|
||||||
* the config value of `name` is changed.
|
|
||||||
*/
|
|
||||||
removeListener(name, callback) {
|
|
||||||
const callbacks = this.#listeners.get(name);
|
|
||||||
if (!callbacks) return;
|
|
||||||
callbacks.set(name, callbacks.filter(c => c !== callback));
|
|
||||||
}
|
|
||||||
|
|
||||||
save() {
|
|
||||||
localStorage.setItem(ARIMELODY_CONFIG_NAME, JSON.stringify({
|
|
||||||
crt: this.crt,
|
|
||||||
cursor: this.cursor,
|
|
||||||
cursorFunMode: this.cursorFunMode
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
get crt() { return this._crt }
|
|
||||||
set crt(/** @type boolean */ enabled) {
|
|
||||||
this._crt = enabled;
|
|
||||||
this.save();
|
|
||||||
|
|
||||||
if (enabled) {
|
|
||||||
document.body.classList.add("crt");
|
|
||||||
} else {
|
|
||||||
document.body.classList.remove("crt");
|
|
||||||
}
|
|
||||||
document.getElementById('toggle-crt').className = enabled ? "" : "disabled";
|
|
||||||
|
|
||||||
this.#listeners.get('crt').forEach(callback => {
|
|
||||||
callback(this._crt);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
get cursor() { return this._cursor }
|
|
||||||
set cursor(/** @type boolean */ value) {
|
|
||||||
this._cursor = value;
|
|
||||||
this.save();
|
|
||||||
this.#listeners.get('cursor').forEach(callback => {
|
|
||||||
callback(this._cursor);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
get cursorFunMode() { return this._cursorFunMode }
|
|
||||||
set cursorFunMode(/** @type boolean */ value) {
|
|
||||||
this._cursorFunMode = value;
|
|
||||||
this.save();
|
|
||||||
this.#listeners.get('cursorFunMode').forEach(callback => {
|
|
||||||
callback(this._cursorFunMode);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = (() => {
|
const config = (() => {
|
||||||
let values = null;
|
let saved = localStorage.getItem("config");
|
||||||
|
if (saved) {
|
||||||
|
const config = JSON.parse(saved);
|
||||||
|
setCRT(config.crt || DEFAULT_CONFIG.crt);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
const saved = localStorage.getItem(ARIMELODY_CONFIG_NAME);
|
localStorage.setItem("config", JSON.stringify(DEFAULT_CONFIG));
|
||||||
if (saved)
|
return DEFAULT_CONFIG;
|
||||||
values = JSON.parse(saved);
|
|
||||||
|
|
||||||
return new Config(values);
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
function saveConfig() {
|
||||||
|
localStorage.setItem("config", JSON.stringify(config));
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById("toggle-crt").addEventListener("click", () => {
|
document.getElementById("toggle-crt").addEventListener("click", () => {
|
||||||
config.crt = !config.crt;
|
config.crt = !config.crt;
|
||||||
|
setCRT(config.crt);
|
||||||
|
saveConfig();
|
||||||
});
|
});
|
||||||
|
|
||||||
window.config = config;
|
function setCRT(/** @type boolean */ enabled) {
|
||||||
export default config;
|
if (enabled) {
|
||||||
|
document.body.classList.add("crt");
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove("crt");
|
||||||
|
}
|
||||||
|
document.getElementById('toggle-crt').className = enabled ? "" : "disabled";
|
||||||
|
}
|
||||||
|
|
|
@ -1,403 +0,0 @@
|
||||||
import config from './config.js';
|
|
||||||
|
|
||||||
const CURSOR_LERP_RATE = 1/100;
|
|
||||||
const CURSOR_FUNCHAR_RATE = 20;
|
|
||||||
const CURSOR_CHAR_MAX_LIFE = 5000;
|
|
||||||
const CURSOR_MAX_CHARS = 64;
|
|
||||||
|
|
||||||
/** @type HTMLCanvasElement */
|
|
||||||
let canvas;
|
|
||||||
/** @type CanvasRenderingContext2D */
|
|
||||||
let ctx;
|
|
||||||
/** @type Cursor */
|
|
||||||
let myCursor;
|
|
||||||
/** @type Map<number, Cursor> */
|
|
||||||
let cursors = new Map();
|
|
||||||
|
|
||||||
/** @type WebSocket */
|
|
||||||
let ws;
|
|
||||||
|
|
||||||
let running = false;
|
|
||||||
let lastUpdate = 0;
|
|
||||||
|
|
||||||
let cursorBoxHeight = 0;
|
|
||||||
let cursorBoxRadius = 0;
|
|
||||||
let cursorIDFontSize = 0;
|
|
||||||
let cursorCharFontSize = 0;
|
|
||||||
|
|
||||||
class Cursor {
|
|
||||||
#funCharCooldown = CURSOR_FUNCHAR_RATE;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} id
|
|
||||||
* @param {number} x
|
|
||||||
* @param {number} y
|
|
||||||
*/
|
|
||||||
constructor(id, x, y) {
|
|
||||||
this.id = id;
|
|
||||||
|
|
||||||
// real coordinates (canonical)
|
|
||||||
this.x = x;
|
|
||||||
this.y = y;
|
|
||||||
// render coordinates (interpolated)
|
|
||||||
this.rx = x;
|
|
||||||
this.ry = y;
|
|
||||||
|
|
||||||
this.msg = '';
|
|
||||||
/** @type Array<FunChar> */
|
|
||||||
this.funChars = new Array();
|
|
||||||
this.colour = randomColour();
|
|
||||||
this.click = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {number} deltaTime
|
|
||||||
*/
|
|
||||||
update(deltaTime) {
|
|
||||||
this.rx += (this.x - this.rx) * CURSOR_LERP_RATE * deltaTime;
|
|
||||||
this.ry += (this.y - this.ry) * CURSOR_LERP_RATE * deltaTime;
|
|
||||||
|
|
||||||
if (this.#funCharCooldown > 0)
|
|
||||||
this.#funCharCooldown -= deltaTime;
|
|
||||||
|
|
||||||
const x = this.rx * innerWidth - scrollX;
|
|
||||||
const y = this.ry * innerHeight - scrollY;
|
|
||||||
const onBackground = ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--on-background');
|
|
||||||
|
|
||||||
if (config.cursorFunMode === true) {
|
|
||||||
if (this.msg.length > 0) {
|
|
||||||
if (this.#funCharCooldown <= 0) {
|
|
||||||
this.#funCharCooldown = CURSOR_FUNCHAR_RATE;
|
|
||||||
if (this.funChars.length >= CURSOR_MAX_CHARS) {
|
|
||||||
this.funChars.shift();
|
|
||||||
}
|
|
||||||
const yOffset = -10 / innerHeight;
|
|
||||||
const accelMultiplier = 0.002;
|
|
||||||
this.funChars.push(new FunChar(
|
|
||||||
this.x, this.y + yOffset,
|
|
||||||
(this.x - this.rx) * accelMultiplier, (this.y - this.ry) * accelMultiplier,
|
|
||||||
this.msg));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.funChars.forEach(char => {
|
|
||||||
if (char.life > CURSOR_CHAR_MAX_LIFE ||
|
|
||||||
char.y - scrollY > innerHeight ||
|
|
||||||
char.x < 0 ||
|
|
||||||
char.x * innerWidth - scrollX > innerWidth
|
|
||||||
) {
|
|
||||||
this.funChars = this.funChars.filter(c => c !== this);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
char.update(deltaTime);
|
|
||||||
});
|
|
||||||
} else if (this.msg.length > 0) {
|
|
||||||
ctx.font = 'normal bold ' + cursorCharFontSize + 'px monospace';
|
|
||||||
ctx.fillStyle = onBackground;
|
|
||||||
ctx.fillText(
|
|
||||||
this.msg,
|
|
||||||
(x + 6) * devicePixelRatio,
|
|
||||||
(y + -8) * devicePixelRatio);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lightTheme = matchMedia && matchMedia('(prefers-color-scheme: light)').matches;
|
|
||||||
|
|
||||||
if (lightTheme)
|
|
||||||
ctx.filter = 'saturate(5) brightness(0.8)';
|
|
||||||
|
|
||||||
const idText = '0x' + this.id.toString(16).padStart(8, '0');
|
|
||||||
const colour = this.click ? onBackground : this.colour;
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.roundRect(
|
|
||||||
(x) * devicePixelRatio,
|
|
||||||
(y) * devicePixelRatio,
|
|
||||||
(12 + 7.2 * idText.length) * devicePixelRatio,
|
|
||||||
cursorBoxHeight,
|
|
||||||
cursorBoxRadius);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fillStyle = lightTheme ? '#fff8' : '#0008';
|
|
||||||
ctx.fill();
|
|
||||||
ctx.strokeStyle = colour;
|
|
||||||
ctx.lineWidth = devicePixelRatio;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
ctx.font = cursorIDFontSize + 'px monospace';
|
|
||||||
ctx.fillStyle = colour;
|
|
||||||
ctx.fillText(
|
|
||||||
idText,
|
|
||||||
(x + 6) * devicePixelRatio,
|
|
||||||
(y + 14) * devicePixelRatio);
|
|
||||||
|
|
||||||
ctx.filter = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FunChar {
|
|
||||||
/**
|
|
||||||
* @param {number} x
|
|
||||||
* @param {number} y
|
|
||||||
* @param {number} xa
|
|
||||||
* @param {number} ya
|
|
||||||
* @param {string} text
|
|
||||||
*/
|
|
||||||
constructor(x, y, xa, ya, text) {
|
|
||||||
this.x = x;
|
|
||||||
this.y = y;
|
|
||||||
this.xa = xa + Math.random() * .0005 - .00025;
|
|
||||||
this.ya = ya + Math.random() * -.00025;
|
|
||||||
this.r = this.xa * 1000;
|
|
||||||
this.ra = this.r * 0.01;
|
|
||||||
this.text = text;
|
|
||||||
this.life = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {number} deltaTime
|
|
||||||
*/
|
|
||||||
update(deltaTime) {
|
|
||||||
this.life += deltaTime;
|
|
||||||
|
|
||||||
this.x += this.xa * deltaTime;
|
|
||||||
this.y += this.ya * deltaTime;
|
|
||||||
this.r += this.ra * deltaTime;
|
|
||||||
this.ya = Math.min(this.ya + 0.000001 * deltaTime, 10);
|
|
||||||
|
|
||||||
const x = this.x * innerWidth - scrollX;
|
|
||||||
const y = this.y * innerHeight - scrollY;
|
|
||||||
|
|
||||||
const translateOffset = {
|
|
||||||
x: (x + 7.2) * devicePixelRatio,
|
|
||||||
y: (y - 7.2) * devicePixelRatio,
|
|
||||||
};
|
|
||||||
ctx.translate(translateOffset.x, translateOffset.y);
|
|
||||||
ctx.rotate(this.r);
|
|
||||||
ctx.translate(-translateOffset.x, -translateOffset.y);
|
|
||||||
|
|
||||||
ctx.font = 'normal bold ' + cursorCharFontSize + 'px monospace';
|
|
||||||
ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--on-background');
|
|
||||||
ctx.fillText(
|
|
||||||
this.text,
|
|
||||||
x * devicePixelRatio,
|
|
||||||
y * devicePixelRatio);
|
|
||||||
|
|
||||||
ctx.resetTransform();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns string
|
|
||||||
*/
|
|
||||||
function randomColour() {
|
|
||||||
const min = 128;
|
|
||||||
const range = 100;
|
|
||||||
const red = Math.round((min + Math.random() * range)).toString(16);
|
|
||||||
const green = Math.round((min + Math.random() * range)).toString(16);
|
|
||||||
const blue = Math.round((min + Math.random() * range)).toString(16);
|
|
||||||
|
|
||||||
return '#' + red + green + blue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {MouseEvent} event
|
|
||||||
*/
|
|
||||||
let mouseMoveLock = false;
|
|
||||||
const mouseMoveCooldown = 1000/30;
|
|
||||||
function handleMouseMove(event) {
|
|
||||||
if (!myCursor) return;
|
|
||||||
|
|
||||||
const x = event.pageX / innerWidth;
|
|
||||||
const y = event.pageY / innerHeight;
|
|
||||||
const f = 10000; // four digit floating-point precision
|
|
||||||
|
|
||||||
if (!mouseMoveLock) {
|
|
||||||
mouseMoveLock = true;
|
|
||||||
if (ws && ws.readyState == WebSocket.OPEN)
|
|
||||||
ws.send(`pos:${Math.round(x * f) / f}:${Math.round(y * f) / f}`);
|
|
||||||
setTimeout(() => {
|
|
||||||
mouseMoveLock = false;
|
|
||||||
}, mouseMoveCooldown);
|
|
||||||
}
|
|
||||||
|
|
||||||
myCursor.x = x;
|
|
||||||
myCursor.y = y;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMouseDown() {
|
|
||||||
myCursor.click = true;
|
|
||||||
if (ws && ws.readyState == WebSocket.OPEN)
|
|
||||||
ws.send('click:1');
|
|
||||||
}
|
|
||||||
function handleMouseUp() {
|
|
||||||
myCursor.click = false;
|
|
||||||
if (ws && ws.readyState == WebSocket.OPEN)
|
|
||||||
ws.send('click:0');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {KeyboardEvent} event
|
|
||||||
*/
|
|
||||||
function handleKeyPress(event) {
|
|
||||||
if (event.key.length > 1) return;
|
|
||||||
if (event.metaKey || event.ctrlKey) return;
|
|
||||||
if (myCursor.msg === event.key) return;
|
|
||||||
if (ws && ws.readyState == WebSocket.OPEN)
|
|
||||||
ws.send(`char:${event.key}`);
|
|
||||||
myCursor.msg = event.key;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyUp() {
|
|
||||||
if (ws && ws.readyState == WebSocket.OPEN)
|
|
||||||
ws.send(`nochar`);
|
|
||||||
myCursor.msg = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {number} timestamp
|
|
||||||
*/
|
|
||||||
function update(timestamp) {
|
|
||||||
if (!running) return;
|
|
||||||
|
|
||||||
const deltaTime = timestamp - lastUpdate;
|
|
||||||
lastUpdate = timestamp;
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
cursors.forEach(cursor => {
|
|
||||||
cursor.update(deltaTime);
|
|
||||||
});
|
|
||||||
|
|
||||||
requestAnimationFrame(update);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleWindowResize() {
|
|
||||||
canvas.width = innerWidth * devicePixelRatio;
|
|
||||||
canvas.height = innerHeight * devicePixelRatio;
|
|
||||||
cursorBoxHeight = 20 * devicePixelRatio;
|
|
||||||
cursorBoxRadius = 4 * devicePixelRatio;
|
|
||||||
cursorIDFontSize = 12 * devicePixelRatio;
|
|
||||||
cursorCharFontSize = 20 * devicePixelRatio;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cursorSetup() {
|
|
||||||
if (running) throw new Error('Only one instance of Cursor can run at a time.');
|
|
||||||
running = true;
|
|
||||||
|
|
||||||
canvas = document.createElement('canvas');
|
|
||||||
canvas.id = 'cursors';
|
|
||||||
handleWindowResize();
|
|
||||||
document.body.appendChild(canvas);
|
|
||||||
|
|
||||||
ctx = canvas.getContext('2d');
|
|
||||||
|
|
||||||
myCursor = new Cursor('You!', innerWidth / 2, innerHeight / 2);
|
|
||||||
cursors.set(0, myCursor);
|
|
||||||
|
|
||||||
addEventListener('resize', handleWindowResize);
|
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
|
||||||
document.addEventListener('mousedown', handleMouseDown);
|
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
|
||||||
document.addEventListener('keypress', handleKeyPress);
|
|
||||||
document.addEventListener('keyup', handleKeyUp);
|
|
||||||
|
|
||||||
requestAnimationFrame(update);
|
|
||||||
|
|
||||||
ws = new WebSocket('/cursor-ws');
|
|
||||||
ws.addEventListener('open', () => {
|
|
||||||
console.log('Cursor connected to server successfully.');
|
|
||||||
|
|
||||||
ws.send(`loc:${location.pathname}`);
|
|
||||||
});
|
|
||||||
ws.addEventListener('error', error => {
|
|
||||||
console.error('Cursor WebSocket error:', error);
|
|
||||||
});
|
|
||||||
ws.addEventListener('close', () => {
|
|
||||||
console.log('Cursor connection closed.');
|
|
||||||
});
|
|
||||||
ws.addEventListener('message', event => {
|
|
||||||
const args = String(event.data).split(':');
|
|
||||||
if (args.length == 0) return;
|
|
||||||
|
|
||||||
let id = 0;
|
|
||||||
/** @type Cursor */
|
|
||||||
let cursor;
|
|
||||||
if (args.length > 1) {
|
|
||||||
id = Number(args[1]);
|
|
||||||
cursor = cursors.get(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (args[0]) {
|
|
||||||
case 'id': {
|
|
||||||
myCursor.id = id;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'join': {
|
|
||||||
if (id === myCursor.id) break;
|
|
||||||
cursors.set(id, new Cursor(id, 0, 0));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'leave': {
|
|
||||||
if (!cursor || cursor === myCursor) break;
|
|
||||||
cursors.delete(id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'char': {
|
|
||||||
if (!cursor || cursor === myCursor) break;
|
|
||||||
cursor.msg = args[2];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'nochar': {
|
|
||||||
if (!cursor || cursor === myCursor) break;
|
|
||||||
cursor.msg = '';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'click': {
|
|
||||||
if (!cursor || cursor === myCursor) break;
|
|
||||||
cursor.click = args[2] == '1';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'pos': {
|
|
||||||
if (!cursor || cursor === myCursor) break;
|
|
||||||
cursor.x = Number(args[2]);
|
|
||||||
cursor.y = Number(args[3]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
console.warn('Cursor: Unknown command received from server:', args[0]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Cursor tracking @ ${location.pathname}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cursorDestroy() {
|
|
||||||
if (!running) return;
|
|
||||||
|
|
||||||
removeEventListener('resize', handleWindowResize);
|
|
||||||
document.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
document.removeEventListener('mousedown', handleMouseDown);
|
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
|
||||||
document.removeEventListener('keypress', handleKeyPress);
|
|
||||||
document.removeEventListener('keyup', handleKeyUp);
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
cursors.clear();
|
|
||||||
myCursor = null;
|
|
||||||
|
|
||||||
console.log(`Cursor no longer tracking.`);
|
|
||||||
running = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.cursor === true) {
|
|
||||||
cursorSetup();
|
|
||||||
}
|
|
||||||
|
|
||||||
config.addListener('cursor', enabled => {
|
|
||||||
if (enabled === true)
|
|
||||||
cursorSetup();
|
|
||||||
else
|
|
||||||
cursorDestroy();
|
|
||||||
});
|
|
|
@ -1,5 +1,3 @@
|
||||||
import { hijackClickEvent } from "./main.js";
|
|
||||||
|
|
||||||
const hexPrimary = document.getElementById("hex-primary");
|
const hexPrimary = document.getElementById("hex-primary");
|
||||||
const hexSecondary = document.getElementById("hex-secondary");
|
const hexSecondary = document.getElementById("hex-secondary");
|
||||||
const hexTertiary = document.getElementById("hex-tertiary");
|
const hexTertiary = document.getElementById("hex-tertiary");
|
||||||
|
@ -16,8 +14,3 @@ updateHexColours();
|
||||||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
|
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
|
||||||
updateHexColours();
|
updateHexColours();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll("ul#projects li.project-item").forEach(projectItem => {
|
|
||||||
const link = projectItem.querySelector('a');
|
|
||||||
hijackClickEvent(projectItem, link);
|
|
||||||
});
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import "./header.js";
|
import "./header.js";
|
||||||
import "./config.js";
|
import "./config.js";
|
||||||
import "./cursor.js";
|
|
||||||
|
|
||||||
function type_out(e) {
|
function type_out(e) {
|
||||||
const text = e.innerText;
|
const text = e.innerText;
|
||||||
|
@ -45,28 +44,12 @@ function fill_list(list) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hijackClickEvent(container, link) {
|
|
||||||
container.addEventListener('click', event => {
|
|
||||||
if (event.target.tagName.toLowerCase() === 'a') return;
|
|
||||||
event.preventDefault();
|
|
||||||
link.dispatchEvent(new MouseEvent('click', {
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
view: window,
|
|
||||||
ctrlKey: event.ctrlKey,
|
|
||||||
metaKey: event.metaKey,
|
|
||||||
shiftKey: event.shiftKey,
|
|
||||||
altKey: event.altKey,
|
|
||||||
button: event.button,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
[...document.querySelectorAll(".typeout")]
|
[...document.querySelectorAll(".typeout")]
|
||||||
.filter((e) => e.innerText != "")
|
.filter((e) => e.innerText != "")
|
||||||
.forEach((e) => {
|
.forEach((e) => {
|
||||||
type_out(e);
|
type_out(e);
|
||||||
|
console.log(e);
|
||||||
});
|
});
|
||||||
[...document.querySelectorAll("ol, ul")]
|
[...document.querySelectorAll("ol, ul")]
|
||||||
.filter((e) => e.innerText != "")
|
.filter((e) => e.innerText != "")
|
||||||
|
|
|
@ -1,6 +1,12 @@
|
||||||
import { hijackClickEvent } from "./main.js";
|
import "./main.js";
|
||||||
|
|
||||||
document.querySelectorAll("div.music").forEach(container => {
|
document.querySelectorAll("div.music").forEach(container => {
|
||||||
const link = container.querySelector(".music-title a")
|
const link = container.querySelector(".music-title a").href
|
||||||
hijackClickEvent(container, link);
|
|
||||||
|
container.addEventListener("click", event => {
|
||||||
|
if (event.target.href) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
location = link;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,74 +1,66 @@
|
||||||
//
|
/**
|
||||||
// pride flag - copyright (c) 2024 ari melody
|
* 🏳️🌈🏳️⚧️💖 pride flag 💖🏳️⚧️🏳️🌈
|
||||||
//
|
* made with ❤️ by ari melody, 2023
|
||||||
// this code is provided AS-IS, WITHOUT ANY WARRANTY, to be
|
*
|
||||||
// freely redistributed and/or modified as you please, however
|
* web: https://arimelody.me
|
||||||
// retaining this license in any redistribution.
|
* source: https://github.com/mellodoot/prideflag
|
||||||
//
|
*/
|
||||||
// please use this flag to link to an LGBTQI+-supporting page
|
|
||||||
// of your choosing!
|
|
||||||
//
|
|
||||||
// web: https://arimelody.me
|
|
||||||
// source: https://git.arimelody.me/ari/prideflag
|
|
||||||
//
|
|
||||||
|
|
||||||
const pride_url = "https://git.arimelody.me/ari/prideflag";
|
|
||||||
|
|
||||||
const pride_flag_svg =
|
const pride_flag_svg =
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
|
||||||
<path id="red" d="M120,80 L100,100 L120,120 Z" style="fill:#d20605"/>
|
<path id="red" d="M120,80 L100,100 L120,120 Z" style="fill:#d20605"/>
|
||||||
<path id="orange" d="M120,80 V40 L80,80 L100,100 Z" style="fill:#ef9c00"/>
|
<path id="orange" d="M120,80 V40 L80,80 L100,100 Z" style="fill:#ef9c00"/>
|
||||||
<path id="yellow" d="M120,40 V0 L60,60 L80,80 Z" style="fill:#e5fe02"/>
|
<path id="yellow" d="M120,40 V0 L60,60 L80,80 Z" style="fill:#e5fe02"/>
|
||||||
<path id="green" d="M120,0 H80 L40,40 L60,60 Z" style="fill:#09be01"/>
|
<path id="green" d="M120,0 H80 L40,40 L60,60 Z" style="fill:#09be01"/>
|
||||||
<path id="blue" d="M80,0 H40 L20,20 L40,40 Z" style="fill:#081a9a"/>
|
<path id="blue" d="M80,0 H40 L20,20 L40,40 Z" style="fill:#081a9a"/>
|
||||||
<path id="purple" d="M40,0 H0 L20,20 Z" style="fill:#76008a"/>
|
<path id="purple" d="M40,0 H0 L20,20 Z" style="fill:#76008a"/>
|
||||||
|
|
||||||
<rect id="black" x="60" width="60" height="60" style="fill:#010101"/>
|
<rect id="black" x="60" width="60" height="60" style="fill:#010101"/>
|
||||||
<rect id="brown" x="70" width="50" height="50" style="fill:#603814"/>
|
<rect id="brown" x="70" width="50" height="50" style="fill:#603814"/>
|
||||||
<rect id="lightblue" x="80" width="40" height="40" style="fill:#73d6ed"/>
|
<rect id="lightblue" x="80" width="40" height="40" style="fill:#73d6ed"/>
|
||||||
<rect id="pink" x="90" width="30" height="30" style="fill:#ffafc8"/>
|
<rect id="pink" x="90" width="30" height="30" style="fill:#ffafc8"/>
|
||||||
<rect id="white" x="100" width="20" height="20" style="fill:#fff"/>
|
<rect id="white" x="100" width="20" height="20" style="fill:#fff"/>
|
||||||
|
|
||||||
<rect id="intyellow" x="110" width="10" height="10" style="fill:#fed800"/>
|
<rect id="intyellow" x="110" width="10" height="10" style="fill:#fed800"/>
|
||||||
<circle id="intpurple" cx="120" cy="0" r="5" stroke="#7601ad" stroke-width="2" fill="none"/>
|
<circle id="intpurple" cx="120" cy="0" r="5" stroke="#7601ad" stroke-width="2" fill="none"/>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
|
|
||||||
const pride_flag_css =
|
const pride_flag_css =
|
||||||
`#prideflag {
|
`#pride-flag svg {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: 120px;
|
width: 120px;
|
||||||
transform-origin: 100% 0%;
|
transform-origin: 100% 0%;
|
||||||
transition: transform .5s cubic-bezier(.32,1.63,.41,1.01);
|
transition: transform .5s cubic-bezier(.32,1.63,.41,1.01);
|
||||||
z-index: 8008135;
|
z-index: 8008135;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
#prideflag:hover {
|
#pride-flag svg:hover {
|
||||||
transform: scale(110%);
|
transform: scale(110%);
|
||||||
}
|
}
|
||||||
#prideflag:active {
|
#pride-flag svg:active {
|
||||||
transform: scale(110%);
|
transform: scale(110%);
|
||||||
}
|
}
|
||||||
#prideflag * {
|
#pride-flag svg * {
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
function create_pride_flag() {
|
function create_pride_flag() {
|
||||||
const flag = document.createElement("a");
|
const container = document.createElement("a");
|
||||||
flag.id = "prideflag";
|
container.id = "pride-flag";
|
||||||
flag.href = pride_url;
|
container.href = "https://github.com/mellodoot/prideflag";
|
||||||
flag.target = "_blank";
|
container.target = "_blank";
|
||||||
flag.innerHTML = pride_flag_svg;
|
container.innerHTML = pride_flag_svg;
|
||||||
return flag;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
function load_pride_flag_style() {
|
function load_pride_flag_style() {
|
||||||
const pride_stylesheet = document.createElement('style');
|
const pride_stylesheet = document.createElement('style');
|
||||||
pride_stylesheet.textContent = pride_flag_css;
|
pride_stylesheet.textContent = pride_flag_css;
|
||||||
document.head.appendChild(pride_stylesheet);
|
document.head.appendChild(pride_stylesheet);
|
||||||
}
|
}
|
||||||
|
|
||||||
load_pride_flag_style();
|
load_pride_flag_style();
|
||||||
flag = create_pride_flag();
|
pride_flag = create_pride_flag();
|
||||||
document.body.appendChild(flag);
|
document.querySelector("main").appendChild(pride_flag);
|
||||||
|
|
|
@ -6,7 +6,6 @@
|
||||||
--secondary: #f8e05b;
|
--secondary: #f8e05b;
|
||||||
--tertiary: #f788fe;
|
--tertiary: #f788fe;
|
||||||
--links: #5eb2ff;
|
--links: #5eb2ff;
|
||||||
--live: #fd3737;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
@media (prefers-color-scheme: light) {
|
||||||
|
|
|
@ -1,9 +0,0 @@
|
||||||
canvas#cursors {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
|
@ -25,6 +25,7 @@ nav {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: .5em;
|
gap: .5em;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
img#header-icon {
|
img#header-icon {
|
||||||
|
@ -35,19 +36,19 @@ img#header-icon {
|
||||||
}
|
}
|
||||||
|
|
||||||
#header-text {
|
#header-text {
|
||||||
|
width: 11em;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#header-text h1 {
|
#header-text h1 {
|
||||||
width: fit-content;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
#header-text h2 {
|
#header-text h2 {
|
||||||
width: fit-content;
|
|
||||||
height: 1.2em;
|
height: 1.2em;
|
||||||
line-height: 1.2em;
|
line-height: 1.2em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
@ -153,7 +154,7 @@ header ul li a:hover {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
border-bottom: 1px solid #888;
|
border-bottom: 1px solid #888;
|
||||||
background: var(--background);
|
background: #080808;
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -96,36 +96,30 @@ hr {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.platform-links {
|
ul.links {
|
||||||
padding-left: 1em;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: .5em;
|
gap: 1em .5em;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.platform-links li {
|
ul.links li {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.platform-links li a {
|
ul.links li a {
|
||||||
padding: .4em .5em;
|
padding: .4em .5em;
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
gap: .5em;
|
|
||||||
border: 1px solid var(--links);
|
border: 1px solid var(--links);
|
||||||
color: var(--links);
|
color: var(--links);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
transition-property: color, border-color, background-color, box-shadow;
|
transition-property: color, border-color, background-color;
|
||||||
transition-duration: .2s;
|
transition-duration: .2s;
|
||||||
animation-delay: 0s;
|
animation-delay: 0s;
|
||||||
animation: list-item-fadein .2s forwards;
|
animation: list-item-fadein .2s forwards;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.platform-links li a:hover {
|
ul.links li a:hover {
|
||||||
color: #eee;
|
color: #eee;
|
||||||
border-color: #eee;
|
border-color: #eee;
|
||||||
background-color: var(--links) !important;
|
background-color: var(--links) !important;
|
||||||
|
@ -133,75 +127,6 @@ ul.platform-links li a:hover {
|
||||||
box-shadow: 0 0 1em var(--links);
|
box-shadow: 0 0 1em var(--links);
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.platform-links li a img {
|
|
||||||
height: 1em;
|
|
||||||
width: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul#projects {
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
li.project-item {
|
|
||||||
padding: .5em;
|
|
||||||
border: 1px solid var(--links);
|
|
||||||
margin: 1em 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: .5em;
|
|
||||||
border-radius: 2px;
|
|
||||||
transition-property: color, border-color, background-color, box-shadow;
|
|
||||||
transition-duration: .2s;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
li.project-item a {
|
|
||||||
transition: color .2s linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
li.project-item:hover {
|
|
||||||
color: #eee;
|
|
||||||
border-color: #eee;
|
|
||||||
background-color: var(--links) !important;
|
|
||||||
text-decoration: none;
|
|
||||||
box-shadow: 0 0 1em var(--links);
|
|
||||||
}
|
|
||||||
li.project-item:hover a {
|
|
||||||
color: #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
li.project-item .project-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
li.project-item img.project-icon {
|
|
||||||
width: 2.5em;
|
|
||||||
height: 2.5em;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
li.project-item span.project-icon {
|
|
||||||
font-size: 2em;
|
|
||||||
display: block;
|
|
||||||
width: 45px;
|
|
||||||
height: 45px;
|
|
||||||
text-align: center;
|
|
||||||
/* background: #0004; */
|
|
||||||
/* border: 1px solid var(--on-background); */
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
li.project-item a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
li.project-item p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
div#web-buttons {
|
div#web-buttons {
|
||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
}
|
}
|
||||||
|
@ -223,112 +148,3 @@ div#web-buttons {
|
||||||
box-shadow: 1px 1px 0 #eee, 2px 2px 0 #eee;
|
box-shadow: 1px 1px 0 #eee, 2px 2px 0 #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
#live-banner {
|
|
||||||
margin: 1em 0 2em 0;
|
|
||||||
padding: 1em;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid var(--primary);
|
|
||||||
box-shadow: 0 0 8px var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
#live-banner p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-highlight {
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-preview {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: start;
|
|
||||||
gap: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-preview div:first-of-type {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: .3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-thumbnail {
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-button {
|
|
||||||
margin: .2em;
|
|
||||||
padding: .4em .5em;
|
|
||||||
display: inline-block;
|
|
||||||
color: var(--primary);
|
|
||||||
border: 1px solid var(--primary);
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: color .1s linear, background-color .1s linear, box-shadow .1s linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-button:hover {
|
|
||||||
color: var(--background);
|
|
||||||
background-color: var(--primary);
|
|
||||||
box-shadow: 0 0 8px var(--primary);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: .3em;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
#live-banner h2 {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--on-background);
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
font-weight: 800;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-pinger {
|
|
||||||
width: .5em;
|
|
||||||
height: .5em;
|
|
||||||
margin: .1em .2em;
|
|
||||||
display: inline-block;
|
|
||||||
border-radius: 100%;
|
|
||||||
background-color: var(--primary);
|
|
||||||
box-shadow: 0 0 4px var(--primary);
|
|
||||||
animation: live-pinger-pulse 1s infinite alternate ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes live-pinger-pulse {
|
|
||||||
from {
|
|
||||||
opacity: .8;
|
|
||||||
transform: scale(1.0);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-game {
|
|
||||||
overflow: hidden;
|
|
||||||
text-wrap: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-game .live-game-prefix {
|
|
||||||
opacity: .8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-title {
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-viewers {
|
|
||||||
opacity: .5;
|
|
||||||
}
|
|
||||||
|
|
|
@ -2,8 +2,6 @@
|
||||||
@import url("/style/header.css");
|
@import url("/style/header.css");
|
||||||
@import url("/style/footer.css");
|
@import url("/style/footer.css");
|
||||||
@import url("/style/prideflag.css");
|
@import url("/style/prideflag.css");
|
||||||
@import url("/style/cursor.css");
|
|
||||||
@import url("/font/inter/inter.css");
|
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Monaspace Argon";
|
font-family: "Monaspace Argon";
|
||||||
|
|
|
@ -613,10 +613,6 @@ footer a:hover {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#tracks h2 {
|
|
||||||
margin: 0 auto .8em auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#lyrics p.album-track-subheading {
|
#lyrics p.album-track-subheading {
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
-- Audit logs
|
|
||||||
CREATE TABLE arimelody.auditlog (
|
|
||||||
id UUID DEFAULT gen_random_uuid(),
|
|
||||||
level int NOT NULL DEFAULT 0,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Need moar timestamps
|
|
||||||
ALTER TABLE arimelody.musicrelease ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT current_timestamp;
|
|
||||||
ALTER TABLE arimelody.account ALTER COLUMN created_at SET NOT NULL;
|
|
|
@ -1,3 +0,0 @@
|
||||||
-- it would be nice to prevent brute-forcing
|
|
||||||
ALTER TABLE arimelody.account ADD COLUMN fail_attempts INT NOT NULL DEFAULT 0;
|
|
||||||
ALTER TABLE arimelody.account ADD COLUMN locked BOOLEAN DEFAULT false;
|
|
|
@ -2,15 +2,6 @@
|
||||||
-- Tables
|
-- Tables
|
||||||
--
|
--
|
||||||
|
|
||||||
-- Audit logs
|
|
||||||
CREATE TABLE arimelody.auditlog (
|
|
||||||
id UUID DEFAULT gen_random_uuid(),
|
|
||||||
level int NOT NULL DEFAULT 0,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Accounts
|
-- Accounts
|
||||||
CREATE TABLE arimelody.account (
|
CREATE TABLE arimelody.account (
|
||||||
id UUID DEFAULT gen_random_uuid(),
|
id UUID DEFAULT gen_random_uuid(),
|
||||||
|
@ -18,9 +9,7 @@ CREATE TABLE arimelody.account (
|
||||||
password TEXT NOT NULL,
|
password TEXT NOT NULL,
|
||||||
email TEXT,
|
email TEXT,
|
||||||
avatar_url TEXT,
|
avatar_url TEXT,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
|
created_at TIMESTAMP DEFAULT current_timestamp
|
||||||
fail_attempts INT NOT NULL DEFAULT 0,
|
|
||||||
locked BOOLEAN DEFAULT false
|
|
||||||
);
|
);
|
||||||
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
|
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
|
||||||
|
|
||||||
|
@ -34,31 +23,29 @@ ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account
|
||||||
-- Invites
|
-- Invites
|
||||||
CREATE TABLE arimelody.invite (
|
CREATE TABLE arimelody.invite (
|
||||||
code text NOT NULL,
|
code text NOT NULL,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
expires_at TIMESTAMP NOT NULL
|
expires_at TIMESTAMP NOT NULL
|
||||||
);
|
);
|
||||||
ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code);
|
ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code);
|
||||||
|
|
||||||
-- Sessions
|
-- Session
|
||||||
CREATE TABLE arimelody.session (
|
CREATE TABLE arimelody.session (
|
||||||
token TEXT,
|
token TEXT,
|
||||||
user_agent TEXT NOT NULL,
|
user_agent TEXT NOT NULL,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
|
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
|
||||||
expires_at TIMESTAMP DEFAULT NULL,
|
expires_at TIMESTAMP DEFAULT NULL,
|
||||||
account UUID,
|
account UUID,
|
||||||
attempt_account UUID,
|
|
||||||
message TEXT,
|
message TEXT,
|
||||||
error TEXT
|
error TEXT
|
||||||
);
|
);
|
||||||
ALTER TABLE arimelody.session ADD CONSTRAINT session_pk PRIMARY KEY (token);
|
ALTER TABLE arimelody.session ADD CONSTRAINT session_pk PRIMARY KEY (token);
|
||||||
|
|
||||||
-- TOTP methods
|
-- TOTPs
|
||||||
CREATE TABLE arimelody.totp (
|
CREATE TABLE arimelody.totp (
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
account UUID NOT NULL,
|
account UUID NOT NULL,
|
||||||
secret TEXT,
|
secret TEXT,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
|
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
|
||||||
confirmed BOOLEAN DEFAULT false
|
|
||||||
);
|
);
|
||||||
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
|
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
|
||||||
|
|
||||||
|
@ -85,8 +72,7 @@ CREATE TABLE arimelody.musicrelease (
|
||||||
buyname text,
|
buyname text,
|
||||||
buylink text,
|
buylink text,
|
||||||
copyright text,
|
copyright text,
|
||||||
copyrightURL text,
|
copyrightURL text
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
|
|
||||||
);
|
);
|
||||||
ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id);
|
ALTER TABLE arimelody.musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id);
|
||||||
|
|
||||||
|
@ -133,7 +119,6 @@ ALTER TABLE arimelody.musicreleasetrack ADD CONSTRAINT musicreleasetrack_pk PRIM
|
||||||
|
|
||||||
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
||||||
ALTER TABLE arimelody.session ADD CONSTRAINT session_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
ALTER TABLE arimelody.session ADD CONSTRAINT session_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
||||||
ALTER TABLE arimelody.session ADD CONSTRAINT session_attempt_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
|
||||||
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE arimelody.musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -23,36 +23,33 @@ ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_pk PRIMARY KEY (account
|
||||||
-- Invites
|
-- Invites
|
||||||
CREATE TABLE arimelody.invite (
|
CREATE TABLE arimelody.invite (
|
||||||
code text NOT NULL,
|
code text NOT NULL,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
expires_at TIMESTAMP NOT NULL
|
expires_at TIMESTAMP NOT NULL
|
||||||
);
|
);
|
||||||
ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code);
|
ALTER TABLE arimelody.invite ADD CONSTRAINT invite_pk PRIMARY KEY (code);
|
||||||
|
|
||||||
-- Sessions
|
-- Session
|
||||||
CREATE TABLE arimelody.session (
|
CREATE TABLE arimelody.session (
|
||||||
token TEXT,
|
token TEXT,
|
||||||
user_agent TEXT NOT NULL,
|
user_agent TEXT NOT NULL,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
|
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
|
||||||
expires_at TIMESTAMP DEFAULT NULL,
|
expires_at TIMESTAMP DEFAULT NULL,
|
||||||
account UUID,
|
account UUID,
|
||||||
attempt_account UUID,
|
|
||||||
message TEXT,
|
message TEXT,
|
||||||
error TEXT
|
error TEXT
|
||||||
);
|
);
|
||||||
ALTER TABLE arimelody.session ADD CONSTRAINT session_pk PRIMARY KEY (token);
|
ALTER TABLE arimelody.session ADD CONSTRAINT session_pk PRIMARY KEY (token);
|
||||||
|
|
||||||
-- TOTP methods
|
-- TOTPs
|
||||||
CREATE TABLE arimelody.totp (
|
CREATE TABLE arimelody.totp (
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
account UUID NOT NULL,
|
account UUID NOT NULL,
|
||||||
secret TEXT,
|
secret TEXT,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp,
|
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
|
||||||
confirmed BOOLEAN DEFAULT false
|
|
||||||
);
|
);
|
||||||
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
|
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_pk PRIMARY KEY (account, name);
|
||||||
|
|
||||||
-- Foreign keys
|
-- Foreign keys
|
||||||
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
ALTER TABLE arimelody.privilege ADD CONSTRAINT privilege_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
||||||
ALTER TABLE arimelody.session ADD CONSTRAINT session_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
ALTER TABLE arimelody.token ADD CONSTRAINT token_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
||||||
ALTER TABLE arimelody.session ADD CONSTRAINT session_attempt_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
|
||||||
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
ALTER TABLE arimelody.totp ADD CONSTRAINT totp_account_fk FOREIGN KEY (account) REFERENCES account(id) ON DELETE CASCADE;
|
|
@ -1,28 +1,33 @@
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
var IndexTemplate = template.Must(template.ParseFiles(
|
var Pages = map[string]*template.Template{
|
||||||
filepath.Join("views", "layout.html"),
|
"index": template.Must(template.ParseFiles(
|
||||||
filepath.Join("views", "header.html"),
|
filepath.Join("views", "layout.html"),
|
||||||
filepath.Join("views", "footer.html"),
|
filepath.Join("views", "header.html"),
|
||||||
filepath.Join("views", "prideflag.html"),
|
filepath.Join("views", "footer.html"),
|
||||||
filepath.Join("views", "index.html"),
|
filepath.Join("views", "prideflag.html"),
|
||||||
))
|
filepath.Join("views", "index.html"),
|
||||||
var MusicTemplate = template.Must(template.ParseFiles(
|
)),
|
||||||
filepath.Join("views", "layout.html"),
|
"music": template.Must(template.ParseFiles(
|
||||||
filepath.Join("views", "header.html"),
|
filepath.Join("views", "layout.html"),
|
||||||
filepath.Join("views", "footer.html"),
|
filepath.Join("views", "header.html"),
|
||||||
filepath.Join("views", "prideflag.html"),
|
filepath.Join("views", "footer.html"),
|
||||||
filepath.Join("views", "music.html"),
|
filepath.Join("views", "prideflag.html"),
|
||||||
))
|
filepath.Join("views", "music.html"),
|
||||||
var MusicGatewayTemplate = template.Must(template.ParseFiles(
|
)),
|
||||||
filepath.Join("views", "layout.html"),
|
"music-gateway": template.Must(template.ParseFiles(
|
||||||
filepath.Join("views", "header.html"),
|
filepath.Join("views", "layout.html"),
|
||||||
filepath.Join("views", "footer.html"),
|
filepath.Join("views", "header.html"),
|
||||||
filepath.Join("views", "prideflag.html"),
|
filepath.Join("views", "footer.html"),
|
||||||
filepath.Join("views", "music-gateway.html"),
|
filepath.Join("views", "prideflag.html"),
|
||||||
))
|
filepath.Join("views", "music-gateway.html"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
|
||||||
|
var Components = map[string]*template.Template{
|
||||||
|
}
|
||||||
|
|
|
@ -1,45 +0,0 @@
|
||||||
package view
|
|
||||||
|
|
||||||
import (
|
|
||||||
"arimelody-web/controller"
|
|
||||||
"arimelody-web/model"
|
|
||||||
"arimelody-web/templates"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
func IndexHandler(app *model.AppState) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method == http.MethodHead {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type IndexData struct {
|
|
||||||
TwitchStatus *model.TwitchStreamInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
var err error
|
|
||||||
var twitchStatus *model.TwitchStreamInfo = nil
|
|
||||||
if app.Twitch != nil && len(app.Config.Twitch.Broadcaster) > 0 {
|
|
||||||
twitchStatus, err = controller.GetTwitchStatus(app, app.Config.Twitch.Broadcaster)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to get Twitch status for %s: %v\n", app.Config.Twitch.Broadcaster, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
|
||||||
err := templates.IndexTemplate.Execute(w, IndexData{
|
|
||||||
TwitchStatus: twitchStatus,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to render index page: %v\n", err)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
StaticHandler("public").ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,13 +1,12 @@
|
||||||
package view
|
package view
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
|
|
||||||
"arimelody-web/controller"
|
"arimelody-web/controller"
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
"arimelody-web/templates"
|
"arimelody-web/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTTP HANDLER METHODS
|
// HTTP HANDLER METHODS
|
||||||
|
@ -37,7 +36,7 @@ func ServeCatalog(app *model.AppState) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
releases, err := controller.GetAllReleases(app.DB, true, 0, true)
|
releases, err := controller.GetAllReleases(app.DB, true, 0, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("WARN: Failed to pull releases for catalog: %s\n", err)
|
fmt.Printf("FATAL: Failed to pull releases for catalog: %s\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -48,7 +47,7 @@ func ServeCatalog(app *model.AppState) http.Handler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = templates.MusicTemplate.Execute(w, releases)
|
err = templates.Pages["music"].Execute(w, releases)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
@ -60,13 +59,7 @@ func ServeGateway(app *model.AppState, release *model.Release) http.Handler {
|
||||||
// only allow authorised users to view hidden releases
|
// only allow authorised users to view hidden releases
|
||||||
privileged := false
|
privileged := false
|
||||||
if !release.Visible {
|
if !release.Visible {
|
||||||
session, err := controller.GetSessionFromRequest(app, r)
|
session := r.Context().Value("session").(*model.Session)
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
|
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if session != nil && session.Account != nil {
|
if session != nil && session.Account != nil {
|
||||||
// TODO: check privilege on release
|
// TODO: check privilege on release
|
||||||
privileged = true
|
privileged = true
|
||||||
|
@ -86,7 +79,7 @@ func ServeGateway(app *model.AppState, release *model.Release) http.Handler {
|
||||||
response.Links = release.Links
|
response.Links = release.Links
|
||||||
}
|
}
|
||||||
|
|
||||||
err := templates.MusicGatewayTemplate.Execute(w, response)
|
err := templates.Pages["music-gateway"].Execute(w, response)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error rendering music gateway for %s: %s\n", release.ID, err)
|
fmt.Printf("Error rendering music gateway for %s: %s\n", release.ID, err)
|
||||||
|
|
|
@ -1,31 +0,0 @@
|
||||||
package view
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
func StaticHandler(directory string) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
info, err := os.Stat(filepath.Join(directory, filepath.Clean(r.URL.Path)))
|
|
||||||
|
|
||||||
// does the file exist?
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// is thjs a directory? (forbidden)
|
|
||||||
if info.IsDir() {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.FileServer(http.Dir(directory)).ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
@ -6,16 +6,16 @@
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<main>
|
<main>
|
||||||
<h1>
|
<h1>
|
||||||
# 404 - not found!
|
# 404 - not found!
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
the page you're looking for does not exist.
|
the page you're looking for does not exist.
|
||||||
<br>
|
<br>
|
||||||
if you like, you can head back <a href="/">home</a> or <a href="{{.Target}}">try again!</a>
|
if you like, you can head back <a href="/">home</a> or <a href="{{.Target}}">try again!</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p><small>status: ERR_NOT_FOUND</small></p>
|
<p><small>status: ERR_NOT_FOUND</small></p>
|
||||||
</main>
|
</main>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
@ -1,14 +1,9 @@
|
||||||
{{define "footer"}}
|
{{define "footer"}}
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div id="footer">
|
<div id="footer">
|
||||||
<small>
|
<small><em>*made with ♥ by ari, 2024*</em></small>
|
||||||
<em>
|
</div>
|
||||||
*made with <span aria-label="love">♥</span> by ari, 2025*
|
|
||||||
<a href="https://git.arimelody.me/ari/arimelody.me" target="_blank">source</a>
|
|
||||||
</em>
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
@ -1,41 +1,44 @@
|
||||||
{{define "header"}}
|
{{define "header"}}
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<nav>
|
<nav>
|
||||||
<div id="header-home">
|
<div id="header-home">
|
||||||
<img src="/img/favicon.png" id="header-icon" width="100" height="100" alt="">
|
<img src="/img/favicon.png" id="header-icon" width="100" height="100" alt="">
|
||||||
<div id="header-text">
|
<div id="header-text">
|
||||||
<h1>ari melody</h1>
|
<h1>ari melody</h1>
|
||||||
<h2>your local SPACEGIRL 💫</h2>
|
<h2>your local SPACEGIRL 💫</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a id="header-links-toggle">
|
<a id="header-links-toggle">
|
||||||
<svg viewBox="0 0 70 50" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
|
<svg viewBox="0 0 70 50" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
|
||||||
<rect y="00" width="70" height="10" rx="5" fill="#eee" />
|
<rect y="00" width="70" height="10" rx="5" fill="#eee" />
|
||||||
<rect y="20" width="70" height="10" rx="5" fill="#eee" />
|
<rect y="20" width="70" height="10" rx="5" fill="#eee" />
|
||||||
<rect y="40" width="70" height="10" rx="5" fill="#eee" />
|
<rect y="40" width="70" height="10" rx="5" fill="#eee" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<ul id="header-links">
|
<ul id="header-links">
|
||||||
<li>
|
<li>
|
||||||
<a href="/" preload="mouseover">home</a>
|
<a href="/" preload="mouseover">home</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/music" preload="mouseover">music</a>
|
<a href="/music" preload="mouseover">music</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<!-- coming later! -->
|
<a href="https://git.arimelody.me/ari/arimelody.me" target="_blank">source</a>
|
||||||
<span title="coming later!">blog</span>
|
</li>
|
||||||
</li>
|
<li>
|
||||||
<li>
|
<!-- coming later! -->
|
||||||
<!-- coming later! -->
|
<span title="coming later!">blog</span>
|
||||||
<span title="coming later!">art</span>
|
</li>
|
||||||
</li>
|
<li>
|
||||||
<li id="toggle-crt">
|
<!-- coming later! -->
|
||||||
<a href="javascript:void(0)">crt</a>
|
<span title="coming later!">art</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
<li id="toggle-crt">
|
||||||
</nav>
|
<a href="javascript:void(0)">crt</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
168
views/index.html
|
@ -6,8 +6,8 @@
|
||||||
|
|
||||||
<meta property="og:title" content="ari melody">
|
<meta property="og:title" content="ari melody">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:url" content="www.arimelody.space">
|
<meta property="og:url" content="www.arimelody.me">
|
||||||
<meta property="og:image" content="https://www.arimelody.space/img/favicon.png">
|
<meta property="og:image" content="https://www.arimelody.me/img/favicon.png">
|
||||||
<meta property="og:site_name" content="ari melody">
|
<meta property="og:site_name" content="ari melody">
|
||||||
<meta property="og:description" content="home to your local SPACEGIRL 💫">
|
<meta property="og:description" content="home to your local SPACEGIRL 💫">
|
||||||
|
|
||||||
|
@ -17,28 +17,11 @@
|
||||||
<link rel="me" href="https://ice.arimelody.me/@ari">
|
<link rel="me" href="https://ice.arimelody.me/@ari">
|
||||||
<link rel="me" href="https://wetdry.world/@ari">
|
<link rel="me" href="https://wetdry.world/@ari">
|
||||||
|
|
||||||
<script type="module" src="/script/index.js" defer></script>
|
<script type="module" src="/script/index.js" defer> </script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<main>
|
<main>
|
||||||
{{if .TwitchStatus}}
|
|
||||||
<div id="live-banner">
|
|
||||||
<div class="live-preview">
|
|
||||||
<div>
|
|
||||||
<img src="{{.TwitchStatus.Thumbnail 144 81}}" alt="livestream thumbnail" class="live-thumbnail">
|
|
||||||
<a href="https://twitch.tv/{{.TwitchStatus.UserName}}" class="live-button">join in!</a>
|
|
||||||
</div>
|
|
||||||
<div class="live-info">
|
|
||||||
<h2>ari melody <span class="live-highlight">LIVE</span> <i class="live-pinger"></i></h2>
|
|
||||||
<p class="live-game"><span class="live-game-prefix">streaming:</span> {{.TwitchStatus.GameName}}</p>
|
|
||||||
<p class="live-title">{{.TwitchStatus.Title}}</p>
|
|
||||||
<p class="live-viewers">{{.TwitchStatus.ViewerCount}} viewers</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<h1 class="typeout">
|
<h1 class="typeout">
|
||||||
# hello, world!
|
# hello, world!
|
||||||
</h1>
|
</h1>
|
||||||
|
@ -50,7 +33,7 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
i'm a <a href="/music">musician</a>, <a href="https://codeberg.org/arimelody?tab=repositories">developer</a>,
|
i'm a <a href="/music">musician</a>, <a href="https://github.com/arimelody?tab=repositories">developer</a>,
|
||||||
<a href="https://twitch.tv/arispacegirl">streamer</a>, <a href="https://youtube.com/@arispacegirl">youtuber</a>,
|
<a href="https://twitch.tv/arispacegirl">streamer</a>, <a href="https://youtube.com/@arispacegirl">youtuber</a>,
|
||||||
and probably a bunch of other things i forgot to mention!
|
and probably a bunch of other things i forgot to mention!
|
||||||
</p>
|
</p>
|
||||||
|
@ -61,7 +44,7 @@
|
||||||
<p>
|
<p>
|
||||||
if you're looking to support me financially, that's so cool of you!!
|
if you're looking to support me financially, that's so cool of you!!
|
||||||
if you like, you can buy some of my music over on
|
if you like, you can buy some of my music over on
|
||||||
<a href="https://arimelody.bandcamp.com">bandcamp</a>
|
<a href="https://arimelody.bandcamp.com" target="_blank">bandcamp</a>
|
||||||
so you can at least get something for your money.
|
so you can at least get something for your money.
|
||||||
thank you very much either way!! 💕
|
thank you very much either way!! 💕
|
||||||
</p>
|
</p>
|
||||||
|
@ -101,97 +84,57 @@
|
||||||
<p>
|
<p>
|
||||||
<strong>where to find me 🛰️</strong>
|
<strong>where to find me 🛰️</strong>
|
||||||
</p>
|
</p>
|
||||||
<ul class="platform-links">
|
<ul class="links">
|
||||||
<li>
|
<li>
|
||||||
<a href="https://youtube.com/@arispacegirl" title="youtube">
|
<a href="https://youtube.com/@arispacegirl" target="_blank">youtube</a>
|
||||||
<img src="/img/brand/youtube.svg" alt="youtube" width="32" height="32"/>
|
|
||||||
youtube
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="https://twitch.tv/arispacegirl" title="twitch">
|
<a href="https://twitch.tv/arispacegirl" target="_blank">twitch</a>
|
||||||
<img src="/img/brand/twitch.svg" alt="twitch" width="32" height="32"/>
|
|
||||||
twitch
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="https://arimelody.bandcamp.com" title="bandcamp">
|
<a href="https://sptfy.com/mellodoot" target="_blank">spotify</a>
|
||||||
<img src="/img/brand/bandcamp.svg" alt="bandcamp" width="32" height="32"/>
|
|
||||||
bandcamp
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="https://codeberg.org/arimelody" title="codeberg">
|
<a href="https://arimelody.bandcamp.com" target="_blank">bandcamp</a>
|
||||||
<img src="/img/brand/codeberg.svg" alt="codeberg" width="32" height="32"/>
|
|
||||||
codeberg
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="https://bsky.app/profile/arimelody.space" title="bluesky">
|
<a href="https://github.com/arimelody" target="_blank">github</a>
|
||||||
<img src="/img/brand/bluesky.svg" alt="bluesky" width="32" height="32"/>
|
|
||||||
bluesky
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://arimelody.space/discord" title="discord">
|
|
||||||
<img src="/img/brand/discord.svg" alt="discord" width="32" height="32"/>
|
|
||||||
discord
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<strong>projects i've worked on 🛠️</strong>
|
<strong>projects i've worked on 🛠️</strong>
|
||||||
</p>
|
</p>
|
||||||
<ul id="projects">
|
<ul class="links">
|
||||||
<li class="project-item">
|
<li>
|
||||||
<span aria-hidden=true class="project-icon">⛏️</span>
|
<a href="https://catdance.arimelody.me" target="_blank">
|
||||||
<div class="project-info">
|
catdance
|
||||||
<a href="https://mcq.bliss.town">McStatusFace</a>
|
</a>
|
||||||
<p>minecraft server query utility</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
<li class="project-item">
|
<li>
|
||||||
<img src="https://catdance.arimelody.me/img/favicon.png" alt="catdance icon" aria-hidden=true class="project-icon" width="64" height="64">
|
<a href="https://git.arimelody.me/ari/prideflag" target="_blank">
|
||||||
<div class="project-info">
|
pride flag
|
||||||
<a href="https://catdance.arimelody.me">catdance</a>
|
</a>
|
||||||
<p>watch the cat dance 🐱</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
<li class="project-item">
|
<li>
|
||||||
<img src="https://git.arimelody.me/repo-avatars/6b0a1ffb78cbc6f906f83152ea42a710220174e8f48a3e44f159ae58dacd7a2f" alt="pride flag icon" aria-hidden=true class="project-icon" width="64" height="64">
|
<a href="https://github.com/arimelody/ipaddrgen" target="_blank">
|
||||||
<div class="project-info">
|
ipaddrgen
|
||||||
<a href="https://git.arimelody.me/ari/prideflag">pride flag</a>
|
</a>
|
||||||
<p>progressive pride flag widget for websites</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
<li class="project-item">
|
<li>
|
||||||
<span aria-hidden=true class="project-icon">👩💻</span>
|
<a href="https://impact.arimelody.me/" target="_blank">
|
||||||
<div class="project-info">
|
impact meme
|
||||||
<a href="https://github.com/arimelody/ipaddrgen">ipaddrgen</a>
|
</a>
|
||||||
<p>silly hackerman IP address generator</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
<li class="project-item">
|
<li>
|
||||||
<img src="https://impact.arimelody.me/favicon.png" alt="impact meme icon" aria-hidden=true class="project-icon" width="64" height="64">
|
<a href="https://term.arimelody.me/" target="_blank">
|
||||||
<div class="project-info">
|
OpenTerminal
|
||||||
<a href="https://impact.arimelody.me/">impact meme</a>
|
</a>
|
||||||
<p>impact meme generator</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
<li class="project-item">
|
<li>
|
||||||
<img src="https://codeberg.org/repo-avatars/e67303eeda4fa6d268948e71b7b0837357d8c519772701ffc36b84ae7975319f" alt="OpenTerminal icon" aria-hidden=true class="project-icon" width="64" height="64">
|
<a href="https://silver.bliss.town/" target="_blank">
|
||||||
<div class="project-info">
|
Silver.js
|
||||||
<a href="https://term.arimelody.me/">OpenTerminal</a>
|
</a>
|
||||||
<p>communal online text buffer</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li class="project-item">
|
|
||||||
<span aria-hidden=true class="project-icon">📜</span>
|
|
||||||
<div class="project-info">
|
|
||||||
<a href="https://silver.bliss.town/">Silver.js</a>
|
|
||||||
<p>lightweight reactive state library</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
@ -202,48 +145,45 @@
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div id="web-buttons">
|
<div id="web-buttons">
|
||||||
<a href="https://arimelody.space">
|
<a href="https://arimelody.me">
|
||||||
<img src="/img/buttons/ari melody.gif" alt="ari melody web button" width="88" height="31">
|
<img src="/img/buttons/ari melody.gif" alt="ari melody web button" width="88" height="31">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://supitszaire.com">
|
<a href="https://supitszaire.com" target="_blank">
|
||||||
<img src="/img/buttons/zaire.gif" alt="zaire web button" width="88" height="31">
|
<img src="/img/buttons/zaire.gif" alt="zaire web button" width="88" height="31">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://mae.wtf">
|
<a href="https://mae.wtf" target="_blank">
|
||||||
<img src="/img/buttons/mae.png" alt="vimae web button" width="88" height="31">
|
<img src="/img/buttons/mae.png" alt="vimae web button" width="88" height="31">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://girlthi.ng/~thermia/">
|
<a href="https://zvava.org" target="_blank">
|
||||||
<img src="/img/buttons/thermia.gif" alt="thermia web button" width="88" height="31">
|
<img src="/img/buttons/zvava.png" alt="zvava web button" width="88" height="31">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://elke.cafe">
|
<a href="https://elke.cafe" target="_blank">
|
||||||
<img src="/img/buttons/elke.gif" alt="elke web button" width="88" height="31">
|
<img src="/img/buttons/elke.gif" alt="elke web button" width="88" height="31">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://invoxiplaygames.uk/">
|
<a href="https://invoxiplaygames.uk/" target="_blank">
|
||||||
<img src="/img/buttons/ipg.png" alt="InvoxiPlayGames web button" width="88" height="31">
|
<img src="/img/buttons/ipg.png" alt="InvoxiPlayGames web button" width="88" height="31">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://ioletsgo.gay">
|
<a href="https://ioletsgo.gay" target="_blank">
|
||||||
<img src="/img/buttons/ioletsgo.gif" alt="ioletsgo web button" width="88" height="31">
|
<img src="/img/buttons/ioletsgo.gif" alt="ioletsgo web button" width="88" height="31">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://notnite.com/">
|
<a href="https://notnite.com/" target="_blank">
|
||||||
<img src="/img/buttons/notnite.png" alt="notnite web button" width="88" height="31">
|
<img src="/img/buttons/notnite.png" alt="notnite web button" width="88" height="31">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://www.da.vidbuchanan.co.uk/">
|
<a href="https://www.da.vidbuchanan.co.uk/" target="_blank">
|
||||||
<img src="/img/buttons/retr0id_now.gif" alt="retr0id web button" width="88" height="31">
|
<img src="/img/buttons/retr0id_now.gif" alt="retr0id web button" width="88" height="31">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://aikoyori.xyz">
|
<a href="https://aikoyori.xyz" target="_blank">
|
||||||
<img src="/img/buttons/aikoyori.gif" alt="aikoyori web button" width="88" height="31">
|
<img src="/img/buttons/aikoyori.gif" alt="aikoyori web button" width="88" height="31">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://xenia.blahaj.land/">
|
<a href="https://xenia.blahaj.land/" target="_blank">
|
||||||
<img src="/img/buttons/xenia.png" alt="xenia web button" width="88" height="31">
|
<img src="/img/buttons/xenia.png" alt="xenia web button" width="88" height="31">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://stardust.elysium.gay/">
|
<a href="https://stardust.elysium.gay/" target="_blank">
|
||||||
<img src="/img/buttons/stardust.png" alt="stardust web button" width="88" height="31">
|
<img src="/img/buttons/stardust.png" alt="stardust web button" width="88" height="31">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://isabelroses.com/">
|
<a href="https://isabelroses.com/" target="_blank">
|
||||||
<img src="/img/buttons/isabelroses.gif" alt="isabel roses web button" width="88" height="31">
|
<img src="/img/buttons/isabelroses.gif" alt="isabel roses web button" width="88" height="31">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://bubblegum.girlonthemoon.xyz/">
|
|
||||||
<img src="/img/buttons/girlonthemoon.png" alt="sweet like bubblegum web button" width="88" height="31">
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
|
@ -257,16 +197,14 @@
|
||||||
<img src="/img/buttons/misc/sprunk.gif" alt="sprunk" width="88" height="31">
|
<img src="/img/buttons/misc/sprunk.gif" alt="sprunk" width="88" height="31">
|
||||||
<img src="/img/buttons/misc/tohell.gif" alt="go straight to hell" width="88" height="31">
|
<img src="/img/buttons/misc/tohell.gif" alt="go straight to hell" width="88" height="31">
|
||||||
<img src="/img/buttons/misc/virusalert.gif" alt="virus alert! click here" onclick="alert('meow :3')" width="88" height="31">
|
<img src="/img/buttons/misc/virusalert.gif" alt="virus alert! click here" onclick="alert('meow :3')" width="88" height="31">
|
||||||
<a href="http://wiishopchannel.net/">
|
<img src="/img/buttons/misc/wii.gif" alt="wii" width="88" height="31">
|
||||||
<img src="/img/buttons/misc/wii.gif" alt="wii" width="88" height="31">
|
|
||||||
</a>
|
|
||||||
<img src="/img/buttons/misc/www2.gif" alt="www" width="88" height="31">
|
<img src="/img/buttons/misc/www2.gif" alt="www" width="88" height="31">
|
||||||
<img src="/img/buttons/misc/iemandatory.gif" alt="get mandatory internet explorer" width="88" height="31">
|
<img src="/img/buttons/misc/iemandatory.gif" alt="get mandatory internet explorer" width="88" height="31">
|
||||||
<img src="/img/buttons/misc/learn_html.gif" alt="HTML - learn it today!" width="88" height="31">
|
<img src="/img/buttons/misc/learn_html.gif" alt="HTML - learn it today!" width="88" height="31">
|
||||||
<a href="https://smokepowered.com">
|
<a href="https://smokepowered.com" target="_blank">
|
||||||
<img src="/img/buttons/misc/smokepowered.gif" alt="high on SMOKE" width="88" height="31">
|
<img src="/img/buttons/misc/smokepowered.gif" alt="high on SMOKE" width="88" height="31">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://epicblazed.com">
|
<a href="https://epicblazed.com" target="_blank">
|
||||||
<img src="/img/buttons/misc/epicblazed.png" alt="epic blazed" width="88" height="31">
|
<img src="/img/buttons/misc/epicblazed.png" alt="epic blazed" width="88" height="31">
|
||||||
</a>
|
</a>
|
||||||
<img src="/img/buttons/misc/blink.gif" alt="closeup anime blink" width="88" height="31">
|
<img src="/img/buttons/misc/blink.gif" alt="closeup anime blink" width="88" height="31">
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
@ -10,25 +10,25 @@
|
||||||
{{block "head" .}}{{end}}
|
{{block "head" .}}{{end}}
|
||||||
|
|
||||||
<script type="module", src="/script/main.js"></script>
|
<script type="module", src="/script/main.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
{{template "header"}}
|
{{template "header"}}
|
||||||
|
|
||||||
{{block "content" .}}
|
{{block "content" .}}
|
||||||
<main>
|
<main>
|
||||||
<h1>
|
<h1>
|
||||||
# hello, world!
|
# hello, world!
|
||||||
</h1>
|
</h1>
|
||||||
<p>
|
<p>
|
||||||
this is a default page!
|
this is a default page!
|
||||||
</p>
|
</p>
|
||||||
</main>
|
</main>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{template "footer"}}
|
{{template "footer"}}
|
||||||
<div id="overlay"></div>
|
<div id="overlay"></div>
|
||||||
{{template "prideflag"}}
|
{{template "prideflag"}}
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
<meta name="description" content="Stream "{{.Title}}" by {{.PrintArtists true true}} on all platforms!">
|
<meta name="description" content="Stream "{{.Title}}" by {{.PrintArtists true true}} on all platforms!">
|
||||||
<meta name="author" content="{{.PrintArtists true true}}">
|
<meta name="author" content="{{.PrintArtists true true}}">
|
||||||
<meta name="keywords" content="{{.PrintArtists true false}}, music, {{.Title}}, {{.ID}}, {{.ReleaseDate.Year}}">
|
<meta name="keywords" content="{{.PrintArtists true false}}, music, {{.Title}}, {{.ID}}, {{.GetReleaseYear}}">
|
||||||
|
|
||||||
<meta property="og:url" content="https://arimelody.me/music/{{.ID}}">
|
<meta property="og:url" content="https://arimelody.me/music/{{.ID}}">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
|
@ -54,7 +54,7 @@
|
||||||
<div id="overview">
|
<div id="overview">
|
||||||
<div id="title-container">
|
<div id="title-container">
|
||||||
<h1 id="title">{{.Title}}</h1>
|
<h1 id="title">{{.Title}}</h1>
|
||||||
<span id="year" title="{{.PrintReleaseDate}}">{{.ReleaseDate.Year}}</span>
|
<span id="year" title="{{.PrintReleaseDate}}">{{.GetReleaseYear}}</span>
|
||||||
</div>
|
</div>
|
||||||
<p id="artist">{{.PrintArtists true true}}</p>
|
<p id="artist">{{.PrintArtists true true}}</p>
|
||||||
{{if .IsReleased}}
|
{{if .IsReleased}}
|
||||||
|
@ -91,7 +91,7 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if and .Copyright .CopyrightURL}}
|
{{if and .Copyright .CopyrightURL}}
|
||||||
<p id="copyright">{{.Title}} © {{.ReleaseDate.Year}} by {{.PrintArtists true true}} is licensed under <a href="{{.CopyrightURL}}" target="_blank">{{.Copyright}}</a></p>
|
<p id="copyright">{{.Title}} © {{.GetReleaseYear}} by {{.PrintArtists true true}} is licensed under <a href="{{.CopyrightURL}}" target="_blank">{{.Copyright}}</a></p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<button id="share">share</button>
|
<button id="share">share</button>
|
||||||
|
@ -105,8 +105,8 @@
|
||||||
<ul>
|
<ul>
|
||||||
{{range .Credits}}
|
{{range .Credits}}
|
||||||
{{$Artist := .Artist}}
|
{{$Artist := .Artist}}
|
||||||
{{if $Artist.Website}}
|
{{if $Artist.GetWebsite}}
|
||||||
<li><strong><a href="{{$Artist.Website}}">{{$Artist.Name}}</a></strong>: {{.Role}}</li>
|
<li><strong><a href="{{$Artist.GetWebsite}}">{{$Artist.Name}}</a></strong>: {{.Role}}</li>
|
||||||
{{else}}
|
{{else}}
|
||||||
<li><strong>{{$Artist.Name}}</strong>: {{.Role}}</li>
|
<li><strong>{{$Artist.Name}}</strong>: {{.Role}}</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
@ -1,21 +1,21 @@
|
||||||
{{define "prideflag"}}
|
{{define "prideflag"}}
|
||||||
<a href="https://github.com/arimelody/prideflag" target="_blank" id="prideflag">
|
<a href="https://github.com/arimelody/prideflag" target="_blank" id="prideflag">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" hx-preserve="true">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" hx-preserve="true">
|
||||||
<path id="red" d="M120,80 L100,100 L120,120 Z" style="fill:#d20605"/>
|
<path id="red" d="M120,80 L100,100 L120,120 Z" style="fill:#d20605"/>
|
||||||
<path id="orange" d="M120,80 V40 L80,80 L100,100 Z" style="fill:#ef9c00"/>
|
<path id="orange" d="M120,80 V40 L80,80 L100,100 Z" style="fill:#ef9c00"/>
|
||||||
<path id="yellow" d="M120,40 V0 L60,60 L80,80 Z" style="fill:#e5fe02"/>
|
<path id="yellow" d="M120,40 V0 L60,60 L80,80 Z" style="fill:#e5fe02"/>
|
||||||
<path id="green" d="M120,0 H80 L40,40 L60,60 Z" style="fill:#09be01"/>
|
<path id="green" d="M120,0 H80 L40,40 L60,60 Z" style="fill:#09be01"/>
|
||||||
<path id="blue" d="M80,0 H40 L20,20 L40,40 Z" style="fill:#081a9a"/>
|
<path id="blue" d="M80,0 H40 L20,20 L40,40 Z" style="fill:#081a9a"/>
|
||||||
<path id="purple" d="M40,0 H0 L20,20 Z" style="fill:#76008a"/>
|
<path id="purple" d="M40,0 H0 L20,20 Z" style="fill:#76008a"/>
|
||||||
|
|
||||||
<rect id="black" x="60" width="60" height="60" style="fill:#010101"/>
|
<rect id="black" x="60" width="60" height="60" style="fill:#010101"/>
|
||||||
<rect id="brown" x="70" width="50" height="50" style="fill:#603814"/>
|
<rect id="brown" x="70" width="50" height="50" style="fill:#603814"/>
|
||||||
<rect id="lightblue" x="80" width="40" height="40" style="fill:#73d6ed"/>
|
<rect id="lightblue" x="80" width="40" height="40" style="fill:#73d6ed"/>
|
||||||
<rect id="pink" x="90" width="30" height="30" style="fill:#ffafc8"/>
|
<rect id="pink" x="90" width="30" height="30" style="fill:#ffafc8"/>
|
||||||
<rect id="white" x="100" width="20" height="20" style="fill:#fff"/>
|
<rect id="white" x="100" width="20" height="20" style="fill:#fff"/>
|
||||||
|
|
||||||
<rect id="intyellow" x="110" width="10" height="10" style="fill:#fed800"/>
|
<rect id="intyellow" x="110" width="10" height="10" style="fill:#fed800"/>
|
||||||
<circle id="intpurple" cx="120" cy="0" r="5" stroke="#7601ad" stroke-width="2" fill="none"/>
|
<circle id="intpurple" cx="120" cy="0" r="5" stroke="#7601ad" stroke-width="2" fill="none"/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|