full stack first commit

This commit is contained in:
Leon Bösche
2025-12-18 00:02:50 +01:00
parent ab7c734ae7
commit b35adc3d06
18 changed files with 717 additions and 85 deletions

View File

@@ -45,6 +45,16 @@ type Membership struct {
CreatedAt time.Time
}
type Activity struct {
ID uuid.UUID
UserID uuid.UUID
OrgID uuid.UUID
FileID *string
Action string
Metadata map[string]interface{}
Timestamp time.Time
}
func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) {
var user User
err := db.QueryRowContext(ctx, `
@@ -122,3 +132,89 @@ func (db *DB) GetUserMembership(ctx context.Context, userID, orgID uuid.UUID) (*
}
return &membership, nil
}
func (db *DB) CreateOrg(ctx context.Context, name, slug string) (*Organization, error) {
var org Organization
err := db.QueryRowContext(ctx, `
INSERT INTO organizations (name, slug)
VALUES ($1, $2)
RETURNING id, name, slug, created_at
`, name, slug).Scan(&org.ID, &org.Name, &org.Slug, &org.CreatedAt)
if err != nil {
return nil, err
}
return &org, nil
}
func (db *DB) AddMembership(ctx context.Context, userID, orgID uuid.UUID, role string) error {
_, err := db.ExecContext(ctx, `
INSERT INTO memberships (user_id, org_id, role)
VALUES ($1, $2, $3)
`, userID, orgID, role)
return err
}
func (db *DB) LogActivity(ctx context.Context, userID, orgID uuid.UUID, fileID *string, action string, metadata map[string]interface{}) error {
_, err := db.ExecContext(ctx, `
INSERT INTO activities (user_id, org_id, file_id, action, metadata)
VALUES ($1, $2, $3, $4, $5)
`, userID, orgID, fileID, action, metadata)
return err
}
func (db *DB) GetOrgActivities(ctx context.Context, orgID uuid.UUID, limit int) ([]Activity, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, user_id, org_id, file_id, action, metadata, timestamp
FROM activities
WHERE org_id = $1
ORDER BY timestamp DESC
LIMIT $2
`, orgID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var activities []Activity
for rows.Next() {
var a Activity
err := rows.Scan(&a.ID, &a.UserID, &a.OrgID, &a.FileID, &a.Action, &a.Metadata, &a.Timestamp)
if err != nil {
return nil, err
}
activities = append(activities, a)
}
return activities, rows.Err()
}
func (db *DB) GetOrgMembers(ctx context.Context, orgID uuid.UUID) ([]Membership, error) {
rows, err := db.QueryContext(ctx, `
SELECT user_id, org_id, role, created_at
FROM memberships
WHERE org_id = $1
`, orgID)
if err != nil {
return nil, err
}
defer rows.Close()
var memberships []Membership
for rows.Next() {
var m Membership
err := rows.Scan(&m.UserID, &m.OrgID, &m.Role, &m.CreatedAt)
if err != nil {
return nil, err
}
memberships = append(memberships, m)
}
return memberships, rows.Err()
}
func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, role string) error {
_, err := db.ExecContext(ctx, `
UPDATE memberships
SET role = $1
WHERE org_id = $2 AND user_id = $3
`, role, orgID, userID)
return err
}

View File

