Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,6 @@ REDIS_DB=0
# RabbitMQ
RABBITMQ_URL=amqp://guest:guest@localhost:5672/

# Frontend URL for invite links in emails (e.g. https://app.example.com). If unset, CORS_ORIGIN is used.
APP_BASE_URL=https://app.example.com

# MinIO (S3-compatible)
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY_ID=minioadmin
Expand Down
16 changes: 6 additions & 10 deletions api/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,11 @@ import (

"github.com/Devlaner/devlane/api/internal/config"
"github.com/Devlaner/devlane/api/internal/database"
"github.com/Devlaner/devlane/api/internal/mail"
"github.com/Devlaner/devlane/api/internal/minio"
"github.com/Devlaner/devlane/api/internal/queue"
"github.com/Devlaner/devlane/api/internal/rabbitmq"
"github.com/Devlaner/devlane/api/internal/redis"
"github.com/Devlaner/devlane/api/internal/router"
"github.com/Devlaner/devlane/api/internal/store"
)

func main() {
Expand Down Expand Up @@ -71,21 +69,21 @@ func main() {
}
}

// MinIO
mc, err := minio.New(cfg, log)
if err != nil {
// MinIO (optional: file uploads for covers, avatars, logos)
var mc *minio.Client
if client, err := minio.New(cfg, log); err != nil {
log.Warn("minio", "error", err)
} else {
_ = mc
mc = client
}

r := router.New(router.Config{
Log: log,
DB: db,
Redis: rdb,
Queue: queuePublisher,
Minio: mc,
CORSAllowOrigin: cfg.CORSAllowOrigin,
AppBaseURL: cfg.AppBaseURL,
})

// Start task consumer when RabbitMQ is available
Expand All @@ -95,9 +93,7 @@ func main() {
if chConsume, err := rmq.NewChannel(); err == nil {
defer chConsume.Close()
consumer := queue.NewConsumer(chConsume, log)
instanceSettingStore := store.NewInstanceSettingStore(db)
emailSender := mail.NewSMTPEmailSender(instanceSettingStore, log)
consumer.Register(queue.QueueEmails, queue.HandleSendEmail(emailSender))
consumer.Register(queue.QueueEmails, queue.HandleSendEmail(queue.NoopEmailSender(log)))
consumer.Register(queue.QueueWebhooks, queue.HandleWebhook(queue.NoopWebhookDeliverer(log)))
if err := consumer.Run(consumerCtx, []string{queue.QueueEmails, queue.QueueWebhooks}); err != nil {
log.Warn("queue consumer", "error", err)
Expand Down
3 changes: 0 additions & 3 deletions api/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,6 @@ type Config struct {
MigrationsPath string

CORSAllowOrigin string
// AppBaseURL is the public URL of the frontend (e.g. https://app.example.com). Used for invite links in emails. If empty, CORSAllowOrigin is used.
AppBaseURL string
}

func (c *Config) DSN() string {
Expand Down Expand Up @@ -86,7 +84,6 @@ func Load() (*Config, error) {
MinIOUseSSL: minioSSL,
MigrationsPath: getEnv("MIGRATIONS_PATH", "migrations"),
CORSAllowOrigin: getEnv("CORS_ORIGIN", "http://localhost:5173"),
AppBaseURL: getEnv("APP_BASE_URL", ""),
}

return cfg, nil
Expand Down
9 changes: 9 additions & 0 deletions api/internal/handler/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ type UpdateMeRequest struct {
LastName *string `json:"last_name"`
DisplayName *string `json:"display_name"`
UserTimezone *string `json:"user_timezone"`
Avatar *string `json:"avatar"`
CoverImage *string `json:"cover_image"`
}

// UpdateMe updates the authenticated user's profile (email is not updatable).
Expand Down Expand Up @@ -208,6 +210,12 @@ func (h *AuthHandler) UpdateMe(c *gin.Context) {
if req.UserTimezone != nil {
user.UserTimezone = *req.UserTimezone
}
if req.Avatar != nil {
user.Avatar = *req.Avatar
}
if req.CoverImage != nil {
user.CoverImage = *req.CoverImage
}
if err := h.Auth.UpdateProfile(c.Request.Context(), user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Update failed"})
return
Expand Down Expand Up @@ -522,6 +530,7 @@ func userResponse(u *model.User) gin.H {
"last_name": u.LastName,
"display_name": u.DisplayName,
"avatar": u.Avatar,
"cover_image": u.CoverImage,
"is_active": u.IsActive,
"is_onboarded": u.IsOnboarded,
"date_joined": u.DateJoined,
Expand Down
106 changes: 106 additions & 0 deletions api/internal/handler/favorite.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package handler

import (
"net/http"

"github.com/Devlaner/devlane/api/internal/middleware"
"github.com/Devlaner/devlane/api/internal/service"
"github.com/Devlaner/devlane/api/internal/store"
"github.com/gin-gonic/gin"
)

// FavoriteHandler serves user favorite project endpoints.
type FavoriteHandler struct {
Project *service.ProjectService
Favorites *store.UserFavoriteStore
}

// ListFavoriteProjects returns the list of favorited project IDs for the current user.
// GET /api/users/me/favorite-projects/
func (h *FavoriteHandler) ListFavoriteProjects(c *gin.Context) {
user := middleware.GetUser(c)
if user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
if h.Favorites == nil {
c.JSON(http.StatusOK, gin.H{"project_ids": []string{}})
return
}
ids, err := h.Favorites.ListProjectIDsByUser(c.Request.Context(), user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load favorites"})
return
}
strIds := make([]string, 0, len(ids))
for _, id := range ids {
strIds = append(strIds, id.String())
}
c.JSON(http.StatusOK, gin.H{"project_ids": strIds})
}

// AddFavoriteProject adds a project to the current user's favorites.
// POST /api/workspaces/:slug/projects/:projectId/favorite
func (h *FavoriteHandler) AddFavoriteProject(c *gin.Context) {
user := middleware.GetUser(c)
if user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
slug := c.Param("slug")
projectID, ok := projectID(c)
if !ok {
return
}
if h.Favorites == nil || h.Project == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Favorites not available"})
return
}
project, err := h.Project.GetByID(c.Request.Context(), slug, projectID, user.ID)
if err != nil {
if err == service.ErrProjectNotFound || err == service.ErrProjectForbidden {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add favorite"})
return
}
if err := h.Favorites.AddProject(c.Request.Context(), user.ID, project.WorkspaceID, projectID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add favorite"})
return
}
c.JSON(http.StatusOK, gin.H{"project_id": projectID.String()})
}

// RemoveFavoriteProject removes a project from the current user's favorites.
// DELETE /api/workspaces/:slug/projects/:projectId/favorite
func (h *FavoriteHandler) RemoveFavoriteProject(c *gin.Context) {
user := middleware.GetUser(c)
if user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
projectID, ok := projectID(c)
if !ok {
return
}
if h.Favorites == nil || h.Project == nil {
c.JSON(http.StatusOK, gin.H{"message": "ok"})
return
}
// Verify user has access by resolving workspace + project
slug := c.Param("slug")
if _, err := h.Project.GetByID(c.Request.Context(), slug, projectID, user.ID); err != nil {
if err == service.ErrProjectNotFound || err == service.ErrProjectForbidden {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove favorite"})
return
}
if err := h.Favorites.RemoveProject(c.Request.Context(), user.ID, projectID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove favorite"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "ok"})
}
89 changes: 89 additions & 0 deletions api/internal/handler/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package handler
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"

"github.com/Devlaner/devlane/api/internal/auth"
Expand Down Expand Up @@ -292,3 +295,89 @@ func (h *InstanceSettingsHandler) UpdateSetting(c *gin.Context) {
responseValue := decryptSectionSecrets(key, value)
c.JSON(http.StatusOK, gin.H{"key": key, "value": responseValue})
}

// unsplashPhoto is a single photo from Unsplash API response.
type unsplashPhoto struct {
ID string `json:"id"`
URLs struct {
Full string `json:"full"`
Regular string `json:"regular"`
Thumb string `json:"thumb"`
} `json:"urls"`
}

// unsplashSearchResponse is the Unsplash search API response.
type unsplashSearchResponse struct {
Results []unsplashPhoto `json:"results"`
}

// UnsplashSearchResult is a simplified photo returned by our proxy.
type UnsplashSearchResult struct {
ID string `json:"id"`
URL string `json:"url"`
Thumb string `json:"thumb"`
}

// UnsplashSearch proxies search to Unsplash API using instance image settings key (auth required).
// GET /api/instance/unsplash/search?q=...
func (h *InstanceSettingsHandler) UnsplashSearch(c *gin.Context) {
if h.Settings == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Settings not available"})
return
}
row, err := h.Settings.Get(c.Request.Context(), "image")
if err != nil || row == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsplash is not configured"})
return
}
decrypted := decryptSectionSecrets("image", row.Value)
keyVal, _ := decrypted["unsplash_access_key"].(string)
keyVal = strings.TrimSpace(keyVal)
if keyVal == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsplash is not configured"})
return
}

q := strings.TrimSpace(c.Query("q"))
if q == "" {
c.JSON(http.StatusOK, gin.H{"results": []UnsplashSearchResult{}})
return
}

apiURL := "https://api.unsplash.com/search/photos?query=" + url.QueryEscape(q) + "&per_page=20"
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, apiURL, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search"})
return
}
req.Header.Set("Authorization", "Client-ID "+keyVal)

resp, err := http.DefaultClient.Do(req)
if err != nil {
Comment on lines +347 to +356
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UnsplashSearch uses http.DefaultClient.Do(req) which has no timeout by default. A slow/hung upstream Unsplash request can tie up handler goroutines and degrade API availability. Consider using an http.Client with a reasonable timeout (or enforcing a context deadline) for this outbound call.

Copilot uses AI. Check for mistakes.
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search"})
return
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
c.JSON(http.StatusBadGateway, gin.H{"error": "Unsplash search failed"})
return
}

var payload unsplashSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid response"})
return
}

results := make([]UnsplashSearchResult, 0, len(payload.Results))
for _, p := range payload.Results {
u := p.URLs.Regular
if u == "" {
u = p.URLs.Full
}
results = append(results, UnsplashSearchResult{ID: p.ID, URL: u, Thumb: p.URLs.Thumb})
}
c.JSON(http.StatusOK, gin.H{"results": results})
}
45 changes: 31 additions & 14 deletions api/internal/handler/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"net/http"

"github.com/Devlaner/devlane/api/internal/middleware"
"github.com/Devlaner/devlane/api/internal/model"
"github.com/Devlaner/devlane/api/internal/service"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
Expand Down Expand Up @@ -116,19 +117,22 @@ func (h *ProjectHandler) Update(c *gin.Context) {
return
}
var body struct {
Name string `json:"name"`
Identifier string `json:"identifier"`
Description *string `json:"description"`
Timezone *string `json:"timezone"`
ProjectLeadID *string `json:"project_lead_id"`
DefaultAssigneeID *string `json:"default_assignee_id"`
GuestViewAllFeatures *bool `json:"guest_view_all_features"`
ModuleView *bool `json:"module_view"`
CycleView *bool `json:"cycle_view"`
IssueViewsView *bool `json:"issue_views_view"`
PageView *bool `json:"page_view"`
IntakeView *bool `json:"intake_view"`
IsTimeTrackingEnabled *bool `json:"is_time_tracking_enabled"`
Name string `json:"name"`
Identifier string `json:"identifier"`
Description *string `json:"description"`
Timezone *string `json:"timezone"`
CoverImage *string `json:"cover_image"`
Emoji *string `json:"emoji"`
IconProp map[string]interface{} `json:"icon_prop"`
ProjectLeadID *string `json:"project_lead_id"`
DefaultAssigneeID *string `json:"default_assignee_id"`
GuestViewAllFeatures *bool `json:"guest_view_all_features"`
ModuleView *bool `json:"module_view"`
CycleView *bool `json:"cycle_view"`
IssueViewsView *bool `json:"issue_views_view"`
PageView *bool `json:"page_view"`
IntakeView *bool `json:"intake_view"`
IsTimeTrackingEnabled *bool `json:"is_time_tracking_enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "detail": err.Error()})
Expand All @@ -147,6 +151,19 @@ func (h *ProjectHandler) Update(c *gin.Context) {
if body.Timezone != nil {
timezone = body.Timezone
}
var coverImage *string
if body.CoverImage != nil {
coverImage = body.CoverImage
}
var iconProp *model.JSONMap
if body.Emoji != nil && *body.Emoji != "" {
// When setting emoji, clear icon_prop
empty := model.JSONMap{}
iconProp = &empty
} else if len(body.IconProp) > 0 {
ip := model.JSONMap(body.IconProp)
iconProp = &ip
}
var projectLeadIDPtr *uuid.UUID
if body.ProjectLeadID != nil {
if *body.ProjectLeadID == "" {
Expand All @@ -173,7 +190,7 @@ func (h *ProjectHandler) Update(c *gin.Context) {
defaultAssigneeIDPtr = &id
}
}
p, err := h.Project.Update(c.Request.Context(), slug, projectID, user.ID, name, identifier, description, timezone, body.ProjectLeadID != nil, projectLeadIDPtr, body.DefaultAssigneeID != nil, defaultAssigneeIDPtr, body.GuestViewAllFeatures, body.ModuleView, body.CycleView, body.IssueViewsView, body.PageView, body.IntakeView, body.IsTimeTrackingEnabled)
p, err := h.Project.Update(c.Request.Context(), slug, projectID, user.ID, name, identifier, description, timezone, coverImage, body.Emoji, iconProp, body.ProjectLeadID != nil, projectLeadIDPtr, body.DefaultAssigneeID != nil, defaultAssigneeIDPtr, body.GuestViewAllFeatures, body.ModuleView, body.CycleView, body.IssueViewsView, body.PageView, body.IntakeView, body.IsTimeTrackingEnabled)
if err != nil {
if err == service.ErrProjectNotFound || err == service.ErrProjectForbidden {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
Expand Down
Loading