Add setup-flow

This commit is contained in:
2026-03-21 03:12:13 +01:00
parent 80a2f662dc
commit dd044cf5d0
17 changed files with 519 additions and 37 deletions

View File

@@ -1,6 +0,0 @@
PORT=8000
JWT_SECRET=
ADMIN_PASSWORD=
DOMAIN=http://localhost:8000
USE_HTTPS=false

View File

@@ -28,6 +28,9 @@ COPY --from=builder /app/.env ./
RUN mkdir -p /app/uploads RUN mkdir -p /app/uploads
RUN adduser -D appuser
USER appuser
ENV GIN_MODE=release ENV GIN_MODE=release
EXPOSE 8000 EXPOSE 8000

View File

@@ -7,6 +7,8 @@ import (
"ResendIt/internal/user" "ResendIt/internal/user"
"ResendIt/internal/util" "ResendIt/internal/util"
"ResendIt/internal/web" "ResendIt/internal/web"
"crypto/rand"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"html/template" "html/template"
@@ -45,7 +47,7 @@ func main() {
r.LoadHTMLGlob("templates/*.html") r.LoadHTMLGlob("templates/*.html")
//r.LoadHTMLGlob("internal/templates/new/*.html") //r.LoadHTMLGlob("internal/templates/new/*.html")
r.Static("/static", "/static") r.Static("/static", "./static")
r.GET("/ping", func(c *gin.Context) { r.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -78,36 +80,54 @@ func main() {
file.RegisterRoutes(apiRoute, fileHandler) file.RegisterRoutes(apiRoute, fileHandler)
webHandler := web.NewHandler(fileService) webHandler := web.NewHandler(fileService)
web.RegisterRoutes(r, webHandler) web.RegisterRoutes(r, webHandler, userService)
err = r.Run(":" + os.Getenv("PORT")) port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
err = r.Run(":" + port)
if err != nil { if err != nil {
return return
} }
} }
func generateRandomPassword(length int) string {
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return base64.URLEncoding.EncodeToString(b)[:length]
}
func createAdminUser(service *user.Service) { func createAdminUser(service *user.Service) {
//Check if admin user already exists
_, err := service.FindByUsername("admin") _, err := service.FindByUsername("admin")
if err == nil { if err == nil {
fmt.Println("Admin user already exists, skipping creation") fmt.Println("Admin user already exists, skipping creation")
return return
} else if !errors.Is(err, user.ErrUserNotFound) { } else if errors.Is(err, user.ErrUserNotFound) {
fmt.Printf("Error checking for admin user: %v\n", err) fmt.Println("Admin user not found, creating new admin user")
password := generateRandomPassword(16)
adminUser, err := service.CreateUser("admin", password, "admin")
if err != nil {
fmt.Printf("Error creating admin user: %v\n", err)
return
}
adminUser.ForceChangePassword = true
_, err = service.UpdateUser(adminUser)
if err != nil {
fmt.Printf("Error creating admin user: %v\n", err)
} else {
fmt.Printf("Admin user created with random password: %s\n", password)
}
return return
} }
adminPassword, exists := os.LookupEnv("ADMIN_PASSWORD") fmt.Printf("Error checking for admin user: %v\n", err)
if !exists || adminPassword == "" { return
fmt.Println("ADMIN_PASSWORD not set in environment variables")
fmt.Println("NO ADMIN ACCOUNT WILL BE CREATED")
return
}
_, err = service.CreateUser("admin", adminPassword, "admin")
if err != nil {
fmt.Printf("Error creating admin user: %v\n", err)
} else {
fmt.Println("Admin user created successfully")
}
} }

View File

@@ -22,13 +22,11 @@ func AuthMiddleware() gin.HandlerFunc {
var tokenString string var tokenString string
// 🔥 1. Try cookie first (NEW)
cookie, err := c.Cookie("auth_token") cookie, err := c.Cookie("auth_token")
if err == nil && cookie != "" { if err == nil && cookie != "" {
tokenString = cookie tokenString = cookie
} }
// 🔥 2. Fallback to Authorization header (for API tools / future SPA)
if tokenString == "" { if tokenString == "" {
authHeader := c.GetHeader("Authorization") authHeader := c.GetHeader("Authorization")
@@ -40,13 +38,11 @@ func AuthMiddleware() gin.HandlerFunc {
} }
} }
// ❌ No token at all
if tokenString == "" { if tokenString == "" {
abortUnauthorized(c) abortUnauthorized(c)
return return
} }
// 🔐 Parse JWT
claims := &Claims{} claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {

View File

@@ -13,7 +13,13 @@ import (
func Connect() (*gorm.DB, error) { func Connect() (*gorm.DB, error) {
dbType := os.Getenv("DB_TYPE") dbType := os.Getenv("DB_TYPE")
if dbType == "" {
dbType = "sqlite"
}
dsn := os.Getenv("DATABASE_URL") dsn := os.Getenv("DATABASE_URL")
if dbType == "sqlite" && dsn == "" {
dsn = "./data/database.db"
}
switch dbType { switch dbType {
case "sqlite": case "sqlite":

View File

@@ -7,6 +7,7 @@ import (
type FileRecord struct { type FileRecord struct {
ID string `gorm:"primaryKey" json:"id"` ID string `gorm:"primaryKey" json:"id"`
DeletionID string `json:"deletion_id"` DeletionID string `json:"deletion_id"`
ViewID string `json:"view_id"`
Filename string `json:"filename"` Filename string `json:"filename"`
Path string `json:"-"` // file path on disk (not exposed via JSON) Path string `json:"-"` // file path on disk (not exposed via JSON)
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`

View File

@@ -3,6 +3,7 @@ package file
import ( import (
"io" "io"
"os" "os"
"path/filepath"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -29,7 +30,8 @@ func (s *Service) UploadFile(filename string, data io.Reader, deleteAfterDownloa
return nil, err return nil, err
} }
path := folderPath + "/" + filename safeName := uuid.NewString() + filepath.Ext(filename)
path := filepath.Join(folderPath, safeName)
out, err := os.Create(path) out, err := os.Create(path)
if err != nil { if err != nil {

View File

@@ -3,3 +3,5 @@ package user
import "errors" import "errors"
var ErrUserNotFound = errors.New("user not found") var ErrUserNotFound = errors.New("user not found")
var ErrPasswordsDoNotMatch = errors.New("Incorrect old password")
var ErrInvalidPassword = errors.New("invalid password")

View File

@@ -1,6 +1,10 @@
package user package user
import "github.com/gin-gonic/gin" import (
"fmt"
"github.com/gin-gonic/gin"
)
type Handler struct { type Handler struct {
service *Service service *Service
@@ -30,3 +34,55 @@ func (h *Handler) Register(c *gin.Context) {
c.JSON(201, gin.H{"id": user.ID, "username": user.Username, "role": user.Role}) c.JSON(201, gin.H{"id": user.ID, "username": user.Username, "role": user.Role})
} }
func (h *Handler) ChangePassword(c *gin.Context) {
var req struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
userID, exists := c.Get("user_id")
if !exists {
fmt.Println("User ID not found in context")
c.JSON(401, gin.H{"error": "unauthorized"})
return
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "invalid request"})
return
}
err := h.service.ChangePassword(userID.(string), req.OldPassword, req.NewPassword)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "password changed successfully"})
}
func ForcePasswordChangeMiddleware(userService *Service) gin.HandlerFunc {
return func(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.Next()
return
}
user, err := userService.FindByID(userID.(string))
if err != nil {
c.AbortWithStatus(500)
return
}
// Allow access to change password page itself
if user.ForceChangePassword && c.Request.URL.Path != "/change-password" {
c.Redirect(302, "/change-password")
c.Abort()
return
}
c.Next()
}
}

View File

@@ -4,7 +4,8 @@ import "gorm.io/gorm"
type User struct { type User struct {
gorm.Model gorm.Model
Username string `gorm:"uniqueIndex;not null"` Username string `gorm:"uniqueIndex;not null"`
PasswordHash string `gorm:"not null"` PasswordHash string `gorm:"not null"`
Role string `gorm:"not null"` Role string `gorm:"not null"`
ForceChangePassword bool `gorm:"default:false"`
} }

View File

@@ -25,6 +25,17 @@ func (r *Repository) FindByUsername(username string) (*User, error) {
return &u, nil return &u, nil
} }
func (r *Repository) FindByID(id string) (*User, error) {
var u User
if err := r.db.First(&u, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound
}
return nil, err
}
return &u, nil
}
func (r *Repository) Create(u *User) error { func (r *Repository) Create(u *User) error {
return r.db.Create(u).Error return r.db.Create(u).Error
} }
@@ -37,6 +48,10 @@ func (r *Repository) GetAll() ([]User, error) {
return users, nil return users, nil
} }
func (r *Repository) Update(u *User) error {
return r.db.Save(u).Error
}
func (r *Repository) Delete(id uint) error { func (r *Repository) Delete(id uint) error {
return r.db.Delete(&User{}, id).Error return r.db.Delete(&User{}, id).Error
} }

View File

@@ -1,11 +1,17 @@
package user package user
import ( import (
"ResendIt/internal/api/middleware"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func RegisterRoutes(r *gin.RouterGroup, h *Handler) { func RegisterRoutes(r *gin.RouterGroup, h *Handler) {
//auth := r.Group("/user") auth := r.Group("/user")
auth.Use(middleware.AuthMiddleware())
auth.Use(middleware.RequireRole("admin"))
auth.POST("/change-password", h.ChangePassword)
//auth.POST("/register", h.Register) //auth.POST("/register", h.Register)
} }

View File

@@ -35,6 +35,66 @@ func (s *Service) CreateUser(username, password, role string) (*User, error) {
return u, nil return u, nil
} }
// UpdateUser updates a user's information
func (s *Service) UpdateUser(user *User) (*User, error) {
if err := s.repo.Update(user); err != nil {
return nil, err
}
return user, nil
}
func validNewPassword(oldPassword, newPassword string) bool {
if oldPassword == newPassword {
return false
}
if len(newPassword) < 8 {
return false
}
//Contains 1 uppercase, 1 lowercase, 1 number
hasUpper := false
hasLower := false
hasNumber := false
for _, c := range newPassword {
switch {
case 'A' <= c && c <= 'Z':
hasUpper = true
case 'a' <= c && c <= 'z':
hasLower = true
case '0' <= c && c <= '9':
hasNumber = true
}
}
if !hasUpper || !hasLower || !hasNumber {
return false
}
return true
}
func (s *Service) ChangePassword(userID string, oldPassword string, newPassword string) error {
user, err := s.repo.FindByID(userID)
if err != nil {
return err
}
if !validNewPassword(oldPassword, newPassword) {
return ErrInvalidPassword
}
if !security.CheckPassword(oldPassword, user.PasswordHash) {
return ErrPasswordsDoNotMatch
}
newHash, err := security.HashPassword(newPassword)
if err != nil {
return err
}
user.PasswordHash = newHash
user.ForceChangePassword = false
return s.repo.Update(user)
}
// GetAllUsers returns all users // GetAllUsers returns all users
func (s *Service) GetAllUsers() ([]User, error) { func (s *Service) GetAllUsers() ([]User, error) {
return s.repo.GetAll() return s.repo.GetAll()
@@ -53,3 +113,8 @@ func (s *Service) DeleteUser(requesterID, targetID uint) error {
func (s *Service) FindByUsername(username string) (*User, error) { func (s *Service) FindByUsername(username string) (*User, error) {
return s.repo.FindByUsername(username) return s.repo.FindByUsername(username)
} }
// FindByID returns a user by ID
func (s *Service) FindByID(id string) (*User, error) {
return s.repo.FindByID(id)
}

View File

@@ -64,3 +64,7 @@ func (h *Handler) Logout(c *gin.Context) {
c.SetCookie("auth_token", "", -1, "/", "", false, true) c.SetCookie("auth_token", "", -1, "/", "", false, true)
c.Redirect(302, "/") c.Redirect(302, "/")
} }
func (h *Handler) ChangePasswordPage(c *gin.Context) {
c.HTML(200, "changePassword.html", nil)
}

View File

@@ -2,19 +2,22 @@ package web
import ( import (
"ResendIt/internal/api/middleware" "ResendIt/internal/api/middleware"
"ResendIt/internal/user"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func RegisterRoutes(r *gin.Engine, h *Handler) { func RegisterRoutes(r *gin.Engine, h *Handler, userService *user.Service) {
r.GET("/", h.Index) r.GET("/", h.Index)
r.GET("/upload", h.UploadPage) //r.GET("/upload", h.UploadPage)
r.GET("/login", h.LoginPage) r.GET("/login", h.LoginPage)
adminRoutes := r.Group("/") adminRoutes := r.Group("/")
adminRoutes.Use(middleware.AuthMiddleware()) adminRoutes.Use(middleware.AuthMiddleware())
adminRoutes.Use(middleware.RequireRole("admin")) adminRoutes.Use(middleware.RequireRole("admin"))
adminRoutes.Use(user.ForcePasswordChangeMiddleware(userService))
adminRoutes.GET("/admin", h.AdminPage) adminRoutes.GET("/admin", h.AdminPage)
adminRoutes.GET("/logout", h.Logout) adminRoutes.GET("/logout", h.Logout)
adminRoutes.GET("/change-password", h.ChangePasswordPage)
} }

View File

@@ -214,6 +214,7 @@
if (event.target == overlay) closeModal(); if (event.target == overlay) closeModal();
} }
</script> </script>
<a href="/change-password" class="fixed bottom-1 left-1 text-[10px] underline">CHANGE PASSWORD</a>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,307 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Change Password</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<style>
* { border-radius: 0 !important; }
body { font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace; background: #fff; color: #000; padding: 20px; }
.box { border: 3px solid #000; background: #fff; }
/* Chunky Buttons */
button, .button {
border: 2px solid #000;
background: #fff;
padding: 4px 10px;
cursor: pointer;
font-size: 11px;
font-weight: 900;
text-decoration: none;
text-transform: uppercase;
box-shadow: 3px 3px 0px #000;
}
button:hover, .button:hover { background: #000; color: #fff; box-shadow: none; transform: translate(2px, 2px); }
button:active { background: #ff0000; color: #fff; }
.nav-link { font-weight: 900; text-decoration: underline; text-transform: uppercase; font-size: 12px; }
.nav-link:hover { background: #000; color: #fff; }
/* Inputs */
.field-group { margin-bottom: 20px; }
label {
display: block;
font-size: 11px;
font-weight: 900;
text-transform: uppercase;
margin-bottom: 4px;
letter-spacing: 0.05em;
}
input[type="password"] {
width: 100%;
border: 3px solid #000;
padding: 10px 12px;
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace;
font-size: 14px;
font-weight: 700;
background: #fff;
outline: none;
box-sizing: border-box;
transition: background 0.1s;
}
input[type="password"]:focus {
background: #ffff00;
border-color: #000;
}
/* Strength Meter */
.strength-bar-wrap {
height: 8px;
border: 2px solid #000;
margin-top: 6px;
background: #eee;
overflow: hidden;
}
.strength-bar {
height: 100%;
width: 0%;
transition: width 0.2s, background 0.2s;
}
.strength-label {
font-size: 10px;
font-weight: 900;
text-transform: uppercase;
margin-top: 3px;
}
/* Error / Success */
.msg {
border: 3px solid #000;
padding: 10px 14px;
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
display: none;
margin-bottom: 20px;
}
.msg-error { background: #ff0000; color: #fff; }
.msg-success { background: #00ff00; color: #000; }
/* Match indicator */
.match-hint {
font-size: 10px;
font-weight: 900;
text-transform: uppercase;
margin-top: 4px;
min-height: 14px;
}
.match-ok { color: #007700; }
.match-bad { color: #ff0000; }
/* Submit button — big */
#submit-btn {
width: 100%;
padding: 14px;
font-size: 16px;
border: 4px solid #000;
box-shadow: 6px 6px 0px #000;
}
#submit-btn:hover { box-shadow: none; transform: translate(4px, 4px); }
/* Requirements list */
.req-list { list-style: none; padding: 0; margin: 0; }
.req-list li {
font-size: 11px;
font-weight: 700;
padding: 2px 0;
display: flex;
gap: 6px;
align-items: center;
}
.req-list li span.icon { font-style: normal; font-size: 12px; }
.req-met { color: #007700; }
.req-unmet { color: #aaa; }
</style>
</head>
<body>
<div class="max-w-lg mx-auto">
<header class="mb-6 border-b-8 border-black pb-4 flex justify-between items-start">
<div>
<h1 class="text-4xl font-black uppercase tracking-tighter leading-none">Change_Password</h1>
</div>
<div class="flex flex-col items-end gap-2">
<a href="/admin" class="nav-link">← BACK_TO_ADMIN</a>
<a href="/logout" class="nav-link text-red-600">LOGOUT_SESSION</a>
</div>
</header>
<div class="box p-6">
<div id="msg-error" class="msg msg-error">⚠ ERROR: Passwords do not match.</div>
<div id="msg-success" class="msg msg-success">✓ PASSWORD UPDATED. Credential change committed.</div>
<div class="field-group">
<label for="current-pw">Current_Password</label>
<input type="password" id="current-pw" placeholder="••••••••••••" autocomplete="current-password">
</div>
<div class="field-group">
<label for="new-pw">New_Password</label>
<input type="password" id="new-pw" placeholder="••••••••••••" autocomplete="new-password" oninput="onNewPwInput()">
<div class="strength-bar-wrap"><div class="strength-bar" id="strength-bar"></div></div>
<div class="strength-label" id="strength-label" style="color:#aaa"></div>
</div>
<div class="box p-3 mb-6 bg-gray-50">
<div class="text-[10px] font-black uppercase mb-2 tracking-widest">Requirements</div>
<ul class="req-list" id="req-list">
<li id="req-len" class="req-unmet"><span class="icon"></span> Min 8 characters</li>
<li id="req-upper" class="req-unmet"><span class="icon"></span> Uppercase letter</li>
<li id="req-num" class="req-unmet"><span class="icon"></span> Number</li>
</ul>
</div>
<div class="field-group">
<label for="confirm-pw">Confirm_New_Password</label>
<input type="password" id="confirm-pw" placeholder="••••••••••••" autocomplete="new-password" oninput="onConfirmInput()">
<div class="match-hint" id="match-hint"></div>
</div>
<button id="submit-btn" onclick="handleSubmit()">
CHANGE PASSWORD
</button>
</div>
</div>
<script>
const requirements = [
{ id: 'req-len', test: v => v.length >= 8 },
{ id: 'req-upper', test: v => /[A-Z]/.test(v) },
{ id: 'req-num', test: v => /[0-9]/.test(v) },
];
function getStrength(v) {
const score = requirements.filter(r => r.test(v)).length * 2;
if (v.length === 0) return { score: 0, label: '—', color: '#eee', pct: 0 };
if (score <= 1) return { score, label: 'WEAK', color: '#ff0000', pct: 20 };
if (score === 2) return { score, label: 'POOR', color: '#ff6600', pct: 40 };
if (score === 3) return { score, label: 'FAIR', color: '#ffcc00', pct: 60 };
if (score === 4) return { score, label: 'STRONG', color: '#88cc00', pct: 80 };
return { score, label: 'MAXIMUM', color: '#00cc44', pct: 100 };
}
function onNewPwInput() {
const v = document.getElementById('new-pw').value;
const { label, color, pct } = getStrength(v);
const bar = document.getElementById('strength-bar');
bar.style.width = pct + '%';
bar.style.background = color;
const lbl = document.getElementById('strength-label');
lbl.innerText = label;
lbl.style.color = pct === 0 ? '#aaa' : color;
requirements.forEach(r => {
const el = document.getElementById(r.id);
const met = r.test(v);
el.className = met ? 'req-met' : 'req-unmet';
el.querySelector('.icon').innerText = met ? '■' : '□';
});
onConfirmInput();
}
function onConfirmInput() {
const nv = document.getElementById('new-pw').value;
const cv = document.getElementById('confirm-pw').value;
const hint = document.getElementById('match-hint');
if (!cv) { hint.innerText = ''; return; }
if (nv === cv) {
hint.innerText = '✓ PASSWORDS MATCH';
hint.className = 'match-hint match-ok';
} else {
hint.innerText = '✗ MISMATCH';
hint.className = 'match-hint match-bad';
}
}
function hideMessages() {
document.getElementById('msg-error').style.display = 'none';
document.getElementById('msg-success').style.display = 'none';
}
async function handleSubmit() {
hideMessages();
const current = document.getElementById('current-pw').value;
const nv = document.getElementById('new-pw').value;
const cv = document.getElementById('confirm-pw').value;
if (!current || !nv || !cv) {
showError('All fields are required.');
return;
}
if (nv !== cv) {
showError('Passwords do not match.');
return;
}
const { score } = getStrength(nv);
if (score < 3) {
showError('Password is too weak.');
return;
}
try {
const res = await fetch('/api/user/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
old_password: current,
new_password: nv
})
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showError(data.error || 'Something went wrong.');
return;
}
showSuccess('Password updated successfully.');
document.getElementById('current-pw').value = '';
document.getElementById('new-pw').value = '';
document.getElementById('confirm-pw').value = '';
onNewPwInput();
setTimeout(() => {
window.location.href = '/login';
}, 3000);
} catch (err) {
showError('Network error. Try again.');
}
}
function showSuccess(msg) {
const el = document.getElementById('msg-success');
el.innerText = '✓ ' + msg;
el.style.display = 'block';
}
function showError(msg) {
const el = document.getElementById('msg-error');
el.innerText = '⚠ ERROR: ' + msg;
el.style.display = 'block';
}
</script>
</body>
</html>