mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-03 22:02:57 +00:00
feat: full project sync - CI fixes, frontend, workspace API, and all changes
This commit is contained in:
@@ -0,0 +1,660 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const sessionCookieName = "excalidraw_session"
|
||||
|
||||
type API struct {
|
||||
store *Store
|
||||
limiter *rateLimiter
|
||||
testMode bool
|
||||
}
|
||||
|
||||
func NewAPI(store *Store) *API {
|
||||
return &API{
|
||||
store: store,
|
||||
limiter: newRateLimiter(10, 15*time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Get("/health", a.handleHealth)
|
||||
r.Get("/auth/setup-status", a.handleSetupStatus)
|
||||
r.Post("/auth/signup", a.handleSignup)
|
||||
r.Post("/auth/login", a.handleLogin)
|
||||
r.Post("/auth/logout", a.handleLogout)
|
||||
r.Get("/shared/{token}", a.handleSharedResource)
|
||||
})
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.requireSession)
|
||||
r.Use(requireSameOriginMutation)
|
||||
r.Get("/auth/me", a.handleMe)
|
||||
r.Get("/teams", a.handleListTeams)
|
||||
r.Post("/teams", a.handleCreateTeam)
|
||||
r.Patch("/teams/{teamID}", a.handleUpdateTeam)
|
||||
r.Get("/teams/{teamID}/members", a.handleListTeamMembers)
|
||||
r.Get("/teams/{teamID}/invites", a.handleListTeamInvites)
|
||||
r.Post("/teams/{teamID}/invites", a.handleCreateTeamInvite)
|
||||
r.Post("/teams/{teamID}/users", a.handleCreateTeamUser)
|
||||
r.Post("/invites/accept", a.handleAcceptInvite)
|
||||
r.Get("/drawings", a.handleListDrawings)
|
||||
r.Post("/drawings", a.handleCreateDrawing)
|
||||
r.Get("/drawings/{drawingID}", a.handleGetDrawing)
|
||||
r.Patch("/drawings/{drawingID}", a.handleUpdateDrawing)
|
||||
r.Delete("/drawings/{drawingID}", a.handleArchiveDrawing)
|
||||
r.Get("/drawings/{drawingID}/revisions", a.handleListRevisions)
|
||||
r.Post("/drawings/{drawingID}/revisions", a.handleCreateRevision)
|
||||
r.Get("/search", a.handleSearch)
|
||||
r.Get("/drawings/{drawingID}/permissions", a.handleListPermissions)
|
||||
r.Post("/drawings/{drawingID}/permissions", a.handleCreatePermission)
|
||||
r.Get("/drawings/{drawingID}/share-links", a.handleListShareLinks)
|
||||
r.Post("/drawings/{drawingID}/share-links", a.handleCreateShareLink)
|
||||
r.Get("/drawings/{drawingID}/assets", a.handleListAssets)
|
||||
r.Post("/drawings/{drawingID}/assets", a.handleCreateAsset)
|
||||
r.Get("/drawings/{drawingID}/embeds", a.handleListEmbeds)
|
||||
r.Post("/drawings/{drawingID}/embeds", a.handleCreateEmbed)
|
||||
r.Get("/drawings/{drawingID}/links", a.handleListLinks)
|
||||
r.Post("/drawings/{drawingID}/links", a.handleCreateLink)
|
||||
r.Get("/drawings/{drawingID}/thumbnail", a.handleThumbnail)
|
||||
r.Get("/templates", a.handleListTemplates)
|
||||
r.Get("/activity", a.handleListActivity)
|
||||
r.Get("/stats", a.handleStats)
|
||||
r.Get("/folders", a.handleListFolders)
|
||||
r.Post("/folders", a.handleCreateFolder)
|
||||
r.Get("/projects", a.handleListProjects)
|
||||
r.Post("/projects", a.handleCreateProject)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (a *API) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
if err := a.store.Ping(r.Context()); err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "unhealthy"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||
}
|
||||
|
||||
func (a *API) handleSetupStatus(w http.ResponseWriter, r *http.Request) {
|
||||
hasUsers, err := a.store.UserExists(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Failed to check setup status")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"has_users": hasUsers})
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
|
||||
const currentUserKey = contextKey("workspace_user")
|
||||
|
||||
func (a *API) requireSession(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
writeError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
user, session, err := a.store.UserBySessionToken(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
clearSessionCookie(w, r)
|
||||
writeError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
ctx := withUser(r.Context(), user, session)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func requireSameOriginMutation(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
expectedHTTP := "http://" + r.Host
|
||||
expectedHTTPS := "https://" + r.Host
|
||||
if origin != expectedHTTP && origin != expectedHTTPS {
|
||||
writeError(w, http.StatusForbidden, "Cross-origin mutation denied")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req, 64<<10) {
|
||||
return
|
||||
}
|
||||
// First-run: only allow signup if no users exist yet
|
||||
if !a.testMode {
|
||||
hasUsers, err := a.store.UserExists(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Failed to check setup status")
|
||||
return
|
||||
}
|
||||
if hasUsers {
|
||||
writeError(w, http.StatusForbidden, "Registration is closed. Contact an administrator.")
|
||||
return
|
||||
}
|
||||
}
|
||||
ipKey := "signup:" + clientIP(r)
|
||||
if !a.limiter.allow(ipKey) {
|
||||
writeError(w, http.StatusTooManyRequests, "Too many signup attempts")
|
||||
return
|
||||
}
|
||||
user, session, token, err := a.store.CreateUserWithPassword(r.Context(), req.Name, req.Email, req.Password)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, ErrConflict) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
writeError(w, status, err.Error())
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, r, token, session.ExpiresAt)
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"user": user, "session": session})
|
||||
}
|
||||
|
||||
func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req, 32<<10) {
|
||||
return
|
||||
}
|
||||
key := "login:" + clientIP(r) + ":" + strings.ToLower(strings.TrimSpace(req.Email))
|
||||
if !a.limiter.allow(key) {
|
||||
writeError(w, http.StatusTooManyRequests, "Too many login attempts")
|
||||
return
|
||||
}
|
||||
user, session, token, err := a.store.AuthenticatePassword(r.Context(), req.Email, req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "Invalid email or password")
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, r, token, session.ExpiresAt)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": user, "session": session})
|
||||
}
|
||||
|
||||
func (a *API) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
|
||||
if err := a.store.DeleteSession(r.Context(), cookie.Value); err != nil {
|
||||
logrus.WithError(err).Warn("failed to delete session")
|
||||
}
|
||||
}
|
||||
clearSessionCookie(w, r)
|
||||
writeJSON(w, http.StatusOK, map[string]any{})
|
||||
}
|
||||
|
||||
func (a *API) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (a *API) handleListTeams(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
teams, err := a.store.ListTeamsForUser(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Failed to list teams")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, teams)
|
||||
}
|
||||
|
||||
func (a *API) handleCreateTeam(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req, 64<<10) {
|
||||
return
|
||||
}
|
||||
team, err := a.store.CreateTeam(r.Context(), user.ID, req.Name, req.Slug)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, team)
|
||||
}
|
||||
|
||||
func (a *API) handleUpdateTeam(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
Slug *string `json:"slug"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req, 64<<10) {
|
||||
return
|
||||
}
|
||||
team, err := a.store.UpdateTeam(r.Context(), user.ID, chi.URLParam(r, "teamID"), req.Name, req.Slug)
|
||||
if err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, team)
|
||||
}
|
||||
|
||||
func (a *API) handleListTeamMembers(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
teamID := chi.URLParam(r, "teamID")
|
||||
if ok, err := a.store.UserCanAccessTeam(r.Context(), user.ID, teamID); err != nil || !ok {
|
||||
writeError(w, http.StatusForbidden, "Team access denied")
|
||||
return
|
||||
}
|
||||
members, err := a.store.ListTeamMembers(r.Context(), teamID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Failed to list team members")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, members)
|
||||
}
|
||||
|
||||
func (a *API) handleListDrawings(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
|
||||
drawings, err := a.store.ListDrawings(r.Context(), user.ID, teamID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrForbidden) {
|
||||
writeError(w, http.StatusForbidden, "Team access denied")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Failed to list drawings")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, drawings)
|
||||
}
|
||||
|
||||
func (a *API) handleCreateDrawing(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
var req CreateDrawingRequest
|
||||
if !decodeJSON(w, r, &req, 256<<10) {
|
||||
return
|
||||
}
|
||||
drawing, err := a.store.CreateDrawing(r.Context(), user.ID, req)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, ErrForbidden) {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
writeError(w, status, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, drawing)
|
||||
}
|
||||
|
||||
func (a *API) handleGetDrawing(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
drawing, err := a.store.GetDrawing(r.Context(), user.ID, chi.URLParam(r, "drawingID"))
|
||||
if err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, drawing)
|
||||
}
|
||||
|
||||
func (a *API) handleUpdateDrawing(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
var req UpdateDrawingRequest
|
||||
if !decodeJSON(w, r, &req, 256<<10) {
|
||||
return
|
||||
}
|
||||
drawing, err := a.store.UpdateDrawing(r.Context(), user.ID, chi.URLParam(r, "drawingID"), req)
|
||||
if err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, drawing)
|
||||
}
|
||||
|
||||
func (a *API) handleArchiveDrawing(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
if err := a.store.ArchiveDrawing(r.Context(), user.ID, chi.URLParam(r, "drawingID")); err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (a *API) handleListRevisions(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
revisions, err := a.store.ListRevisions(r.Context(), user.ID, chi.URLParam(r, "drawingID"))
|
||||
if err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, revisions)
|
||||
}
|
||||
|
||||
func (a *API) handleCreateRevision(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
var req CreateRevisionRequest
|
||||
if !decodeJSON(w, r, &req, 10<<20) {
|
||||
return
|
||||
}
|
||||
revision, err := a.store.CreateRevision(r.Context(), user.ID, chi.URLParam(r, "drawingID"), req)
|
||||
if err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, revision)
|
||||
}
|
||||
|
||||
func (a *API) handleThumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
drawingID := chi.URLParam(r, "drawingID")
|
||||
revisions, err := a.store.ListRevisions(r.Context(), user.ID, drawingID)
|
||||
if err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
if len(revisions) == 0 || revisions[0].Snapshot == nil {
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240" viewBox="0 0 320 240"><rect width="320" height="240" fill="#f8f9fa"/><text x="160" y="120" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#999">No preview</text></svg>`))
|
||||
return
|
||||
}
|
||||
|
||||
var snapshot struct {
|
||||
Elements []struct {
|
||||
Type string `json:"type"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
Stroke string `json:"strokeColor"`
|
||||
Bg string `json:"backgroundColor"`
|
||||
Text string `json:"text"`
|
||||
} `json:"elements"`
|
||||
}
|
||||
if err := json.Unmarshal(revisions[0].Snapshot, &snapshot); err != nil {
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240" viewBox="0 0 320 240"><rect width="320" height="240" fill="#f8f9fa"/><text x="160" y="120" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#999">Preview unavailable</text></svg>`))
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a simple SVG thumbnail from element bounding boxes
|
||||
const vw, vh = 320, 240
|
||||
var b strings.Builder
|
||||
b.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" width="` + itoa(vw) + `" height="` + itoa(vh) + `" viewBox="0 0 ` + itoa(vw) + ` ` + itoa(vh) + `">`)
|
||||
b.WriteString(`<rect width="` + itoa(vw) + `" height="` + itoa(vh) + `" fill="#ffffff"/>`)
|
||||
|
||||
// Compute bounding box to fit elements into view
|
||||
minX, minY, maxX, maxY := 1e9, 1e9, -1e9, -1e9
|
||||
for _, el := range snapshot.Elements {
|
||||
if el.X < minX {
|
||||
minX = el.X
|
||||
}
|
||||
if el.Y < minY {
|
||||
minY = el.Y
|
||||
}
|
||||
if el.X+el.Width > maxX {
|
||||
maxX = el.X + el.Width
|
||||
}
|
||||
if el.Y+el.Height > maxY {
|
||||
maxY = el.Y + el.Height
|
||||
}
|
||||
}
|
||||
if maxX <= minX || maxY <= minY {
|
||||
minX, minY, maxX, maxY = 0, 0, 320, 240
|
||||
}
|
||||
pad := 20.0
|
||||
scaleX := float64(vw-40) / (maxX - minX + 1e-6)
|
||||
scaleY := float64(vh-40) / (maxY - minY + 1e-6)
|
||||
scale := scaleX
|
||||
if scaleY < scaleX {
|
||||
scale = scaleY
|
||||
}
|
||||
offX := pad - minX*scale
|
||||
offY := pad - minY*scale
|
||||
|
||||
for _, el := range snapshot.Elements {
|
||||
x := el.X*scale + offX
|
||||
y := el.Y*scale + offY
|
||||
w := el.Width * scale
|
||||
h := el.Height * scale
|
||||
stroke := el.Stroke
|
||||
if stroke == "" {
|
||||
stroke = "#1e1e1e"
|
||||
}
|
||||
bg := el.Bg
|
||||
if bg == "" || bg == "transparent" {
|
||||
bg = "none"
|
||||
}
|
||||
switch el.Type {
|
||||
case "rectangle", "diamond":
|
||||
b.WriteString(`<rect x="` + ftoa(x) + `" y="` + ftoa(y) + `" width="` + ftoa(w) + `" height="` + ftoa(h) + `" fill="` + bg + `" stroke="` + stroke + `" stroke-width="1"/>`)
|
||||
case "ellipse":
|
||||
b.WriteString(`<ellipse cx="` + ftoa(x+w/2) + `" cy="` + ftoa(y+h/2) + `" rx="` + ftoa(w/2) + `" ry="` + ftoa(h/2) + `" fill="` + bg + `" stroke="` + stroke + `" stroke-width="1"/>`)
|
||||
case "line", "arrow":
|
||||
b.WriteString(`<line x1="` + ftoa(x) + `" y1="` + ftoa(y+h/2) + `" x2="` + ftoa(x+w) + `" y2="` + ftoa(y+h/2) + `" stroke="` + stroke + `" stroke-width="1"/>`)
|
||||
case "text":
|
||||
b.WriteString(`<text x="` + ftoa(x) + `" y="` + ftoa(y+h/2) + `" font-family="sans-serif" font-size="12" fill="` + stroke + `">` + htmlEscape(el.Text) + `</text>`)
|
||||
default:
|
||||
b.WriteString(`<rect x="` + ftoa(x) + `" y="` + ftoa(y) + `" width="` + ftoa(w) + `" height="` + ftoa(h) + `" fill="none" stroke="#ccc" stroke-width="0.5"/>`)
|
||||
}
|
||||
}
|
||||
b.WriteString(`</svg>`)
|
||||
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.Header().Set("Cache-Control", "max-age=60")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(b.String()))
|
||||
}
|
||||
|
||||
func ftoa(f float64) string { return strconv.FormatFloat(f, 'f', 2, 64) }
|
||||
func itoa(i int) string { return strconv.Itoa(i) }
|
||||
func htmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
return s
|
||||
}
|
||||
|
||||
func (a *API) handleListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
|
||||
templates, err := a.store.ListTemplates(r.Context(), user.ID, teamID)
|
||||
if err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, templates)
|
||||
}
|
||||
|
||||
func (a *API) handleListActivity(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
|
||||
activity, err := a.store.ListActivity(r.Context(), user.ID, teamID, 50)
|
||||
if err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, activity)
|
||||
}
|
||||
|
||||
func (a *API) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
|
||||
stats, err := a.store.WorkspaceStats(r.Context(), user.ID, teamID)
|
||||
if err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
func (a *API) handleListFolders(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
|
||||
folders, err := a.store.ListFolders(r.Context(), user.ID, teamID)
|
||||
if err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, folders)
|
||||
}
|
||||
|
||||
func (a *API) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
var req CreateFolderRequest
|
||||
if !decodeJSON(w, r, &req, 128<<10) {
|
||||
return
|
||||
}
|
||||
folder, err := a.store.CreateFolder(r.Context(), user.ID, req)
|
||||
if err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, folder)
|
||||
}
|
||||
|
||||
func (a *API) handleListProjects(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
|
||||
projects, err := a.store.ListProjects(r.Context(), user.ID, teamID)
|
||||
if err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, projects)
|
||||
}
|
||||
|
||||
func (a *API) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
var req CreateProjectRequest
|
||||
if !decodeJSON(w, r, &req, 128<<10) {
|
||||
return
|
||||
}
|
||||
project, err := a.store.CreateProject(r.Context(), user.ID, req)
|
||||
if err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, project)
|
||||
}
|
||||
|
||||
func (a *API) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if q == "" {
|
||||
writeJSON(w, http.StatusOK, []Drawing{})
|
||||
return
|
||||
}
|
||||
drawings, err := a.store.SearchDrawings(r.Context(), user.ID, q)
|
||||
if err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, drawings)
|
||||
}
|
||||
|
||||
func writeLookupError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrForbidden):
|
||||
writeError(w, http.StatusForbidden, "Access denied")
|
||||
case errors.Is(err, sql.ErrNoRows), errors.Is(err, ErrNotFound):
|
||||
writeError(w, http.StatusNotFound, "Resource not found")
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any, limit int64) bool {
|
||||
defer r.Body.Close()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(dst); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Invalid request body")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
logrus.WithError(err).Warn("failed to encode response")
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
|
||||
SetSessionCookie(w, r, token, expires)
|
||||
}
|
||||
|
||||
func SetSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Expires: expires,
|
||||
HttpOnly: true,
|
||||
Secure: isSecureRequest(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: isSecureRequest(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func isSecureRequest(r *http.Request) bool {
|
||||
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||
return strings.TrimSpace(strings.Split(forwarded, ",")[0])
|
||||
}
|
||||
host := r.RemoteAddr
|
||||
if idx := strings.LastIndex(host, ":"); idx > 0 {
|
||||
return host[:idx]
|
||||
}
|
||||
return host
|
||||
}
|
||||
Reference in New Issue
Block a user