first commit

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
HouYunFei
2026-05-19 07:53:53 +08:00
commit 472bf8b732
133 changed files with 16869 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
package service
import (
"time"
"github.com/basketikun/infinite-canvas/model"
"github.com/basketikun/infinite-canvas/repository"
)
func ListAssets(q model.Query) (model.AssetList, error) {
items, total, err := repository.ListAssets(q)
if err != nil {
return model.AssetList{}, err
}
tags, err := repository.ListAssetTags(q)
if err != nil {
return model.AssetList{}, err
}
return model.AssetList{Items: items, Tags: tags, Total: int(total)}, nil
}
func SaveAsset(item model.Asset) (model.Asset, error) {
now := time.Now().Format(time.RFC3339)
if item.Type == "" {
item.Type = model.AssetTypeText
}
if item.ID == "" {
item.ID = newID("asset")
item.CreatedAt = now
}
item.UpdatedAt = now
if item.CoverURL == "" {
item.CoverURL = assetCoverURL(item)
}
return repository.SaveAsset(item)
}
func DeleteAsset(id string) error {
return repository.DeleteAsset(id)
}
func assetCoverURL(item model.Asset) string {
if item.CoverURL != "" {
return item.CoverURL
}
if item.Type == model.AssetTypeImage {
return item.URL
}
return ""
}
+221
View File
@@ -0,0 +1,221 @@
package service
import (
"errors"
"log"
"strings"
"time"
"github.com/basketikun/infinite-canvas/config"
"github.com/basketikun/infinite-canvas/model"
"github.com/basketikun/infinite-canvas/repository"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
type TokenClaims struct {
UserID string `json:"userId"`
Username string `json:"username"`
Role model.UserRole `json:"role"`
jwt.RegisteredClaims
}
func EnsureDefaultAdmin() error {
if strings.TrimSpace(config.Cfg.AdminUsername) == "" || strings.TrimSpace(config.Cfg.AdminPassword) == "" {
return nil
}
WarnDefaultSecurityConfig()
hasAdmin, err := repository.HasAdmin()
if err != nil || hasAdmin {
return err
}
hash, err := hashPassword(config.Cfg.AdminPassword)
if err != nil {
return err
}
_, err = repository.SaveUser(model.User{
ID: newID("user"),
Username: strings.TrimSpace(config.Cfg.AdminUsername),
Password: hash,
Role: model.UserRoleAdmin,
CreatedAt: now(),
UpdatedAt: now(),
})
return err
}
func Register(username string, password string) (model.AuthSession, error) {
return model.AuthSession{}, errors.New("注册功能暂时关闭")
username = strings.TrimSpace(username)
if strings.ContainsAny(username, " \t\r\n") {
return model.AuthSession{}, errors.New("用户名不能包含空格")
}
if username == "" || password == "" {
return model.AuthSession{}, errors.New("用户名和密码不能为空")
}
if _, ok, err := repository.GetUserByUsername(username); err != nil || ok {
if err != nil {
return model.AuthSession{}, err
}
return model.AuthSession{}, errors.New("用户名已存在")
}
hash, err := hashPassword(password)
if err != nil {
return model.AuthSession{}, err
}
user, err := repository.SaveUser(model.User{
ID: newID("user"),
Username: username,
Password: hash,
Role: model.UserRoleUser,
CreatedAt: now(),
UpdatedAt: now(),
})
if err != nil {
return model.AuthSession{}, err
}
return newSession(user)
}
func Login(username string, password string) (model.AuthSession, error) {
user, ok, err := repository.GetUserByUsername(strings.TrimSpace(username))
if err != nil {
return model.AuthSession{}, err
}
if !ok || bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
return model.AuthSession{}, errors.New("用户名或密码错误")
}
return newSession(user)
}
func ParseToken(tokenText string) (TokenClaims, error) {
claims := TokenClaims{}
token, err := jwt.ParseWithClaims(tokenText, &claims, func(token *jwt.Token) (any, error) {
return []byte(config.Cfg.JWTSecret), nil
})
if err != nil || !token.Valid {
return TokenClaims{}, errors.New("登录状态无效")
}
return claims, nil
}
func CurrentAuthUser(tokenText string) (model.AuthUser, bool) {
claims, err := ParseToken(tokenText)
if err != nil {
return model.AuthUser{}, false
}
user, ok, err := repository.GetUserByID(claims.UserID)
if err != nil || !ok {
return model.AuthUser{}, false
}
return model.PublicUser(user), true
}
func ListUsers(q model.Query) (model.UserList, error) {
users, total, err := repository.ListUsers(q)
if err != nil {
return model.UserList{}, err
}
for i := range users {
users[i].Password = ""
}
return model.UserList{Items: users, Total: int(total)}, nil
}
func SaveUser(user model.User, password string) (model.User, error) {
user.Username = strings.TrimSpace(user.Username)
if strings.ContainsAny(user.Username, " \t\r\n") {
return user, errors.New("用户名不能包含空格")
}
if user.Username == "" {
return user, errors.New("用户名不能为空")
}
if user.Role == "" || user.Role == model.UserRoleGuest {
user.Role = model.UserRoleUser
}
if saved, ok, err := repository.GetUserByUsername(user.Username); err != nil {
return user, err
} else if ok && saved.ID != user.ID {
return user, errors.New("用户名已存在")
}
if user.ID == "" {
user.ID = newID("user")
user.CreatedAt = now()
} else if saved, ok, err := repository.GetUserByID(user.ID); err != nil {
return user, err
} else if ok {
user.CreatedAt = saved.CreatedAt
user.Password = saved.Password
}
if password != "" {
hash, err := hashPassword(password)
if err != nil {
return user, err
}
user.Password = hash
}
if user.Password == "" {
return user, errors.New("密码不能为空")
}
user.UpdatedAt = now()
user, err := repository.SaveUser(user)
user.Password = ""
return user, err
}
func DeleteUser(id string) error {
return repository.DeleteUser(id)
}
func GuestUser() model.AuthUser {
return model.AuthUser{ID: "", Username: "guest", Role: model.UserRoleGuest}
}
func newSession(user model.User) (model.AuthSession, error) {
token, err := newToken(user)
if err != nil {
return model.AuthSession{}, err
}
return model.AuthSession{Token: token, User: model.PublicUser(user)}, nil
}
func newToken(user model.User) (string, error) {
expireHours := config.Cfg.JWTExpireHours
if expireHours <= 0 {
expireHours = 168
}
claims := TokenClaims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireHours) * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: user.ID,
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(config.Cfg.JWTSecret))
}
func hashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(hash), err
}
func now() string {
return time.Now().Format(time.RFC3339)
}
func newID(prefix string) string {
return prefix + "-" + uuid.NewString()
}
func WarnDefaultSecurityConfig() {
if config.Cfg.AdminUsername == "admin" && config.Cfg.AdminPassword == "infinite-canvas" {
log.Println("WARNING: using default admin credentials, please set ADMIN_USERNAME and ADMIN_PASSWORD to safer values before deployment")
}
if config.Cfg.JWTSecret == "infinite-canvas" {
log.Println("WARNING: using default JWT_SECRET, please set a long random value before deployment")
}
}
+18
View File
@@ -0,0 +1,18 @@
package service
import (
"context"
"github.com/basketikun/infinite-canvas/model"
)
type userContextKey struct{}
func WithUser(ctx context.Context, user model.AuthUser) context.Context {
return context.WithValue(ctx, userContextKey{}, user)
}
func UserFromContext(ctx context.Context) (model.AuthUser, bool) {
user, ok := ctx.Value(userContextKey{}).(model.AuthUser)
return user, ok
}
+285
View File
@@ -0,0 +1,285 @@
package service
import (
"encoding/json"
"errors"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/basketikun/infinite-canvas/model"
"github.com/basketikun/infinite-canvas/repository"
)
const (
gptImage2RawBase = "https://raw.githubusercontent.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts/main"
awesomeGptImageRawBase = "https://raw.githubusercontent.com/ZeroLu/awesome-gpt-image/main"
awesomeGpt4oImagePromptsBase = "https://raw.githubusercontent.com/ImgEdify/Awesome-GPT4o-Image-Prompts/main"
youMindGptImage2RawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-gpt-image-2/main"
youMindNanoBananaProRawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts/main"
)
var gptImage2CaseFiles = []string{"README.md", "cases/ad-creative.md", "cases/character.md", "cases/comparison.md", "cases/ecommerce.md", "cases/portrait.md", "cases/poster.md", "cases/ui.md"}
type gptImage2Data struct {
Records []struct {
Title string `json:"title"`
TweetURL string `json:"tweet_url"`
ImageDir string `json:"image_dir"`
Category string `json:"category"`
AddedAt string `json:"added_at"`
} `json:"records"`
}
func SyncPromptCategory(category string) ([]model.PromptCategory, error) {
for _, item := range repository.PromptCategories() {
if item.Category != category {
continue
}
items, err := buildPromptCategory(item.Category)
if err != nil {
return nil, err
}
if err := repository.ReplacePromptCategory(item, items); err != nil {
return nil, err
}
return repository.ListPromptCategories()
}
return nil, errors.New("未知提示词分类")
}
func buildPromptCategory(category string) ([]model.Prompt, error) {
switch category {
case "gpt-image-2-prompts":
return buildGptImage2Prompts()
case "awesome-gpt-image":
return buildAwesomeGptImagePrompts()
case "awesome-gpt4o-image-prompts":
return buildAwesomeGpt4oImagePrompts()
case "youmind-gpt-image-2":
return buildYouMindGptImage2Prompts()
case "youmind-nano-banana-pro":
return buildYouMindNanoBananaProPrompts()
}
return nil, errors.New("未知提示词分类")
}
func fetchText(baseURL, file string) (string, error) {
request, _ := http.NewRequest(http.MethodGet, baseURL+"/"+file, nil)
client := http.Client{Timeout: 30 * time.Second}
response, err := client.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return "", errors.New(file + " 拉取失败")
}
data, err := io.ReadAll(response.Body)
return string(data), err
}
func buildGptImage2Prompts() ([]model.Prompt, error) {
cases := map[string]string{}
raw, err := fetchText(gptImage2RawBase, "data/ingested_tweets.json")
if err != nil {
return nil, err
}
data := gptImage2Data{}
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return nil, err
}
for _, file := range gptImage2CaseFiles {
markdown, err := fetchText(gptImage2RawBase, file)
if err != nil {
return nil, err
}
collectGptImage2Cases(cases, markdown)
}
items := []model.Prompt{}
for _, item := range data.Records {
prompt := cases[item.TweetURL]
if prompt == "" {
continue
}
image := gptImage2RawBase + "/" + item.ImageDir + "/output.jpg"
items = append(items, model.Prompt{ID: "gpt-image-2-prompts-" + leftPad(len(items)+1), Title: item.Title, CoverURL: image, Prompt: prompt, Tags: tagsFromCategory(item.Category), CreatedAt: item.AddedAt, UpdatedAt: item.AddedAt, Preview: markdownPreview([]string{image})})
}
return items, nil
}
func collectGptImage2Cases(cases map[string]string, markdown string) {
re := regexp.MustCompile("(?s)### Case \\d+: \\[[^\\]]+\\]\\(([^)]+)\\).*?\\*\\*Prompt:\\*\\*\\s*\\r?\\n\\s*```[\\w-]*\\r?\\n(.*?)\\r?\\n```")
for _, match := range re.FindAllStringSubmatch(markdown, -1) {
cases[match[1]] = strings.TrimSpace(match[2])
}
}
func buildAwesomeGptImagePrompts() ([]model.Prompt, error) {
markdown, err := fetchText(awesomeGptImageRawBase, "README.zh-CN.md")
if err != nil {
return nil, err
}
items := []model.Prompt{}
for _, section := range splitBeforeHeading(markdown, "## ") {
tags := tagsFromHeading(firstMatch(section, `(?m)^##\s+(.+)$`))
for _, block := range splitBeforeHeading(section, "### ") {
title := strings.TrimSpace(regexp.MustCompile(`\[([^\]]+)]\([^)]+\)`).ReplaceAllString(firstMatch(block, `(?m)^###\s+(.+)$`), "$1"))
prompt := strings.TrimSpace(firstMatch(block, "(?s)\\*\\*提示词:\\*\\*\\s*\\r?\\n\\s*```[\\w-]*\\r?\\n(.*?)\\r?\\n```"))
if title == "" || prompt == "" {
continue
}
images := extractMarkdownImages(awesomeGptImageRawBase, block)
cover := ""
if len(images) > 0 {
cover = images[0]
}
items = append(items, model.Prompt{ID: "awesome-gpt-image-" + leftPad(len(items)+1), Title: title, CoverURL: cover, Prompt: prompt, Tags: tags, Preview: markdownPreview(images)})
}
}
return items, nil
}
func buildAwesomeGpt4oImagePrompts() ([]model.Prompt, error) {
markdown, err := fetchText(awesomeGpt4oImagePromptsBase, "README.zh-CN.md")
if err != nil {
return nil, err
}
items := []model.Prompt{}
for _, block := range splitBeforeHeading(markdown, "### ") {
title := strings.TrimSpace(firstMatch(block, `(?m)^###\s+(.+)$`))
prompt := strings.TrimSpace(firstMatch(block, "(?s)- \\*\\*提示词文本:\\*\\*\\s*`(.*?)`"))
if title == "" || prompt == "" {
continue
}
images := extractMarkdownImages(awesomeGpt4oImagePromptsBase, block)
cover := ""
if len(images) > 0 {
cover = images[0]
}
items = append(items, model.Prompt{ID: "awesome-gpt4o-image-prompts-" + leftPad(len(items)+1), Title: title, CoverURL: cover, Prompt: prompt, Tags: []string{"gpt4o"}, Preview: markdownPreview(images)})
}
return items, nil
}
func buildYouMindGptImage2Prompts() ([]model.Prompt, error) {
return buildYouMindPrompts(youMindGptImage2RawBase, "youmind-gpt-image-2", "gpt-image-2")
}
func buildYouMindNanoBananaProPrompts() ([]model.Prompt, error) {
return buildYouMindPrompts(youMindNanoBananaProRawBase, "youmind-nano-banana-pro", "nano-banana-pro")
}
func buildYouMindPrompts(baseURL, idPrefix, modelTag string) ([]model.Prompt, error) {
markdown, err := fetchText(baseURL, "README_zh.md")
if err != nil {
return nil, err
}
items := []model.Prompt{}
for _, block := range splitBeforeHeading(markdown, "### ") {
title := strings.TrimSpace(firstMatch(block, `(?m)^###\s+No\.\s*\d+:\s*(.+)$`))
prompt := strings.TrimSpace(firstMatch(block, "(?s)#### .*?提示词\\s*\\r?\\n\\s*```[\\w-]*\\r?\\n(.*?)\\r?\\n```"))
if title == "" || prompt == "" {
continue
}
images := extractMarkdownImages(baseURL, block)
cover := ""
if len(images) > 0 {
cover = images[0]
}
items = append(items, model.Prompt{ID: idPrefix + "-" + leftPad(len(items)+1), Title: title, CoverURL: cover, Prompt: prompt, Tags: youMindTags(title, modelTag), Preview: markdownPreview(images)})
}
return items, nil
}
func splitBeforeHeading(markdown string, prefix string) []string {
blocks := []string{}
lines := strings.Split(markdown, "\n")
current := []string{}
for _, line := range lines {
if strings.HasPrefix(line, prefix) && len(current) > 0 {
blocks = append(blocks, strings.Join(current, "\n"))
current = []string{}
}
current = append(current, line)
}
return append(blocks, strings.Join(current, "\n"))
}
func firstMatch(value string, pattern string) string {
match := regexp.MustCompile(pattern).FindStringSubmatch(value)
if len(match) > 1 {
return match[1]
}
return ""
}
func tagsFromCategory(category string) []string {
return splitTags(regexp.MustCompile(`(?i)\s+Cases$`).ReplaceAllString(category, ""), `\s*(&|and)\s*`)
}
func tagsFromHeading(heading string) []string {
return splitTags(regexp.MustCompile(`[^\p{L}\p{N}/&、与 ]`).ReplaceAllString(heading, ""), `\s*(/|&|、|与)\s*`)
}
func youMindTags(title, modelTag string) []string {
tags := []string{modelTag}
parts := strings.SplitN(title, " - ", 2)
if len(parts) > 1 {
tags = append(tags, tagsFromHeading(parts[0])...)
}
return tags
}
func splitTags(value string, pattern string) []string {
tags := []string{}
for _, tag := range regexp.MustCompile(pattern).Split(value, -1) {
if tag = strings.ToLower(strings.TrimSpace(tag)); tag != "" {
tags = append(tags, tag)
}
}
return tags
}
func markdownPreview(images []string) string {
lines := []string{}
for _, image := range images {
if image != "" {
lines = append(lines, "![]("+image+")")
}
}
return strings.Join(lines, "\n\n")
}
func extractMarkdownImages(baseURL string, block string) []string {
seen := map[string]bool{}
images := []string{}
for _, pattern := range []string{`<img[^>]+src="([^"]+)"`, `!\[[^\]]*]\(([^)]+)\)`} {
for _, match := range regexp.MustCompile(pattern).FindAllStringSubmatch(block, -1) {
image := absoluteImage(baseURL, match[1])
if image != "" && !seen[image] {
seen[image] = true
images = append(images, image)
}
}
}
return images
}
func absoluteImage(baseURL, image string) string {
if image == "" || strings.HasPrefix(image, "http://") || strings.HasPrefix(image, "https://") {
return image
}
return baseURL + "/" + strings.TrimLeft(strings.TrimPrefix(image, "."), "/")
}
func leftPad(value int) string {
if value >= 1000 {
return strconv.Itoa(value)
}
text := "000" + strconv.Itoa(value)
return text[len(text)-3:]
}
+59
View File
@@ -0,0 +1,59 @@
package service
import (
"time"
"github.com/basketikun/infinite-canvas/model"
"github.com/basketikun/infinite-canvas/repository"
)
func ListPrompts(q model.Query) (model.PromptList, error) {
items, total, err := repository.ListPrompts(q)
if err != nil {
return model.PromptList{}, err
}
tags, err := repository.ListPromptTags(q)
if err != nil {
return model.PromptList{}, err
}
categories := promptCategoryCodes(ListPromptCategories())
return model.PromptList{Items: items, Tags: tags, Categories: categories, Total: int(total)}, nil
}
func ListPromptCategories() []model.PromptCategory {
categories, _ := repository.ListPromptCategories()
return categories
}
func SavePrompt(item model.Prompt) (model.Prompt, error) {
now := time.Now().Format(time.RFC3339)
if item.Category == "" {
item.Category = repository.PromptCategories()[0].Category
}
if item.ID == "" {
item.ID = newID(item.Category)
item.CreatedAt = now
}
item.UpdatedAt = now
category, ok := repository.PromptCategoryByCode(item.Category)
if !ok {
category = repository.PromptCategories()[0]
item.Category = category.Category
}
item.GithubURL = ""
return repository.SavePrompt(item)
}
func DeletePrompt(id string) error {
return repository.DeletePrompt(id)
}
func promptCategoryCodes(items []model.PromptCategory) []string {
codes := []string{}
for _, item := range items {
if item.Category != "" {
codes = append(codes, item.Category)
}
}
return codes
}