@@ -1,16 +1,21 @@
package http
import (
"encoding/json"
"net/http"
"strings"
"go.b0esche.cloud/backend/internal/audit"
"go.b0esche.cloud/backend/internal/auth"
"go.b0esche.cloud/backend/internal/config"
"go.b0esche.cloud/backend/internal/database"
"go.b0esche.cloud/backend/internal/middleware"
"go.b0esche.cloud/backend/internal/org"
"go.b0esche.cloud/backend/internal/permission"
"go.b0esche.cloud/backend/pkg/jwt"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, authService *auth.Service, auditLogger *audit.Logger) http.Handler {
@@ -31,13 +36,57 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
authLoginHandler(w, req, authService)
})
r.Get("/callback", func(w http.ResponseWriter, req *http.Request) {
authCallbackHandler(w, req, cfg, authService, jwtManager, auditLogger)
authCallbackHandler(w, req, cfg, authService, jwtManager, auditLogger, db)
})
r.Post("/refresh", func(w http.ResponseWriter, req *http.Request) {
refreshHandler(w, req, jwtManager, db)
})
})
// Auth middleware for protected routes
r.Use(middleware.Auth(jwtManager, db))
// Org routes
r.Get("/orgs", func(w http.ResponseWriter, req *http.Request) {
listOrgsHandler(w, req, db, jwtManager)
})
r.Post("/orgs", func(w http.ResponseWriter, req *http.Request) {
createOrgHandler(w, req, db, auditLogger, jwtManager)
})
// Org-scoped routes
r.Route("/orgs/{orgId}", func(r chi.Router) {
r.Use(middleware.Org(db, auditLogger))
// File routes
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files", func(w http.ResponseWriter, req *http.Request) {
listFilesHandler(w, req)
})
r.Route("/files/{fileId}", func(r chi.Router) {
r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Get("/view", func(w http.ResponseWriter, req *http.Request) {
viewerHandler(w, req, db, auditLogger)
})
r.With(middleware.Permission(db, auditLogger, permission.DocumentEdit)).Get("/edit", func(w http.ResponseWriter, req *http.Request) {
editorHandler(w, req, db, auditLogger)
})
r.With(middleware.Permission(db, auditLogger, permission.DocumentEdit)).Post("/annotations", func(w http.ResponseWriter, req *http.Request) {
annotationsHandler(w, req, db, auditLogger)
})
r.Get("/meta", func(w http.ResponseWriter, req *http.Request) {
fileMetaHandler(w, req)
})
})
r.Get("/activity", func(w http.ResponseWriter, req *http.Request) {
activityHandler(w, req, db)
})
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/members", func(w http.ResponseWriter, req *http.Request) {
listMembersHandler(w, req, db)
})
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Patch("/members/{userId}", func(w http.ResponseWriter, req *http.Request) {
updateMemberRoleHandler(w, req, db, auditLogger)
})
})
return r
}
@@ -59,7 +108,7 @@ func authLoginHandler(w http.ResponseWriter, r *http.Request, authService *auth.
http.Redirect(w, r, url, http.StatusFound)
}
func authCallbackHandler(w http.ResponseWriter, r *http.Request, cfg *config.Config, authService *auth.Service, jwtManager *jwt.Manager, auditLogger *audit.Logger) {
func authCallbackHandler(w http.ResponseWriter, r *http.Request, cfg *config.Config, authService *auth.Service, jwtManager *jwt.Manager, auditLogger *audit.Logger, db *database.DB) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
@@ -76,7 +125,18 @@ func authCallbackHandler(w http.ResponseWriter, r *http.Request, cfg *config.Con
return
}
token, err := jwtManager.Generate(user.Email, []string{}, session.ID.String()) // Orgs not yet
// Get user orgs
orgs, err := org.ResolveUserOrgs(r.Context(), db, user.ID)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
orgIDs := make([]string, len(orgs))
for i, o := range orgs {
orgIDs[i] = o.ID.String()
}
token, err := jwtManager.Generate(user.Email, orgIDs, session.ID.String())
if err != nil {
http.Error(w, "Token generation failed", http.StatusInternalServerError)
return
@@ -91,3 +151,277 @@ func authCallbackHandler(w http.ResponseWriter, r *http.Request, cfg *config.Con
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"token": "` + token + `"}`))
}
func refreshHandler(w http.ResponseWriter, r *http.Request, jwtManager *jwt.Manager, db *database.DB) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(claims.UserID)
orgs, err := db.GetUserOrganizations(r.Context(), userID)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
orgIDs := make([]string, len(orgs))
for i, o := range orgs {
orgIDs[i] = o.ID.String()
}
newToken, err := jwtManager.Generate(claims.UserID, orgIDs, session.ID.String())
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"token": "` + newToken + `"}`))
}
func listOrgsHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, _, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(claims.UserID)
orgs, err := org.ResolveUserOrgs(r.Context(), db, userID)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(orgs)
}
func createOrgHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, jwtManager *jwt.Manager) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, _, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(claims.UserID)
var req struct {
Name string `json:"name"`
Slug string `json:"slug,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
org, err := org.CreateOrg(r.Context(), db, userID, req.Name, req.Slug)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
OrgID: &org.ID,
Action: "create_org",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(org)
}
func listFilesHandler(w http.ResponseWriter, r *http.Request) {
// Mock files
files := []struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
Size int `json:"size"`
LastModified string `json:"lastModified"`
}{
{"test.pdf", "/test.pdf", "file", 1234, "2023-01-01T00:00:00Z"},
{"folder", "/folder", "folder", 0, "2023-01-01T00:00:00Z"},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(files)
}
func viewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
userIDStr := r.Context().Value("user").(string)
userID, _ := uuid.Parse(userIDStr)
orgID := r.Context().Value("org").(uuid.UUID)
fileId := chi.URLParam(r, "fileId")
// Log activity
db.LogActivity(r.Context(), userID, orgID, &fileId, "view_file", map[string]interface{}{})
session := struct {
ViewUrl string `json:"viewUrl"`
Capabilities struct {
CanEdit bool `json:"canEdit"`
CanAnnotate bool `json:"canAnnotate"`
IsPdf bool `json:"isPdf"`
} `json:"capabilities"`
ExpiresAt string `json:"expiresAt"`
}{
ViewUrl: "https://view.example.com/" + fileId,
Capabilities: struct {
CanEdit bool `json:"canEdit"`
CanAnnotate bool `json:"canAnnotate"`
IsPdf bool `json:"isPdf"`
}{CanEdit: true, CanAnnotate: true, IsPdf: true},
ExpiresAt: "2023-01-01T01:00:00Z",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(session)
}
func editorHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
userIDStr := r.Context().Value("user").(string)
userID, _ := uuid.Parse(userIDStr)
orgID := r.Context().Value("org").(uuid.UUID)
fileId := chi.URLParam(r, "fileId")
// Log activity
db.LogActivity(r.Context(), userID, orgID, &fileId, "edit_file", map[string]interface{}{})
session := struct {
EditUrl string `json:"editUrl"`
ReadOnly bool `json:"readOnly"`
ExpiresAt string `json:"expiresAt"`
}{
EditUrl: "https://edit.example.com/" + fileId,
ReadOnly: false,
ExpiresAt: "2023-01-01T01:00:00Z",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(session)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(session)
}
func annotationsHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
userIDStr := r.Context().Value("user").(string)
userID, _ := uuid.Parse(userIDStr)
orgID := r.Context().Value("org").(uuid.UUID)
fileId := chi.URLParam(r, "fileId")
// Parse payload
var payload struct {
Annotations []interface{} `json:"annotations"`
BaseVersionId string `json:"baseVersionId"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// Log activity
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
OrgID: &orgID,
Resource: &fileId,
Action: "annotate_pdf",
Success: true,
Metadata: map[string]interface{}{"count": len(payload.Annotations)},
})
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "ok"}`))
}
func activityHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
orgID := r.Context().Value("org").(uuid.UUID)
activities, err := db.GetOrgActivities(r.Context(), orgID, 50)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(activities)
}
func listMembersHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
orgID := r.Context().Value("org").(uuid.UUID)
members, err := db.GetOrgMembers(r.Context(), orgID)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(members)
}
func updateMemberRoleHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
orgID := r.Context().Value("org").(uuid.UUID)
userIDStr := chi.URLParam(r, "userId")
userID, err := uuid.Parse(userIDStr)
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
var req struct {
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
if err := db.UpdateMemberRole(r.Context(), orgID, userID, req.Role); err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "ok"}`))
}
func fileMetaHandler(w http.ResponseWriter, r *http.Request) {
meta := struct {
LastModified string `json:"lastModified"`
LastModifiedBy string `json:"lastModifiedBy"`
VersionCount int `json:"versionCount"`
}{
LastModified: "2023-01-01T00:00:00Z",
LastModifiedBy: "user@example.com",
VersionCount: 1,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(meta)
}

View File

@@ -21,3 +21,20 @@ func CheckMembership(ctx context.Context, db *database.DB, userID, orgID uuid.UU
}
return membership.Role, nil
}
// CreateOrg creates a new organization and adds the user as owner
func CreateOrg(ctx context.Context, db *database.DB, userID uuid.UUID, name, slug string) (*database.Organization, error) {
if slug == "" {
// Simple slug generation
slug = name // TODO: make URL safe
}
org, err := db.CreateOrg(ctx, name, slug)
if err != nil {
return nil, err
}
err = db.AddMembership(ctx, userID, org.ID, "owner")
if err != nil {
return nil, err
}
return org, nil
}

View File

@@ -39,4 +39,14 @@ CREATE TABLE audit_logs (
success BOOLEAN NOT NULL,
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
metadata JSONB
);
CREATE TABLE activities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
org_id UUID REFERENCES organizations(id),
file_id TEXT, -- nullable, for future file table
action TEXT NOT NULL,
metadata JSONB,
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);