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: 3 additions & 0 deletions api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ 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: 10 additions & 6 deletions api/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ 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 @@ -69,21 +71,21 @@ func main() {
}
}

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

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 @@ -93,7 +95,9 @@ func main() {
if chConsume, err := rmq.NewChannel(); err == nil {
defer chConsume.Close()
consumer := queue.NewConsumer(chConsume, log)
consumer.Register(queue.QueueEmails, queue.HandleSendEmail(queue.NoopEmailSender(log)))
instanceSettingStore := store.NewInstanceSettingStore(db)
emailSender := mail.NewSMTPEmailSender(instanceSettingStore, log)
consumer.Register(queue.QueueEmails, queue.HandleSendEmail(emailSender))
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: 3 additions & 0 deletions api/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ 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 @@ -84,6 +86,7 @@ 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: 0 additions & 9 deletions api/internal/handler/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,6 @@ 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 @@ -210,12 +208,6 @@ 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 @@ -530,7 +522,6 @@ 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: 0 additions & 106 deletions api/internal/handler/favorite.go

This file was deleted.

89 changes: 0 additions & 89 deletions api/internal/handler/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@ 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 @@ -295,89 +292,3 @@ 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 {
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: 14 additions & 31 deletions api/internal/handler/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ 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 @@ -117,22 +116,19 @@ 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"`
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"`
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"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "detail": err.Error()})
Expand All @@ -151,19 +147,6 @@ 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 @@ -190,7 +173,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, 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)
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)
if err != nil {
if err == service.ErrProjectNotFound || err == service.ErrProjectForbidden {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
Expand Down
Loading