package application import ( "context" "encoding/json" "errors" "fmt" "io" "regexp" "strings" "time" "unicode/utf8" "img_infinite_canvas/internal/domain/brandkit" "img_infinite_canvas/internal/domain/design" ) const ( maxBrandKitDocumentBytes = 24 << 20 maxBrandKitContextRunes = 12000 ) var ( brandKitIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`) brandColorPattern = regexp.MustCompile(`^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$`) ) type BrandKitService struct { store brandkit.Store now func() time.Time } type ResolvedBrandKit struct { ID string Context string ReferenceImages []string } type brandKitDocument struct { Version int `json:"version"` ID string `json:"id"` Name string `json:"name"` BrandName string `json:"brandName"` Tagline string `json:"tagline"` Summary string `json:"summary"` Audience string `json:"audience"` Positioning string `json:"positioning"` Personality string `json:"personality"` Voice brandKitVoice `json:"voice"` Logos []brandKitLogo `json:"logos"` CoverImage *brandKitReferenceImage `json:"coverImage,omitempty"` ColorGroups []brandKitColorGroup `json:"colorGroups"` Typography []brandKitTypography `json:"typography"` ReferenceImages []brandKitReferenceImage `json:"referenceImages"` Visual brandKitVisual `json:"visual"` ApplyToNewProjects bool `json:"applyToNewProjects"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } type brandKitVoice struct { Traits string `json:"traits"` Guidance string `json:"guidance"` Sample string `json:"sample"` } type brandKitLogo struct { ID string `json:"id"` Name string `json:"name"` Variant string `json:"variant"` Usage string `json:"usage"` Source string `json:"source"` DataURL string `json:"dataUrl,omitempty"` } type brandKitColorGroup struct { ID string `json:"id"` Name string `json:"name"` Colors []brandKitColor `json:"colors"` } type brandKitColor struct { ID string `json:"id"` Name string `json:"name"` Hex string `json:"hex"` Usage string `json:"usage"` } type brandKitTypography struct { ID string `json:"id"` Label string `json:"label"` Family string `json:"family"` Weight string `json:"weight"` Sizes string `json:"sizes"` Description string `json:"description"` FontFile *brandKitFontFile `json:"fontFile,omitempty"` } type brandKitFontFile struct { Name string `json:"name"` Format string `json:"format"` Size int64 `json:"size"` DataURL string `json:"dataUrl"` } type brandKitReferenceImage struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Source string `json:"source"` DataURL string `json:"dataUrl,omitempty"` } type brandKitVisual struct { Keywords string `json:"keywords"` Composition string `json:"composition"` Imagery string `json:"imagery"` Dos string `json:"dos"` Donts string `json:"donts"` Prompt string `json:"prompt"` } func NewBrandKitService(store brandkit.Store) *BrandKitService { return &BrandKitService{store: store, now: time.Now} } func (s *BrandKitService) Close() error { if s == nil || s.store == nil { return nil } return s.store.Close() } func (s *BrandKitService) List(ctx context.Context) ([]brandkit.Kit, error) { if s == nil || s.store == nil { return []brandkit.Kit{}, nil } return s.store.List(ctx, design.UserIDFromContext(ctx)) } func (s *BrandKitService) Get(ctx context.Context, id string) (brandkit.Kit, error) { id = strings.TrimSpace(id) if !brandKitIDPattern.MatchString(id) { return brandkit.Kit{}, fmt.Errorf("%w: invalid id", brandkit.ErrInvalidInput) } return s.store.Get(ctx, design.UserIDFromContext(ctx), id) } func (s *BrandKitService) Upsert(ctx context.Context, id string, document string, isDefault bool) (brandkit.Kit, error) { if s == nil || s.store == nil { return brandkit.Kit{}, brandkit.ErrNotFound } id = strings.TrimSpace(id) if !brandKitIDPattern.MatchString(id) { return brandkit.Kit{}, fmt.Errorf("%w: invalid id", brandkit.ErrInvalidInput) } if len(document) == 0 || len(document) > maxBrandKitDocumentBytes { return brandkit.Kit{}, fmt.Errorf("%w: document size is invalid", brandkit.ErrInvalidInput) } now := s.now().UTC() createdAt := now if existing, err := s.store.Get(ctx, design.UserIDFromContext(ctx), id); err == nil { createdAt = existing.CreatedAt } else if !errors.Is(err, brandkit.ErrNotFound) { return brandkit.Kit{}, err } doc, err := normalizeBrandKitDocument(id, []byte(document), isDefault, createdAt, now) if err != nil { return brandkit.Kit{}, err } canonical, err := json.Marshal(doc) if err != nil { return brandkit.Kit{}, fmt.Errorf("%w: encode document", brandkit.ErrInvalidInput) } return s.store.Upsert(ctx, brandkit.Kit{ ID: id, UserID: design.UserIDFromContext(ctx), Name: doc.Name, Document: canonical, IsDefault: isDefault, CreatedAt: createdAt, UpdatedAt: now, }) } func (s *BrandKitService) Delete(ctx context.Context, id string) error { id = strings.TrimSpace(id) if !brandKitIDPattern.MatchString(id) { return fmt.Errorf("%w: invalid id", brandkit.ErrInvalidInput) } return s.store.Delete(ctx, design.UserIDFromContext(ctx), id) } func (s *BrandKitService) ResolveContext(ctx context.Context, id string) (string, error) { resolved, err := s.Resolve(ctx, id) if err != nil { return "", err } return resolved.Context, nil } func (s *BrandKitService) Resolve(ctx context.Context, id string) (ResolvedBrandKit, error) { kit, err := s.Get(ctx, id) if err != nil { return ResolvedBrandKit{}, err } compiled, err := compileBrandKitContext(kit.Document) if err != nil { return ResolvedBrandKit{}, err } references, err := brandKitReferenceImages(kit.Document) if err != nil { return ResolvedBrandKit{}, err } return ResolvedBrandKit{ID: kit.ID, Context: compiled, ReferenceImages: references}, nil } func (s *BrandKitService) ResolveDefaultContext(ctx context.Context) (string, string, error) { resolved, err := s.ResolveDefault(ctx) if err != nil { return "", "", err } return resolved.ID, resolved.Context, nil } func (s *BrandKitService) ResolveDefault(ctx context.Context) (ResolvedBrandKit, error) { if s == nil || s.store == nil { return ResolvedBrandKit{}, brandkit.ErrNotFound } kit, err := s.store.GetDefault(ctx, design.UserIDFromContext(ctx)) if err != nil { return ResolvedBrandKit{}, err } compiled, err := compileBrandKitContext(kit.Document) if err != nil { return ResolvedBrandKit{}, err } references, err := brandKitReferenceImages(kit.Document) if err != nil { return ResolvedBrandKit{}, err } return ResolvedBrandKit{ID: kit.ID, Context: compiled, ReferenceImages: references}, nil } func normalizeBrandKitDocument(id string, raw []byte, isDefault bool, createdAt time.Time, updatedAt time.Time) (brandKitDocument, error) { var doc brandKitDocument decoder := json.NewDecoder(strings.NewReader(string(raw))) decoder.DisallowUnknownFields() if err := decoder.Decode(&doc); err != nil { return brandKitDocument{}, fmt.Errorf("%w: malformed document", brandkit.ErrInvalidInput) } if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { return brandKitDocument{}, fmt.Errorf("%w: malformed document", brandkit.ErrInvalidInput) } if doc.Version != 1 || strings.TrimSpace(doc.ID) != id { return brandKitDocument{}, fmt.Errorf("%w: unsupported version or mismatched id", brandkit.ErrInvalidInput) } doc.Name = strings.TrimSpace(doc.Name) if doc.Name == "" || !textWithin(doc.Name, 120) { return brandKitDocument{}, fmt.Errorf("%w: name is required", brandkit.ErrInvalidInput) } if err := validateBrandKitDocument(doc); err != nil { return brandKitDocument{}, err } doc.ID = id doc.ApplyToNewProjects = isDefault doc.CreatedAt = createdAt.UTC().Format(time.RFC3339) doc.UpdatedAt = updatedAt.UTC().Format(time.RFC3339) return doc, nil } func validateBrandKitDocument(doc brandKitDocument) error { for _, value := range []string{doc.BrandName, doc.Tagline, doc.Summary, doc.Audience, doc.Positioning, doc.Personality, doc.Voice.Traits, doc.Voice.Guidance, doc.Voice.Sample} { if !textWithin(value, 4000) { return fmt.Errorf("%w: text field is too long", brandkit.ErrInvalidInput) } } if len(doc.Logos) > 12 || len(doc.ColorGroups) > 20 || len(doc.Typography) > 20 || len(doc.ReferenceImages) > 20 { return fmt.Errorf("%w: too many document items", brandkit.ErrInvalidInput) } for _, logo := range doc.Logos { if !brandKitIDPattern.MatchString(logo.ID) || !oneOf(logo.Variant, "primary", "secondary", "mark") || !oneOf(logo.Source, "upload", "moteva-mark") || !textWithin(logo.Name, 240) || !textWithin(logo.Usage, 4000) || !validImageDataURL(logo.DataURL) { return fmt.Errorf("%w: invalid logo", brandkit.ErrInvalidInput) } } if doc.CoverImage != nil { if err := validateReferenceImage(*doc.CoverImage); err != nil { return err } } for _, group := range doc.ColorGroups { if !brandKitIDPattern.MatchString(group.ID) || !textWithin(group.Name, 120) || len(group.Colors) > 32 { return fmt.Errorf("%w: invalid color group", brandkit.ErrInvalidInput) } for _, color := range group.Colors { if !brandKitIDPattern.MatchString(color.ID) || !brandColorPattern.MatchString(strings.TrimSpace(color.Hex)) || !textWithin(color.Name, 120) || !textWithin(color.Usage, 1000) { return fmt.Errorf("%w: invalid color", brandkit.ErrInvalidInput) } } } for _, item := range doc.Typography { if !brandKitIDPattern.MatchString(item.ID) || !textWithin(item.Label, 120) || !textWithin(item.Family, 240) || !textWithin(item.Weight, 80) || !textWithin(item.Sizes, 240) || !textWithin(item.Description, 4000) { return fmt.Errorf("%w: invalid typography", brandkit.ErrInvalidInput) } if item.FontFile != nil && (strings.TrimSpace(item.FontFile.Name) == "" || !textWithin(item.FontFile.Name, 240) || !textWithin(item.FontFile.Format, 32) || item.FontFile.Size < 0 || item.FontFile.Size > 8<<20 || !validDataURL(item.FontFile.DataURL)) { return fmt.Errorf("%w: invalid font file", brandkit.ErrInvalidInput) } } for _, image := range doc.ReferenceImages { if err := validateReferenceImage(image); err != nil { return err } } for _, value := range []string{doc.Visual.Keywords, doc.Visual.Composition, doc.Visual.Imagery, doc.Visual.Dos, doc.Visual.Donts} { if !textWithin(value, 4000) { return fmt.Errorf("%w: visual field is too long", brandkit.ErrInvalidInput) } } if !textWithin(doc.Visual.Prompt, 8000) { return fmt.Errorf("%w: reusable direction is too long", brandkit.ErrInvalidInput) } return nil } func validateReferenceImage(image brandKitReferenceImage) error { if !brandKitIDPattern.MatchString(image.ID) || !oneOf(image.Source, "upload", "moteva-board") || !textWithin(image.Name, 240) || !textWithin(image.Description, 4000) || !validImageDataURL(image.DataURL) { return fmt.Errorf("%w: invalid reference image", brandkit.ErrInvalidInput) } return nil } func compileBrandKitContext(raw []byte) (string, error) { var doc brandKitDocument if err := json.Unmarshal(raw, &doc); err != nil { return "", fmt.Errorf("%w: malformed stored document", brandkit.ErrInvalidInput) } sections := []string{ "Brand Kit: " + doc.Name, brandKitContextSection("Brand", compactValues(doc.BrandName, doc.Tagline, doc.Summary)), brandKitContextSection("Audience", compactValues(doc.Audience)), brandKitContextSection("Positioning and personality", compactValues(doc.Positioning, doc.Personality)), brandKitContextSection("Voice", compactValues(doc.Voice.Traits, doc.Voice.Guidance, doc.Voice.Sample)), } var logoLines []string for _, logo := range doc.Logos { logoLines = appendNonEmpty(logoLines, strings.Join(compactValues(logo.Name, logo.Variant, logo.Usage), " | ")) } sections = append(sections, brandKitContextSection("Logo rules", logoLines)) if doc.CoverImage != nil { sections = append(sections, brandKitContextSection("Cover image", compactValues(doc.CoverImage.Name, doc.CoverImage.Description))) } var colorLines []string for _, group := range doc.ColorGroups { for _, color := range group.Colors { colorLines = append(colorLines, strings.Join(compactValues(group.Name+" / "+color.Name, color.Hex, color.Usage), " | ")) } } sections = append(sections, brandKitContextSection("Color system", colorLines)) var typeLines []string for _, item := range doc.Typography { fileName := "" if item.FontFile != nil { fileName = item.FontFile.Name } typeLines = appendNonEmpty(typeLines, strings.Join(compactValues(item.Label, item.Family, fileName, item.Weight, item.Sizes, item.Description), " | ")) } sections = append(sections, brandKitContextSection("Typography", typeLines)) var referenceLines []string for _, image := range doc.ReferenceImages { referenceLines = appendNonEmpty(referenceLines, strings.Join(compactValues(image.Name, image.Description), ": ")) } sections = append(sections, brandKitContextSection("Reference images", referenceLines)) sections = append(sections, brandKitContextSection("Attached Brand Kit images (appended after user references)", brandKitReferenceImageContext(doc))) sections = append(sections, brandKitContextSection("Visual direction", compactValues(doc.Visual.Keywords, doc.Visual.Composition, doc.Visual.Imagery)), brandKitContextSection("Use", compactValues(doc.Visual.Dos)), brandKitContextSection("Avoid", compactValues(doc.Visual.Donts)), brandKitContextSection("Reusable direction", compactValues(doc.Visual.Prompt)), ) sections = compactValues(sections...) contextBlock := "Binding creative system for this canvas. Apply it to planning, copy, composition, typography, color, and generated imagery unless the user explicitly overrides a rule in the current request.\n\n" + strings.Join(sections, "\n\n") return clampRunes(contextBlock, maxBrandKitContextRunes), nil } func brandKitReferenceImages(raw []byte) ([]string, error) { var doc brandKitDocument if err := json.Unmarshal(raw, &doc); err != nil { return nil, fmt.Errorf("%w: malformed stored document", brandkit.ErrInvalidInput) } images := make([]string, 0, len(doc.Logos)+len(doc.ReferenceImages)+1) seen := make(map[string]struct{}) add := func(value string) { value = strings.TrimSpace(value) if value == "" { return } if _, exists := seen[value]; exists { return } seen[value] = struct{}{} images = append(images, value) } for _, logo := range doc.Logos { add(logo.DataURL) } if doc.CoverImage != nil { add(doc.CoverImage.DataURL) } for _, image := range doc.ReferenceImages { add(image.DataURL) } return images, nil } func brandKitReferenceImageContext(doc brandKitDocument) []string { lines := make([]string, 0, len(doc.Logos)+len(doc.ReferenceImages)+1) index := 0 for _, logo := range doc.Logos { if strings.TrimSpace(logo.DataURL) == "" { continue } index++ lines = append(lines, fmt.Sprintf("Brand image %d | logo | %s", index, strings.Join(compactValues(logo.Name, logo.Variant, logo.Usage), " | "))) } if doc.CoverImage != nil && strings.TrimSpace(doc.CoverImage.DataURL) != "" { index++ lines = append(lines, fmt.Sprintf("Brand image %d | cover | %s", index, strings.Join(compactValues(doc.CoverImage.Name, doc.CoverImage.Description), " | "))) } for _, image := range doc.ReferenceImages { if strings.TrimSpace(image.DataURL) == "" { continue } index++ lines = append(lines, fmt.Sprintf("Brand image %d | visual reference | %s", index, strings.Join(compactValues(image.Name, image.Description), " | "))) } return lines } func brandKitContextSection(title string, values []string) string { values = compactValues(values...) if len(values) == 0 { return "" } return title + ":\n- " + strings.Join(values, "\n- ") } func compactValues(values ...string) []string { result := make([]string, 0, len(values)) for _, value := range values { if value = strings.TrimSpace(value); value != "" { result = append(result, value) } } return result } func appendNonEmpty(values []string, value string) []string { if value = strings.TrimSpace(value); value != "" { return append(values, value) } return values } func textWithin(value string, max int) bool { return utf8.RuneCountInString(value) <= max } func validDataURL(value string) bool { value = strings.TrimSpace(value) return value == "" || (strings.HasPrefix(value, "data:") && len(value) <= 12<<20) } func validImageDataURL(value string) bool { value = strings.TrimSpace(value) if value == "" { return true } if len(value) > 12<<20 { return false } for _, prefix := range []string{ "data:image/png;base64,", "data:image/jpeg;base64,", "data:image/webp;base64,", "data:image/svg+xml;base64,", } { if strings.HasPrefix(value, prefix) { return true } } return false } func oneOf(value string, allowed ...string) bool { for _, candidate := range allowed { if value == candidate { return true } } return false } func clampRunes(value string, max int) string { if max <= 0 || utf8.RuneCountInString(value) <= max { return value } runes := []rune(value) return string(runes[:max]) }