diff --git a/Makefile b/Makefile index d46f978..c1365f2 100644 --- a/Makefile +++ b/Makefile @@ -100,4 +100,4 @@ update_js_deps: update_go_deps: @echo "Updating Go dependencies..." - @go get -u ./... && go mod tidy \ No newline at end of file + @go get -u ./... && go mod tidy diff --git a/internal/actions/actions.go b/internal/actions/actions.go index 0606911..c7ff926 100644 --- a/internal/actions/actions.go +++ b/internal/actions/actions.go @@ -59,6 +59,15 @@ func IsRunning(actionType int) bool { return actionType >= 0 && actionType < numActions && running[actionType].Load() } +func IsPeriodic(actionType int) bool { + a, ok := registry[actionType] + return ok && a.spec != "" +} + +func Spec(actionType int) string { + return registry[actionType].spec +} + func RunOnce(actionType int) { a, ok := registry[actionType] if !ok { @@ -89,6 +98,7 @@ func RunOnce(actionType int) { }() log.Info().Msgf("Starting running action %d", actionType) + time.Sleep(100 * time.Millisecond) a.run() log.Info().Msgf("Finished running action %d", actionType) } diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index 7057d07..a08f675 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -129,10 +129,10 @@ func TestCmdAdminCreateUser_MissingPassword(t *testing.T) { func TestCmdAdminCreateUser_InvalidUsername(t *testing.T) { // Reserved names must be rejected with the same rules as the web form. - err := runCreateUser(t, "--username", "login", "--password", "pw") + err := runCreateUser(t, "--username", "oauth", "--password", "pw") require.Error(t, err) - _, fetchErr := db.GetUserByUsername("login") + _, fetchErr := db.GetUserByUsername("oauth") require.Error(t, fetchErr, "reserved user must not be persisted") } diff --git a/internal/db/action_lock.go b/internal/db/action_lock.go index c0e6692..6de22df 100644 --- a/internal/db/action_lock.go +++ b/internal/db/action_lock.go @@ -11,6 +11,11 @@ import ( // keyed by Action (the action's identifier); LockedUntil holds the Unix // timestamp the current lease expires at (0 = free). type ActionLock struct { + // autoIncrement:false is required: Action holds the action's own identifier, + // and the first action (SyncReposFromFS) is 0. Without this, GORM treats the + // int primary key as auto-increment and, on Create with the zero value, lets + // the DB assign an id instead — so the action=0 row never exists and its lock + // can never be acquired. Action int `gorm:"primaryKey;autoIncrement:false"` LockedUntil int64 } diff --git a/internal/db/gist.go b/internal/db/gist.go index 96fbdd6..4d5c31d 100644 --- a/internal/db/gist.go +++ b/internal/db/gist.go @@ -167,6 +167,94 @@ func GetAllGistsForCurrentUser(currentUserId uint, since *time.Time, offset int, return gists, err } +// GetAllGistsForCurrentUserFiltered is the filterable variant of +// GetAllGistsForCurrentUser used by the explore "All gists" page. It applies the +// same visibility rule (public gists plus currentUserId's own) and optionally +// narrows by title, language, visibility and topics, returning the total match +// count alongside the page of results. +func GetAllGistsForCurrentUserFiltered(currentUserId uint, title string, language string, visibility string, topics []string, offset int, sort string, order string) ([]*Gist, int64, error) { + var gists []*Gist + var count int64 + + baseQuery := db.Preload("User").Preload("Forked.User").Preload("Topics"). + Where("gists.private = 0 or gists.user_id = ?", currentUserId). + Model(&Gist{}) + + if title != "" { + baseQuery = baseQuery.Where("gists.title like ?", "%"+title+"%") + } + + if language != "" { + baseQuery = baseQuery.Joins("join gist_languages on gists.id = gist_languages.gist_id"). + Where("gist_languages.language = ?", language) + } + + if visibility != "" { + baseQuery = baseQuery.Where("gists.private = ?", ParseVisibility(visibility)) + } + + if len(topics) > 0 { + baseQuery = baseQuery.Joins("join gist_topics on gists.id = gist_topics.gist_id"). + Where("gist_topics.topic in ?", topics) + } + + err := baseQuery.Count(&count).Error + if err != nil { + return nil, 0, err + } + + err = baseQuery.Limit(11). + Offset(offset * 10). + Order("gists." + sort + "_at " + order). + Find(&gists).Error + + return gists, count, err +} + +// GetAllGistsByTopicFiltered returns gists carrying `topic` that are visible to +// currentUserId, optionally narrowed further by the faceted filter params +// (title, language, visibility and extra topics). The path topic is always +// required; any extra topics from the filter are ANDed on top so the result +// stays scoped to the topic page. +func GetAllGistsByTopicFiltered(currentUserId uint, topic string, title string, language string, visibility string, topics []string, offset int, sort string, order string) ([]*Gist, int64, error) { + var gists []*Gist + var count int64 + + baseQuery := db.Preload("User").Preload("Forked.User").Preload("Topics"). + Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId). + Where("exists (select 1 from gist_topics where gist_topics.gist_id = gists.id and gist_topics.topic = ?)", topic). + Model(&Gist{}) + + if title != "" { + baseQuery = baseQuery.Where("gists.title like ?", "%"+title+"%") + } + + if language != "" { + baseQuery = baseQuery.Joins("join gist_languages on gists.id = gist_languages.gist_id"). + Where("gist_languages.language = ?", language) + } + + if visibility != "" { + baseQuery = baseQuery.Where("gists.private = ?", ParseVisibility(visibility)) + } + + for _, t := range topics { + baseQuery = baseQuery.Where("exists (select 1 from gist_topics where gist_topics.gist_id = gists.id and gist_topics.topic = ?)", t) + } + + err := baseQuery.Count(&count).Error + if err != nil { + return nil, 0, err + } + + err = baseQuery.Limit(11). + Offset(offset * 10). + Order("gists." + sort + "_at " + order). + Find(&gists).Error + + return gists, count, err +} + // GetAllGistsFromUserVisibleTo returns gists owned by fromUserId, filtered // to what currentUserId is allowed to see (public always; private/unlisted // only when currentUserId == fromUserId). Same pagination/since shape as @@ -344,6 +432,26 @@ func CountAllGistsLikedByUser(fromUserId uint, currentUserId uint) (int64, error return count, err } +func likedAllStatement(currentUserId uint) *gorm.DB { + return db.Preload("User").Preload("Forked.User").Preload("Topics"). + Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId). + Where("exists (select 1 from likes where likes.gist_id = gists.id)") +} + +func GetAllGistsLiked(currentUserId uint, since *time.Time, offset int, sort string, order string, limit int, perPage int) ([]*Gist, error) { + var gists []*Gist + query := likedAllStatement(currentUserId) + if since != nil { + query = query.Where("gists.updated_at >= ?", since.Unix()) + } + err := query. + Limit(limit). + Offset(offset * perPage). + Order("gists." + sort + "_at " + order). + Find(&gists).Error + return gists, err +} + func forkedStatement(fromUserId uint, currentUserId uint) *gorm.DB { return db.Preload("User").Preload("Forked.User").Preload("Topics"). Where("gists.forked_id is not null and ((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId). @@ -375,6 +483,29 @@ func CountAllGistsForkedByUser(fromUserId uint, currentUserId uint) (int64, erro return count, err } +// forkedAllStatement scopes the explore "recently forked" feed to the source +// gists that have been forked from - i.e. gists that at least one other gist +// points to via forked_id - rather than the fork copies themselves. +func forkedAllStatement(currentUserId uint) *gorm.DB { + return db.Preload("User").Preload("Forked.User").Preload("Topics"). + Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId). + Where("exists (select 1 from gists as forks where forks.forked_id = gists.id)") +} + +func GetAllGistsForked(currentUserId uint, since *time.Time, offset int, sort string, order string, limit int, perPage int) ([]*Gist, error) { + var gists []*Gist + query := forkedAllStatement(currentUserId) + if since != nil { + query = query.Where("gists.updated_at >= ?", since.Unix()) + } + err := query. + Limit(limit). + Offset(offset * perPage). + Order("gists." + sort + "_at " + order). + Find(&gists).Error + return gists, err +} + // applySince narrows a gist query to rows updated at or after `since` when it // is non-nil, matching the filter the API list queries apply. Kept separate so // the count helpers stay in sync with their Find counterparts. @@ -1005,6 +1136,29 @@ type VisibilityDTO struct { Private Visibility `validate:"number,min=0,max=2" form:"private"` } +// GistMetadataDTO carries the editable gist metadata (title, URL path, +// description, topics) without the files, so the settings page can update them +// independently of the gist content. +type GistMetadataDTO struct { + Title string `validate:"max=250" form:"title"` + Description string `validate:"max=1000" form:"description"` + URL string `validate:"max=32,alphanumdashorempty" form:"url"` + Topics string `validate:"gisttopics" form:"topics"` +} + +func (dto *GistMetadataDTO) ToExistingGist(gist *Gist) *Gist { + gist.Title = dto.Title + gist.Description = dto.Description + gist.URL = dto.URL + topics := strings.Fields(dto.Topics) + gistTopics := make([]GistTopic, 0, len(topics)) + for _, topic := range topics { + gistTopics = append(gistTopics, GistTopic{Topic: topic}) + } + gist.Topics = gistTopics + return gist +} + type FileDTO struct { Filename string `validate:"excludes=\x2f,excludes=\x5c,max=255"` Content string diff --git a/internal/db/gist_language.go b/internal/db/gist_language.go index ea77ece..9e3833d 100644 --- a/internal/db/gist_language.go +++ b/internal/db/gist_language.go @@ -27,3 +27,53 @@ func GetGistLanguagesForUser(fromUserId, currentUserId uint) ([]struct { return results, err } + +// GetGistLanguages returns the most common languages across every gist visible +// to currentUserId (public gists plus their own). Used to populate the language +// facet on the explore "All gists" filter. +func GetGistLanguages(currentUserId uint) ([]struct { + Language string + Count int64 +}, error) { + var results []struct { + Language string + Count int64 + } + + err := db.Model(&GistLanguage{}). + Select("language, count(*) as count"). + Joins("JOIN gists ON gists.id = gist_languages.gist_id"). + Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId). + Group("language"). + Order("count DESC"). + Limit(15). + Find(&results).Error + + return results, err +} + +// GetGistLanguagesByTopic returns the most common languages across the gists +// carrying `topic` that are visible to currentUserId. Used to populate the +// language facet on the /-/topics/{topic} filter so it only lists languages +// present among that topic's gists. +func GetGistLanguagesByTopic(currentUserId uint, topic string) ([]struct { + Language string + Count int64 +}, error) { + var results []struct { + Language string + Count int64 + } + + err := db.Model(&GistLanguage{}). + Select("language, count(*) as count"). + Joins("JOIN gists ON gists.id = gist_languages.gist_id"). + Where("((gists.private = 0) or (gists.private > 0 and gists.user_id = ?))", currentUserId). + Where("exists (select 1 from gist_topics where gist_topics.gist_id = gists.id and gist_topics.topic = ?)", topic). + Group("language"). + Order("count DESC"). + Limit(15). + Find(&results).Error + + return results, err +} diff --git a/internal/db/gist_topic.go b/internal/db/gist_topic.go index cf52a9e..078589a 100644 --- a/internal/db/gist_topic.go +++ b/internal/db/gist_topic.go @@ -4,3 +4,28 @@ type GistTopic struct { GistID uint `gorm:"primaryKey"` Topic string `gorm:"primaryKey;size:50"` } + +// TopicCount is a topic together with the number of gists tagged with it. +type TopicCount struct { + Topic string + Count int64 +} + +// GetTopicsWithCount returns topics in use, together with how many gists carry +// each, ordered from most to least used and paginated (limit is typically +// perPage+1 so callers can detect a following page). Only topics on gists +// visible to currentUserId are counted (public gists, plus the user's own +// private ones). +func GetTopicsWithCount(currentUserId uint, offset int, limit int, perPage int) ([]*TopicCount, error) { + var topics []*TopicCount + err := db.Model(&GistTopic{}). + Select("gist_topics.topic as topic, count(*) as count"). + Joins("join gists on gists.id = gist_topics.gist_id"). + Where("gists.private = 0 or gists.user_id = ?", currentUserId). + Group("gist_topics.topic"). + Order("count desc, gist_topics.topic asc"). + Limit(limit). + Offset(offset * perPage). + Find(&topics).Error + return topics, err +} diff --git a/internal/db/user.go b/internal/db/user.go index 2a2a8e5..35cff60 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -124,6 +124,70 @@ func GetAllUsers(offset int) ([]*User, error) { return users, err } +// UserWithNbGists is a user together with the number of gists they own that are +// visible to the viewer (public gists, plus the viewer's own private ones). +type UserWithNbGists struct { + *User + NbGists int64 +} + +// GetUsersWithGistCounts returns a paginated, sorted page of users for the +// explore "Users" list, each annotated with how many gists they own that are +// visible to currentUserId. When query is non-empty, users are filtered to those +// whose username contains it (case-insensitive). sortColumn must be a +// whitelisted column name ("username_normalized" or "created_at") and order +// "asc" or "desc"; both are validated by the caller. limit is typically +// perPage+1 so callers can detect a following page. +func GetUsersWithGistCounts(currentUserId uint, query string, offset int, limit int, perPage int, sortColumn string, order string) ([]*UserWithNbGists, error) { + var users []*User + stmt := db.Model(&User{}) + if query != "" { + stmt = stmt.Where("username_normalized LIKE ?", "%"+strings.ToLower(query)+"%") + } + if err := stmt. + Order(sortColumn + " " + order). + Limit(limit). + Offset(offset * perPage). + Find(&users).Error; err != nil { + return nil, err + } + + result := make([]*UserWithNbGists, 0, len(users)) + if len(users) == 0 { + return result, nil + } + + ids := make([]uint, len(users)) + for i, u := range users { + ids[i] = u.ID + } + + type countRow struct { + UserID uint + Count int64 + } + var rows []countRow + if err := db.Model(&Gist{}). + Select("user_id, count(*) as count"). + Where("user_id IN ?", ids). + Where("private = 0 or user_id = ?", currentUserId). + Group("user_id"). + Find(&rows).Error; err != nil { + return nil, err + } + + counts := make(map[uint]int64, len(rows)) + for _, r := range rows { + counts[r.UserID] = r.Count + } + + for _, u := range users { + result = append(result, &UserWithNbGists{User: u, NbGists: counts[u.ID]}) + } + + return result, nil +} + func GetUserByUsername(username string) (*User, error) { user := new(User) err := db. @@ -301,6 +365,7 @@ type UserUsernameDTO struct { type UserStyleDTO struct { SoftWrap bool `form:"softwrap" json:"soft_wrap"` + ThemeColor string `form:"themecolor" json:"theme_color" validate:"omitempty,themecolor"` RemovedLineColor string `form:"removedlinecolor" json:"removed_line_color" validate:"min=0,max=7"` AddedLineColor string `form:"addedlinecolor" json:"added_line_color" validate:"min=0,max=7"` GitLineColor string `form:"gitlinecolor" json:"git_line_color" validate:"min=0,max=7"` diff --git a/internal/git/mime.go b/internal/git/mime.go index 6c16876..9647418 100644 --- a/internal/git/mime.go +++ b/internal/git/mime.go @@ -21,8 +21,7 @@ func (mt MimeType) IsText() bool { } func (mt MimeType) IsCSV() bool { - return strings.HasPrefix(mt.ContentType, "text/csv") && - (strings.HasSuffix(mt.extension, ".csv")) + return strings.HasSuffix(mt.extension, ".csv") && mt.IsText() } func (mt MimeType) IsImage() bool { diff --git a/internal/i18n/locales/ar-SY.yml b/internal/i18n/locales/ar-SY.yml index 503b4c7..c9a0213 100644 --- a/internal/i18n/locales/ar-SY.yml +++ b/internal/i18n/locales/ar-SY.yml @@ -289,8 +289,6 @@ admin.versions: الإصدارات admin.ssh_keys: مفاتيح SSH admin.stats: الإحصائيات admin.actions: الإجراءات -admin.actions.sync-fs: مزامنة المقاطع من نظام الملفات -admin.actions.sync-db: مزامنة المقاطع من قاعدة البيانات admin.actions.git-gc: تنفيذ جمع القمامة لجميع مستودعات Git admin.actions.sync-previews: مزامنة معاينات جميع المقاطع admin.actions.reset-hooks: إعادة تعيين خطافات خادم Git لجميع المستودعات @@ -335,13 +333,6 @@ flash.admin.user-deleted: تم حذف المستخدم flash.admin.gist-deleted: تم حذف المقطع flash.admin.invitation-created: تم إنشاء الدعوة flash.admin.invitation-deleted: تم حذف الدعوة -flash.admin.sync-fs: تتم مزامنة المستودعات من نظام الملفات... -flash.admin.sync-db: تتم مزامنة المستودعات من قاعدة البيانات... -flash.admin.git-gc: جارٍ تنفيذ جمع القمامة للمستودعات... -flash.admin.sync-previews: تتم مزامنة معاينات المقاطع... -flash.admin.reset-hooks: جارٍ إعادة تعيين خطافات خادم Git لجميع المستودعات... -flash.admin.index-gists: جارٍ إعادة بناء فهرس البحث... -flash.admin.sync-gist-languages: تتم مزامنة لغات المقاطع... flash.auth.username-exists: اسم المستخدم موجود بالفعل flash.auth.invalid-credentials: بيانات الاعتماد غير صالحة diff --git a/internal/i18n/locales/cs-CZ.yml b/internal/i18n/locales/cs-CZ.yml index 156e6ea..95bce41 100644 --- a/internal/i18n/locales/cs-CZ.yml +++ b/internal/i18n/locales/cs-CZ.yml @@ -155,8 +155,6 @@ admin.versions: Verze admin.ssh_keys: SSH klíče admin.stats: Statistiky admin.actions: Akce -admin.actions.sync-fs: Synchronizovat gisty ze souborového systému -admin.actions.sync-db: Synchronizovat gisty z databáze admin.actions.git-gc: Garbage collect git repozitářů admin.id: ID admin.user: Uživatel @@ -201,14 +199,8 @@ flash.admin.user-deleted: '' flash.admin.gist-deleted: '' flash.admin.invitation-created: '' flash.admin.invitation-deleted: '' -flash.admin.sync-fs: '' -flash.admin.sync-db: '' -flash.admin.git-gc: '' -flash.admin.sync-previews: '' gist.new.create-a-new-gist: '' gist.edit.edit-gist: '' -flash.admin.reset-hooks: '' -flash.admin.index-gists: '' flash.auth.username-exists: '' flash.auth.invalid-credentials: '' flash.auth.account-linked-oauth: '' diff --git a/internal/i18n/locales/de-DE.yml b/internal/i18n/locales/de-DE.yml index 82523c9..2e59e36 100644 --- a/internal/i18n/locales/de-DE.yml +++ b/internal/i18n/locales/de-DE.yml @@ -187,8 +187,6 @@ admin.versions: 'Versionen' admin.ssh_keys: 'SSH Schlüssel' admin.stats: 'Statistiken' admin.actions: 'Aktionen' -admin.actions.sync-fs: 'Gists auf dem Dateisystem sychronisieren' -admin.actions.sync-db: 'Gists von der Datenbank synchronisieren' admin.actions.git-gc: '„garbage collection“ bei allen git Repositories ausführen' admin.actions.sync-previews: 'Alle Gist Vorschauen synchronisieren' admin.actions.reset-hooks: 'Alle Git server Hooks für alle Repositories synchronisieren' @@ -231,12 +229,6 @@ flash.admin.user-deleted: 'Benutzer wurde gelöscht' flash.admin.gist-deleted: 'Gist wurde gelöscht' flash.admin.invitation-created: 'Einladung wurde erstellt' flash.admin.invitation-deleted: 'Einladung wurde gelöscht' -flash.admin.sync-fs: 'Synchronisiere Repositories vom Dateisystem...' -flash.admin.sync-db: 'Synchronisiere Repositories aus der Datenbank...' -flash.admin.git-gc: 'Sammle Repositories...' -flash.admin.sync-previews: 'Synchronisiere Gist-Vorschauen...' -flash.admin.reset-hooks: 'Setze Git-Server-Hooks für alle Repositories zurück...' -flash.admin.index-gists: 'Suchindex wird neu aufgebaut...' flash.auth.username-exists: 'Benutzername existiert bereits' flash.auth.invalid-credentials: 'Ungültige Anmeldeinformationen' @@ -305,7 +297,6 @@ auth.totp.save-recovery-codes: Speichere deine Wiederherstellungscodes an einem error.not-in-mfa-session: Nutzer ist nicht in einer Zwei-Faktor-Sitzung gist.revision.binary-file-changes: Änderungen an Binärdateien werden nicht angezeigt error.no-file-uploaded: Keine Datei hochgeladen -flash.admin.sync-gist-languages: Gist-Sprachen werden synchronisiert … validation.invalid-gist-topics: Ungültige Gist-Themen. Sie müssen mit einem Buchstaben oder einer Zahl beginnen, dürfen maximal 50 Zeichen lang sein und dürfen Bindestriche enthalten gist.new.topics: Themen (durch Leerzeichen getrennt) gist.preview-non-available: Vorschau nicht verfügbar diff --git a/internal/i18n/locales/en-US.yml b/internal/i18n/locales/en-US.yml index 749964b..231f2f9 100644 --- a/internal/i18n/locales/en-US.yml +++ b/internal/i18n/locales/en-US.yml @@ -6,6 +6,7 @@ gist.header.like: Like gist.header.unlike: Unlike gist.header.fork: Fork gist.header.edit: Edit +gist.header.edit-files: Edit files gist.header.delete: Delete gist.header.archive: Archive gist.header.unarchive: Unarchive @@ -16,6 +17,7 @@ gist.header.last-active: Last active gist.header.expires: Expires gist.header.select-tab: Select a tab gist.header.code: Code +gist.header.more-actions: More actions gist.header.revisions: Revisions gist.header.revision: Revision gist.header.clone-http: Clone via %s @@ -35,6 +37,7 @@ gist.watch-full-file: View the full file. gist.file-not-valid: This file is not a valid CSV file. gist.no-content: No files found gist.preview-non-available: Preview not available +gist.embed.view-all-files: View all files gist.new.new_gist: New gist gist.new.title: Title @@ -74,9 +77,18 @@ gist.edit.cancel: Cancel gist.edit.save: Save gist.delete.confirm: Are you sure you want to delete this gist ? +gist.settings.visibility-help: Control who can see and find this gist. +gist.settings.archive-help: Archived gists become read-only. You can unarchive at any time. +gist.settings.danger-zone: Danger zone +gist.settings.delete-gist: Delete this gist +gist.settings.delete-help: Once deleted, this gist and all its revisions are gone for good. + gist.list.joined: Joined gist.list.all: All gists +gist.list.recently-liked: Recently liked +gist.list.recently-forked: Recently forked gist.list.search-results: Search results +gist.list.search-for: Results for gist.list.sort: Sort gist.list.sort-by-created: created gist.list.sort-by-updated: updated @@ -84,19 +96,31 @@ gist.list.order-by-asc: Least recently gist.list.order-by-desc: Recently gist.list.select-tab: Select a tab gist.list.liked: Liked -gist.list.likes: likes +gist.list.likes: Likes gist.list.forked: Forked gist.list.forked-from: Forked from -gist.list.forks: forks +gist.list.forks: Forks gist.list.files: files gist.list.last-active: Last active gist.list.no-gists: No gists +gist.list.topics: Topics +gist.list.users: Users +gist.list.no-topics: No topics +gist.list.topic: Topic +gist.list.gists: Gists gist.list.all-liked-by: All gists liked by %s gist.list.all-forked-by: All gists forked by %s gist.list.all-from: All gists from %s gist.list.topic-results-topic: All gists matching topic %s gist.list.topic-results: All gists matching topic +explore.users.no-users: No users +explore.users.search-placeholder: Search users +explore.users.sort-username-asc: Username (A → Z) +explore.users.sort-username-desc: Username (Z → A) +explore.users.sort-joined-desc: Newest +explore.users.sort-joined-asc: Oldest + gist.search.found: gists found gist.search.no-results: No gists found @@ -175,6 +199,7 @@ settings.change-password-help: Change your password to login to Opengist via HTT settings.password-label-title: Password settings.header.account: Account settings.header.mfa: MFA +settings.header.authentication: Authentication settings.header.ssh: SSH settings.header.tokens: Access tokens settings.header.style: Style @@ -270,6 +295,7 @@ auth.totp.scan-qr-code: Scan the QR code below with your authenticator app to en error: Error +error.go-home: Back to home error.page-not-found: Page not found error.bad-request: Bad request error.signup-disabled: Signing up is disabled @@ -287,7 +313,11 @@ error.cannot-open-file: Cannot open uploaded file header.menu.all: All header.menu.new: New +header.menu.topics: Topics +header.menu.users: Users header.menu.search: Search +header.menu.recently-liked: Recently liked +header.menu.recently-forked: Recently forked header.menu.my-gists: My gists header.menu.liked: Liked header.menu.admin: Admin @@ -299,6 +329,8 @@ header.menu.light: Light header.menu.dark: Dark header.menu.system: System footer.powered-by: Powered by %s +ui.feedback-new: Give feedback on the new UI +sidebar.collapse: Collapse pagination.older: Older pagination.newer: Newer @@ -316,8 +348,11 @@ admin.versions: Versions admin.ssh_keys: SSH keys admin.stats: Stats admin.actions: Actions -admin.actions.sync-fs: Synchronize gists from filesystem -admin.actions.sync-db: Synchronize gists from database +admin.actions.subtitle: Run maintenance tasks manually. Some tasks also run automatically on a schedule. +admin.actions.run: Run +admin.actions.running: Running +admin.actions.sync-fs: Delete gists without a repository on disk +admin.actions.sync-db: Delete repositories without a gist in the database admin.actions.git-gc: Garbage collect all git repositories admin.actions.sync-previews: Synchronize all gists previews admin.actions.reset-hooks: Reset Git server hooks for all repositories @@ -366,15 +401,6 @@ flash.admin.user-deleted: User has been deleted flash.admin.gist-deleted: Gist has been deleted flash.admin.invitation-created: Invitation has been created flash.admin.invitation-deleted: Invitation has been deleted -flash.admin.sync-fs: Syncing repositories from filesystem... -flash.admin.sync-db: Syncing repositories from database... -flash.admin.git-gc: Garbage collecting repositories... -flash.admin.sync-previews: Syncing Gist previews... -flash.admin.reset-hooks: Resetting Git server hooks for all repositories... -flash.admin.index-gists: Rebuilding search index... -flash.admin.sync-gist-languages: Syncing Gist languages... -flash.admin.delete-expired-gists: Deleting expired gists... -flash.admin.sync-ssh-keys: Regenerating the authorized_keys file... flash.auth.username-exists: Username already exists flash.auth.invalid-credentials: Invalid credentials @@ -392,6 +418,7 @@ flash.gist.visibility-changed: Gist visibility has been changed flash.gist.archived: Gist has been archived flash.gist.unarchived: Gist has been unarchived flash.gist.deleted: Gist has been deleted +flash.gist.updated: Gist has been updated flash.gist.fork-own-gist: Unable to fork own gists flash.gist.forked: Gist has been forked diff --git a/internal/i18n/locales/es-ES.yml b/internal/i18n/locales/es-ES.yml index 4200e8f..d44a6ec 100644 --- a/internal/i18n/locales/es-ES.yml +++ b/internal/i18n/locales/es-ES.yml @@ -148,8 +148,6 @@ admin.versions: Versiones admin.ssh_keys: Claves SSH admin.stats: Estadísticas admin.actions: Acciones -admin.actions.sync-fs: Sincronizar gists desde el sistema de archivos -admin.actions.sync-db: Sincronizar gists desde la base de datos admin.actions.git-gc: Recolectar basura en los repositorios Git admin.id: ID admin.user: Usuario @@ -226,12 +224,6 @@ flash.admin.user-deleted: 'El usuario ha sido eliminado' flash.admin.gist-deleted: 'El gist ha sido eliminado' flash.admin.invitation-created: 'La invitación ha sido creada' flash.admin.invitation-deleted: 'La invitación ha sido eliminada' -flash.admin.sync-fs: 'Sincronizando repositorios desde el sistema de archivos...' -flash.admin.sync-db: 'Sincronizando repositorios desde la base de datos...' -flash.admin.git-gc: 'Recolectando basura en los repositorios...' -flash.admin.sync-previews: 'Sincronizando vistas previas de gists...' -flash.admin.reset-hooks: 'Reseteando hooks del servidor Git en todos los repositorios...' -flash.admin.index-gists: 'Reconstruyendo índice de búsqueda...' flash.auth.username-exists: 'El nombre de usuario ya existe' flash.auth.invalid-credentials: 'Credenciales incorrectas' flash.auth.account-linked-oauth: 'Cuenta vinculada a %s' diff --git a/internal/i18n/locales/fr-FR.yml b/internal/i18n/locales/fr-FR.yml index 236fba6..df67c08 100644 --- a/internal/i18n/locales/fr-FR.yml +++ b/internal/i18n/locales/fr-FR.yml @@ -148,8 +148,6 @@ admin.versions: Versions admin.ssh_keys: Clés SSH admin.stats: Statistiques admin.actions: Actions -admin.actions.sync-fs: Synchroniser les gists depuis le système de fichiers -admin.actions.sync-db: Synchroniser les gists depuis la base de données admin.actions.git-gc: Nettoyage des dépôts git admin.id: ID admin.user: Utilisateur @@ -226,12 +224,6 @@ flash.admin.user-deleted: 'L''utilisateur a été supprimé' flash.admin.gist-deleted: 'Le gist a été supprimée' flash.admin.invitation-created: 'L''invitation a été créée' flash.admin.invitation-deleted: 'L''invitation a été supprimée' -flash.admin.sync-fs: 'Synchronisation des dépôts à partir du système de fichiers...' -flash.admin.sync-db: 'Synchronisation des dépôts à partir de la base de données...' -flash.admin.git-gc: 'Nettoyage des dépôts...' -flash.admin.sync-previews: 'Synchronisation des aperçus du Gist...' -flash.admin.reset-hooks: 'Réinitialisation des hooks du serveur Git pour tous les dépôts...' -flash.admin.index-gists: 'Reconstruction de l''index de recherche...' flash.auth.username-exists: 'Nom d''utilisateur déjà utilisé' flash.auth.invalid-credentials: 'Identifiants non valides' flash.auth.account-linked-oauth: 'Compte lié à %s' diff --git a/internal/i18n/locales/hu-HU.yml b/internal/i18n/locales/hu-HU.yml index 61e091b..61170ae 100644 --- a/internal/i18n/locales/hu-HU.yml +++ b/internal/i18n/locales/hu-HU.yml @@ -165,8 +165,6 @@ admin.versions: Verziók admin.ssh_keys: SSH kulcsok admin.stats: Statisztikák admin.actions: Műveletek -admin.actions.sync-fs: Gistek szinkronizálása a fájlrendszerrel -admin.actions.sync-db: Gistek szinkronizálása az adatbázissal admin.actions.git-gc: Használatlan git repository-k eltávolítása admin.actions.sync-previews: Gist előnézetek szinkronizálása admin.actions.reset-hooks: Git server hook-ok alaphelyzetbe állítása minden repository-nál @@ -227,12 +225,6 @@ flash.admin.user-deleted: '' flash.admin.gist-deleted: '' flash.admin.invitation-created: '' flash.admin.invitation-deleted: '' -flash.admin.sync-fs: '' -flash.admin.sync-db: '' -flash.admin.git-gc: '' -flash.admin.sync-previews: '' -flash.admin.reset-hooks: '' -flash.admin.index-gists: '' flash.auth.username-exists: '' flash.auth.invalid-credentials: '' flash.auth.account-linked-oauth: '' diff --git a/internal/i18n/locales/it_IT.yml b/internal/i18n/locales/it_IT.yml index 395330b..f7d2d86 100644 --- a/internal/i18n/locales/it_IT.yml +++ b/internal/i18n/locales/it_IT.yml @@ -186,8 +186,6 @@ admin.versions: 'Versioni' admin.ssh_keys: 'Chiavi SSH' admin.stats: 'Statistiche' admin.actions: 'Azioni' -admin.actions.sync-fs: 'Sincronizza gists dal filesystem' -admin.actions.sync-db: 'Sincronizza gists dal database' admin.actions.git-gc: 'Esegui la garbage collection da tutti i repositories' admin.actions.sync-previews: 'Sincronizza tutte le anteprime dei gists' admin.actions.reset-hooks: 'Resetta tutti gli hook del server Git per tutti i repositories' @@ -230,12 +228,6 @@ flash.admin.user-deleted: 'L''utente è stato eliminato' flash.admin.gist-deleted: 'Il gist è stato eliminato' flash.admin.invitation-created: 'L''invito è stato creato' flash.admin.invitation-deleted: 'L''invito è stato eliminato' -flash.admin.sync-fs: 'Sincronizzando i repositories dal filesystem...' -flash.admin.sync-db: 'Sincronizzando i repositories dal database...' -flash.admin.git-gc: 'Eseguendo il garbage collector dei repositories...' -flash.admin.sync-previews: 'Sincronizzando le anteprime dei gists...' -flash.admin.reset-hooks: 'Resettando gli hook di Git per tutti i repositories...' -flash.admin.index-gists: 'Ricostruzione indice di ricerca...' flash.auth.username-exists: 'Il nome utente esiste già' flash.auth.invalid-credentials: 'Credenziali errate' @@ -295,7 +287,6 @@ auth.totp: Time based one-time password (TOTP) auth.totp.help: Il TOTP è un metodo di autenticazione a due fattori che usa una chiave segreta per generare una one-time password (OTP). error.not-in-mfa-session: Non stai usando l'autenticazione a due fattori admin.actions.sync-gist-languages: Sincronizza tutte le lingue dei gist -flash.admin.sync-gist-languages: Sincronizzazione delle lingue gist... flash.auth.passkey-registred: Passkey %s registrata flash.auth.passkey-deleted: Passkey eliminata validation.invalid-gist-topics: 'Argomenti del gist non validi: devono iniziare con una lettera o un numero ed essere composti da al massimo 50 caratteri. Possono includere trattini' diff --git a/internal/i18n/locales/ja_JP.yml b/internal/i18n/locales/ja_JP.yml index a83763b..895b8a5 100644 --- a/internal/i18n/locales/ja_JP.yml +++ b/internal/i18n/locales/ja_JP.yml @@ -236,8 +236,6 @@ admin.versions: '' admin.ssh_keys: '' admin.stats: '' admin.actions: '' -admin.actions.sync-fs: '' -admin.actions.sync-db: '' admin.actions.git-gc: '' admin.actions.sync-previews: '' admin.actions.reset-hooks: '' @@ -282,13 +280,6 @@ flash.admin.user-deleted: '' flash.admin.gist-deleted: '' flash.admin.invitation-created: '' flash.admin.invitation-deleted: '' -flash.admin.sync-fs: '' -flash.admin.sync-db: '' -flash.admin.git-gc: '' -flash.admin.sync-previews: '' -flash.admin.reset-hooks: '' -flash.admin.index-gists: '' -flash.admin.sync-gist-languages: '' flash.auth.username-exists: '' flash.auth.invalid-credentials: '' diff --git a/internal/i18n/locales/ko-KR.yml b/internal/i18n/locales/ko-KR.yml index 222a994..c3cc78b 100644 --- a/internal/i18n/locales/ko-KR.yml +++ b/internal/i18n/locales/ko-KR.yml @@ -366,15 +366,6 @@ flash.admin.user-deleted: 사용자가 삭제되었습니다 flash.admin.gist-deleted: Gist가 삭제되었습니다 flash.admin.invitation-created: 초대가 생성되었습니다 flash.admin.invitation-deleted: 초대가 삭제되었습니다 -flash.admin.sync-fs: 파일 시스템에서 리포지토리를 동기화하는 중... -flash.admin.sync-db: 데이터베이스에서 리포지토리를 동기화하는 중... -flash.admin.git-gc: 리포지토리를 가비지 컬렉션하는 중... -flash.admin.sync-previews: Gist 미리보기를 동기화하는 중... -flash.admin.reset-hooks: 모든 리포지토리의 Git 서버 훅을 초기화하는 중... -flash.admin.index-gists: 검색 인덱스를 재구성하는 중... -flash.admin.sync-gist-languages: Gist 언어를 동기화하는 중... -flash.admin.delete-expired-gists: 만료된 gist를 삭제하는 중... -flash.admin.sync-ssh-keys: authorized_keys 파일을 재생성하는 중... flash.auth.username-exists: 이미 존재하는 사용자 이름입니다 flash.auth.invalid-credentials: 잘못된 인증 정보입니다 diff --git a/internal/i18n/locales/pl_PL.yml b/internal/i18n/locales/pl_PL.yml index 99f8080..ec72fcd 100644 --- a/internal/i18n/locales/pl_PL.yml +++ b/internal/i18n/locales/pl_PL.yml @@ -222,8 +222,6 @@ admin.versions: 'Wersje' admin.ssh_keys: 'Klucze SSH' admin.stats: 'Statystyki' admin.actions: 'Akcje' -admin.actions.sync-fs: 'Synchronizuj Gisty z systemu plików' -admin.actions.sync-db: 'Synchronizuj Gisty z bazy danych' admin.actions.git-gc: 'Zbierz śmieci we wszystkich repozytoriach Git' admin.actions.sync-previews: 'Synchronizuj podglądy wszystkich Gistów' admin.actions.reset-hooks: 'Zresetuj hooki serwera Git dla wszystkich repozytoriów' @@ -266,12 +264,6 @@ flash.admin.user-deleted: 'Użytkownik został usunięty' flash.admin.gist-deleted: 'Gist został usunięty' flash.admin.invitation-created: 'Zaproszenie zostało stworzone' flash.admin.invitation-deleted: 'Zaproszenie zostało usunięte' -flash.admin.sync-fs: 'Synchronizowanie repozytoriów z systemu plików...' -flash.admin.sync-db: 'Synchronizowanie repozytoriów z bazy danych...' -flash.admin.git-gc: 'Zbieranie śmieci w repozytoriach...' -flash.admin.sync-previews: 'Synchronizowanie podglądów Gistów...' -flash.admin.reset-hooks: 'Resetowanie hooków serwera Git dla wszystkich repozytoriów...' -flash.admin.index-gists: 'Przebudowywanie indeksu wyszukiwania...' flash.auth.username-exists: 'Nazwa użytkownika już istnieje' flash.auth.invalid-credentials: 'Niepoprawne dane logowania' diff --git a/internal/i18n/locales/pt-BR.yml b/internal/i18n/locales/pt-BR.yml index ecad48c..e2463da 100644 --- a/internal/i18n/locales/pt-BR.yml +++ b/internal/i18n/locales/pt-BR.yml @@ -146,8 +146,6 @@ admin.versions: Versões admin.ssh_keys: Chaves SSH admin.stats: Estatísticas admin.actions: Ações -admin.actions.sync-fs: Sincronizar gists do sistema de arquivos -admin.actions.sync-db: Sincronizar gists do banco de dados admin.actions.git-gc: Coletar lixo nos repositórios Git admin.id: ID admin.user: Usuário @@ -172,7 +170,6 @@ admin.gists.private: Privado admin.gists.nb-files: Núm. de arquivos admin.gists.nb-likes: Núm. de curtidas admin.gists.delete_confirm: Quer excluir este gist? -flash.admin.index-gists: '' gist.header.embed: '' gist.header.embed-help: '' gist.new.url: '' @@ -228,11 +225,6 @@ flash.admin.user-deleted: '' flash.admin.gist-deleted: '' flash.admin.invitation-created: '' flash.admin.invitation-deleted: '' -flash.admin.sync-fs: '' -flash.admin.sync-db: '' -flash.admin.git-gc: '' -flash.admin.sync-previews: '' -flash.admin.reset-hooks: '' flash.auth.username-exists: '' flash.auth.invalid-credentials: '' flash.auth.account-linked-oauth: '' diff --git a/internal/i18n/locales/ru-RU.yml b/internal/i18n/locales/ru-RU.yml index 1dd27f5..c5f7fec 100644 --- a/internal/i18n/locales/ru-RU.yml +++ b/internal/i18n/locales/ru-RU.yml @@ -148,8 +148,6 @@ admin.versions: Версии admin.ssh_keys: Ключи SSH admin.stats: Статистика admin.actions: Действия -admin.actions.sync-fs: Синхронизировать фрагменты из файловой системы -admin.actions.sync-db: Синхронизировать фрагменты с базой данных admin.actions.git-gc: Сборка мусора в репозиториях Git admin.id: ID admin.user: Пользователь @@ -227,12 +225,6 @@ flash.admin.user-deleted: 'Пользователь удалён' flash.admin.gist-deleted: 'Фрагмент удалён' flash.admin.invitation-created: 'Приглашение создано' flash.admin.invitation-deleted: 'Приглашение удалено' -flash.admin.sync-fs: 'Выполняется синхронизация репозиториев с файловой системой…' -flash.admin.sync-db: 'Выполняется синхронизация репозиториев с базой данных…' -flash.admin.git-gc: 'Сборка мусора в репозиториях…' -flash.admin.sync-previews: 'Обновление предпросмотров фрагментов…' -flash.admin.reset-hooks: 'Пересоздание Git-хуков для всех репозиториев…' -flash.admin.index-gists: 'Перестроение поискового индекса…' flash.auth.username-exists: 'Такое имя пользователя уже занято' flash.auth.invalid-credentials: 'Некорректные данные для входа' flash.auth.account-linked-oauth: 'Учётная запись связана с %s' @@ -334,7 +326,6 @@ auth.totp.help: TOTP — это метод двухфакторной аутен auth.totp.use: Использовать TOTP auth.totp.regenerate-recovery-codes: Сгенерировать коды восстановления заново auth.totp: Одноразовый пароль по времени (TOTP) -flash.admin.sync-gist-languages: Обновление языков фрагментов… settings.token-created: Токен создан, обязательно сохраните его, повторно он показан не будет! settings.token-last-used: Последнее использование settings.token-no-expiration: Бессрочно diff --git a/internal/i18n/locales/tr-TR.yml b/internal/i18n/locales/tr-TR.yml index 4ded186..193489d 100644 --- a/internal/i18n/locales/tr-TR.yml +++ b/internal/i18n/locales/tr-TR.yml @@ -186,8 +186,6 @@ admin.versions: Sürümler admin.ssh_keys: SSH anahtarları admin.stats: İstatistikler admin.actions: Eylemler -admin.actions.sync-fs: Gistleri dosya sisteminden senkronize et -admin.actions.sync-db: Gistleri veri tabanından senkronize et admin.actions.git-gc: Tüm Git depolarındaki gereksiz verileri temizle admin.actions.sync-previews: Tüm gist önizlemelerini senkronize et admin.actions.reset-hooks: Tüm depolar için Git sunucu kancalarını sıfırla @@ -229,12 +227,6 @@ flash.admin.user-deleted: Kullanıcı silindi flash.admin.gist-deleted: Gist silindi flash.admin.invitation-created: Davetiye oluşturuldu flash.admin.invitation-deleted: Davetiye silindi -flash.admin.sync-fs: Depolar dosya sisteminden senkronize ediliyor... -flash.admin.sync-db: Depolar veri tabanından senkronize ediliyor... -flash.admin.git-gc: Depolardan gereksiz veriler temizleniyor... -flash.admin.sync-previews: Gist önizlemeleri senkronize ediliyor... -flash.admin.reset-hooks: Tüm depolar için Git sunucusu kancaları sıfırlanıyor... -flash.admin.index-gists: Arama dizini yeniden oluşturuluyor... flash.auth.username-exists: Kullanıcı adı zaten mevcut flash.auth.invalid-credentials: Geçersiz kimlik bilgileri @@ -274,7 +266,6 @@ auth.mfa.waiting-for-passkey-input: Tarayıcı etkileşiminden gelecek girdi bek settings.header.account: Hesap settings.style.no-soft-wrap: Yumuşak Satır Kaydırma Yok auth.totp: Zamana Dayalı Tek Kullanımlık Parola (TOTP) -flash.admin.sync-gist-languages: Gist dilleri senkronize ediliyor... auth.mfa.passkeys-help: Hesabınıza giriş yapmak ve çok faktörlü kimlik doğrulama yöntemi olarak kullanmak için bir geçiş anahtarı ekleyin. validation.invalid-gist-topics: Geçersiz gist konuları, harf veya rakamla başlamalı, 50 karakterden uzun olmamalı ve tire içerebilir. auth.totp.enter-recovery-key: veya cihazınızı kaybettiyseniz kurtarma anahtarını kullanın diff --git a/internal/i18n/locales/uk-UK.yml b/internal/i18n/locales/uk-UK.yml index 096032a..1961ca5 100644 --- a/internal/i18n/locales/uk-UK.yml +++ b/internal/i18n/locales/uk-UK.yml @@ -187,8 +187,6 @@ admin.versions: Версії admin.ssh_keys: Ключі SSH admin.stats: Статистика admin.actions: Дії -admin.actions.sync-fs: Синхронізувати gists з файлової системи -admin.actions.sync-db: Синхронізувати gists з базою даних admin.actions.git-gc: Збір сміття з репозиторіїв Git admin.actions.sync-previews: Синхронізувати всі gists перегляди admin.actions.reset-hooks: Скинути серверні Git hooks для всіх репозиторіїв @@ -231,12 +229,6 @@ flash.admin.user-deleted: Користувач був видалений flash.admin.gist-deleted: Gist був видалений flash.admin.invitation-created: Запрошення було створено flash.admin.invitation-deleted: Запрошення було видалено -flash.admin.sync-fs: Синхронізація репозиторіїв за файловою системою... -flash.admin.sync-db: Синхронізація репозиторіїв за базою даних... -flash.admin.git-gc: Збір сміття з репозиторіїв... -flash.admin.sync-previews: Синхронізація Gist переглядів... -flash.admin.reset-hooks: Скидання cерверниз Git hooks для всіх репозиторіїв... -flash.admin.index-gists: Перебудова пошукового індексу... flash.auth.username-exists: Це ім'я користувача вже існує flash.auth.invalid-credentials: Недійсні облікові дані diff --git a/internal/i18n/locales/zh-CN.yml b/internal/i18n/locales/zh-CN.yml index 3dbd2de..8cc9cbb 100644 --- a/internal/i18n/locales/zh-CN.yml +++ b/internal/i18n/locales/zh-CN.yml @@ -177,8 +177,6 @@ admin.versions: 版本 admin.ssh_keys: SSH 密钥 admin.stats: 状态 admin.actions: 动作 -admin.actions.sync-fs: 从文件系统同步 Gist -admin.actions.sync-db: 从数据库同步 Gist admin.actions.git-gc: 对 Git 仓库执行垃圾回收 admin.id: ID admin.user: 用户 @@ -257,12 +255,6 @@ flash.admin.user-deleted: '用户已被删除' flash.admin.gist-deleted: 'Gist 已被删除' flash.admin.invitation-created: '邀请已被创建' flash.admin.invitation-deleted: '邀请已被删除' -flash.admin.sync-fs: '正在从文件系统同步存储库...' -flash.admin.sync-db: '正在从数据库同步存储库...' -flash.admin.git-gc: '正在进行存储库垃圾回收...' -flash.admin.sync-previews: '正在同步 Gist 预览...' -flash.admin.reset-hooks: '正在重置所有存储库的 Git 服务挂钩...' -flash.admin.index-gists: '正在重建搜索索引...' flash.auth.username-exists: '用户名已存在' flash.auth.invalid-credentials: '无效的凭证' flash.auth.account-linked-oauth: '帐户已关联到 %s' @@ -330,7 +322,6 @@ gist.new.topics: 主题(用空格分隔) validation.invalid-gist-topics: 无效的 Gists 主题,它们必须以字母或数字开头,长度不超过50个字符,并且可以包含连字符 gist.list.topic-results-topic: '%s 与主题匹配的所有 Gists' admin.actions.sync-gist-languages: 同步所有 gists 语言 -flash.admin.sync-gist-languages: 正在同步 Gist 语言... gist.list.topic-results: 所有匹配主题的 Gist gist.search.help.topic: 具有给定主题的 Gists gist.search.placeholder.title: 标题 diff --git a/internal/i18n/locales/zh-TW.yml b/internal/i18n/locales/zh-TW.yml index 22234c5..037b8ff 100644 --- a/internal/i18n/locales/zh-TW.yml +++ b/internal/i18n/locales/zh-TW.yml @@ -157,8 +157,6 @@ admin.versions: 版本 admin.ssh_keys: SSH 金鑰 admin.stats: 統計 admin.actions: 操作 -admin.actions.sync-fs: 從系統同步 Gists -admin.actions.sync-db: 從資料庫同步 Gists admin.actions.git-gc: 清理所有的 git 儲存庫 admin.actions.sync-previews: 同步所有 Gists 預覽 admin.actions.reset-hooks: 重置 Git 伺服器所有儲存庫的 Git hooks @@ -226,12 +224,6 @@ flash.admin.user-deleted: '' flash.admin.gist-deleted: '' flash.admin.invitation-created: '' flash.admin.invitation-deleted: '' -flash.admin.sync-fs: '' -flash.admin.sync-db: '' -flash.admin.git-gc: '' -flash.admin.sync-previews: '' -flash.admin.reset-hooks: '' -flash.admin.index-gists: '' flash.auth.username-exists: '' flash.auth.invalid-credentials: '' flash.auth.account-linked-oauth: '' diff --git a/internal/validator/validator.go b/internal/validator/validator.go index b422223..4faa7cf 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -23,9 +23,14 @@ func NewValidator() *OpengistValidator { _ = v.RegisterValidation("alphanumdashunderorempty", validateAlphaNumDashUnderOrEmpty) _ = v.RegisterValidation("gisttopics", validateGistTopics) _ = v.RegisterValidation("expirationdate", validateExpirationDate) + _ = v.RegisterValidation("themecolor", validateThemeColor) return &OpengistValidator{v} } +var ThemeColors = []string{ + "red", "amber", "emerald", "sky", "indigo", "purple", "neutral", +} + func (cv *OpengistValidator) Validate(i interface{}) error { return cv.v.Struct(i) } @@ -69,7 +74,7 @@ func validateReservedKeywords(fl validator.FieldLevel) bool { name := fl.Field().String() restrictedNames := map[string]struct{}{} - for _, restrictedName := range []string{"assets", "register", "login", "logout", "settings", "admin-panel", "all", "search", "init", "healthcheck", "preview", "metrics", "mfa", "webauthn", "oauth"} { + for _, restrictedName := range []string{"api", "assets", "init", "healthcheck", "preview", "metrics", "mfa", "webauthn", "oauth"} { restrictedNames[restrictedName] = struct{}{} } @@ -79,7 +84,9 @@ func validateReservedKeywords(fl validator.FieldLevel) bool { } func validateAlphaNumDash(fl validator.FieldLevel) bool { - return regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(fl.Field().String()) + value := fl.Field().String() + return regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(value) && + regexp.MustCompile(`[a-zA-Z0-9]`).MatchString(value) } func validateAlphaNumDashOrEmpty(fl validator.FieldLevel) bool { @@ -87,13 +94,28 @@ func validateAlphaNumDashOrEmpty(fl validator.FieldLevel) bool { } func validateAlphaNumDashUnder(fl validator.FieldLevel) bool { - return regexp.MustCompile(`^[a-zA-Z0-9-_]+$`).MatchString(fl.Field().String()) + value := fl.Field().String() + return regexp.MustCompile(`^[a-zA-Z0-9-_]+$`).MatchString(value) && + regexp.MustCompile(`[a-zA-Z0-9]`).MatchString(value) } func validateAlphaNumDashUnderOrEmpty(fl validator.FieldLevel) bool { return regexp.MustCompile(`^$|^[a-zA-Z0-9-_]+$`).MatchString(fl.Field().String()) } +func validateThemeColor(fl validator.FieldLevel) bool { + color := fl.Field().String() + if color == "" { + return true + } + for _, c := range ThemeColors { + if c == color { + return true + } + } + return false +} + func validateGistTopics(fl validator.FieldLevel) bool { topicsInput := fl.Field().String() if topicsInput == "" { diff --git a/internal/validator/validator_test.go b/internal/validator/validator_test.go new file mode 100644 index 0000000..9ef36e3 --- /dev/null +++ b/internal/validator/validator_test.go @@ -0,0 +1,54 @@ +package validator + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReservedUsernames(t *testing.T) { + v := NewValidator() + + for _, name := range []string{ + "register", + "login", + "logout", + "settings", + "admin-panel", + "all", + "search", + } { + t.Run("allowed "+name, func(t *testing.T) { + require.NoError(t, v.Var(name, "notreserved")) + }) + } + + for _, name := range []string{ + "assets", + "init", + "healthcheck", + "preview", + "metrics", + "mfa", + "webauthn", + "oauth", + } { + t.Run("reserved "+name, func(t *testing.T) { + require.Error(t, v.Var(name, "notreserved")) + }) + } +} + +func TestUsernameRequiresAlphanumericCharacter(t *testing.T) { + v := NewValidator() + + for _, name := range []string{"-", "---"} { + require.Error(t, v.Var(name, "alphanumdash")) + } + for _, name := range []string{"-", "---", "_", "-_-"} { + require.Error(t, v.Var(name, "alphanumdashunder")) + } + + require.NoError(t, v.Var("user-name", "alphanumdash")) + require.NoError(t, v.Var("user_name", "alphanumdashunder")) +} diff --git a/internal/web/handlers/admin/actions.go b/internal/web/handlers/admin/actions.go index ceec380..bee5a2c 100644 --- a/internal/web/handlers/admin/actions.go +++ b/internal/web/handlers/admin/actions.go @@ -2,59 +2,114 @@ package admin import ( "github.com/thomiceli/opengist/internal/actions" + "github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/web/context" ) +// actionView is the template model for a single row on the admin actions page. +type actionView struct { + Path string // POST endpoint suffix under /-/admin-panel, e.g. "sync-fs" + LabelKey string // i18n key for the action's label + Running bool // currently in progress in this instance + Periodic bool // also runs automatically on a schedule + Spec string // raw schedule spec, e.g. "@every 72h"; "" if not periodic +} + +// adminActions lists every action shown on the actions page, in display order. +// The Path values match the POST routes registered in the router. +var adminActions = []struct { + Type int + Path string + Key string +}{ + {actions.SyncReposFromFS, "sync-fs", "admin.actions.sync-fs"}, + {actions.SyncReposFromDB, "sync-db", "admin.actions.sync-db"}, + {actions.GitGcRepos, "gc-repos", "admin.actions.git-gc"}, + {actions.SyncGistPreviews, "sync-previews", "admin.actions.sync-previews"}, + {actions.ResetHooks, "reset-hooks", "admin.actions.reset-hooks"}, + {actions.IndexGists, "index-gists", "admin.actions.index-gists"}, + {actions.SyncGistLanguages, "sync-languages", "admin.actions.sync-gist-languages"}, + {actions.DeleteExpiredGists, "delete-expired-gists", "admin.actions.delete-expired-gists"}, + {actions.SyncSSHKeys, "sync-ssh-keys", "admin.actions.sync-ssh-keys"}, +} + +func AdminActions(ctx *context.Context) error { + ctx.SetData("htmlTitle", ctx.TrH("admin.actions")+" - "+ctx.TrH("admin.admin_panel")) + ctx.SetData("adminHeaderPage", "actions") + + manageSSHKeys := config.C.SshManagesAuthorizedKeys() + + views := make([]actionView, 0, len(adminActions)) + anyRunning := false + for _, a := range adminActions { + // The authorized_keys action is only relevant when Opengist manages the file. + if a.Type == actions.SyncSSHKeys && !manageSSHKeys { + continue + } + + running := actions.IsRunning(a.Type) + if running { + anyRunning = true + } + + views = append(views, actionView{ + Path: a.Path, + LabelKey: a.Key, + Running: running, + Periodic: actions.IsPeriodic(a.Type), + Spec: actions.Spec(a.Type), + }) + } + + ctx.SetData("actions", views) + ctx.SetData("anyRunning", anyRunning) + // Set when arriving right after triggering a run: keeps the list polling for + // a moment even if the goroutine hasn't flipped the running flag yet. + ctx.SetData("pollNow", ctx.QueryParam("run") == "1") + return ctx.Html("admin_actions.html") +} + func AdminSyncReposFromFS(ctx *context.Context) error { - ctx.AddFlash(ctx.Tr("flash.admin.sync-fs"), "success") - go actions.RunOnce(actions.SyncReposFromFS) - return ctx.RedirectTo("/admin-panel") + return runAdminAction(ctx, actions.SyncReposFromFS, "flash.admin.sync-fs") } func AdminSyncReposFromDB(ctx *context.Context) error { - ctx.AddFlash(ctx.Tr("flash.admin.sync-db"), "success") - go actions.RunOnce(actions.SyncReposFromDB) - return ctx.RedirectTo("/admin-panel") + return runAdminAction(ctx, actions.SyncReposFromDB, "flash.admin.sync-db") } func AdminGcRepos(ctx *context.Context) error { - ctx.AddFlash(ctx.Tr("flash.admin.git-gc"), "success") - go actions.RunOnce(actions.GitGcRepos) - return ctx.RedirectTo("/admin-panel") + return runAdminAction(ctx, actions.GitGcRepos, "flash.admin.git-gc") } func AdminSyncGistPreviews(ctx *context.Context) error { - ctx.AddFlash(ctx.Tr("flash.admin.sync-previews"), "success") - go actions.RunOnce(actions.SyncGistPreviews) - return ctx.RedirectTo("/admin-panel") + return runAdminAction(ctx, actions.SyncGistPreviews, "flash.admin.sync-previews") } func AdminResetHooks(ctx *context.Context) error { - ctx.AddFlash(ctx.Tr("flash.admin.reset-hooks"), "success") - go actions.RunOnce(actions.ResetHooks) - return ctx.RedirectTo("/admin-panel") + return runAdminAction(ctx, actions.ResetHooks, "flash.admin.reset-hooks") } func AdminIndexGists(ctx *context.Context) error { - ctx.AddFlash(ctx.Tr("flash.admin.index-gists"), "success") - go actions.RunOnce(actions.IndexGists) - return ctx.RedirectTo("/admin-panel") + return runAdminAction(ctx, actions.IndexGists, "flash.admin.index-gists") } func AdminSyncGistLanguages(ctx *context.Context) error { - ctx.AddFlash(ctx.Tr("flash.admin.sync-gist-languages"), "success") - go actions.RunOnce(actions.SyncGistLanguages) - return ctx.RedirectTo("/admin-panel") + return runAdminAction(ctx, actions.SyncGistLanguages, "flash.admin.sync-gist-languages") } func AdminDeleteExpiredGists(ctx *context.Context) error { - ctx.AddFlash(ctx.Tr("flash.admin.delete-expired-gists"), "success") - go actions.RunOnce(actions.DeleteExpiredGists) - return ctx.RedirectTo("/admin-panel") + return runAdminAction(ctx, actions.DeleteExpiredGists, "flash.admin.delete-expired-gists") } func AdminSyncSSHKeys(ctx *context.Context) error { - ctx.AddFlash(ctx.Tr("flash.admin.sync-ssh-keys"), "success") - go actions.RunOnce(actions.SyncSSHKeys) - return ctx.RedirectTo("/admin-panel") + return runAdminAction(ctx, actions.SyncSSHKeys, "flash.admin.sync-ssh-keys") +} + +func runAdminAction(ctx *context.Context, actionType int, legacyFlashKey string) error { + go actions.RunOnce(actionType) + if ctx.QueryParam("legacy") == "1" { + ctx.AddFlash(ctx.Tr(legacyFlashKey), "success") + return ctx.RedirectTo("/-/admin-panel") + } + return ctx.RedirectTo("/-/admin-panel/actions?run=1") } diff --git a/internal/web/handlers/admin/actions_test.go b/internal/web/handlers/admin/actions_test.go deleted file mode 100644 index 65cefdb..0000000 --- a/internal/web/handlers/admin/actions_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package admin_test - -import ( - "testing" - - "github.com/stretchr/testify/require" - webtest "github.com/thomiceli/opengist/internal/web/test" -) - -func TestAdminActions(t *testing.T) { - s := webtest.Setup(t) - defer webtest.Teardown(t) - urls := []string{ - "/admin-panel/sync-fs", - "/admin-panel/sync-db", - "/admin-panel/gc-repos", - "/admin-panel/sync-previews", - "/admin-panel/reset-hooks", - "/admin-panel/index-gists", - "/admin-panel/sync-languages", - } - - s.Register(t, "thomas") - s.Register(t, "nonadmin") - - t.Run("NoUser", func(t *testing.T) { - for _, url := range urls { - s.Request(t, "POST", url, nil, 404) - } - }) - - t.Run("AdminUser", func(t *testing.T) { - s.Login(t, "thomas") - for _, url := range urls { - resp := s.Request(t, "POST", url, nil, 302) - require.Equal(t, "/admin-panel", resp.Header.Get("Location")) - } - }) - - t.Run("NonAdminUser", func(t *testing.T) { - s.Login(t, "nonadmin") - for _, url := range urls { - s.Request(t, "POST", url, nil, 404) - } - }) -} diff --git a/internal/web/handlers/admin/admin.go b/internal/web/handlers/admin/admin.go index 653a472..9453df2 100644 --- a/internal/web/handlers/admin/admin.go +++ b/internal/web/handlers/admin/admin.go @@ -44,6 +44,8 @@ func AdminIndex(ctx *context.Context) error { } ctx.SetData("countKeys", countKeys) + // The legacy admin dashboard renders actions on this page. Keep supplying + // their state while the new UI uses the dedicated actions page. ctx.SetData("syncReposFromFS", actions.IsRunning(actions.SyncReposFromFS)) ctx.SetData("syncReposFromDB", actions.IsRunning(actions.SyncReposFromDB)) ctx.SetData("gitGcRepos", actions.IsRunning(actions.GitGcRepos)) @@ -108,7 +110,7 @@ func AdminUserDelete(ctx *context.Context) error { opengistssh.SyncAuthorizedKeysLogged() ctx.AddFlash(ctx.Tr("flash.admin.user-deleted"), "success") - return ctx.RedirectTo("/admin-panel/users") + return ctx.RedirectTo("/-/admin-panel/users") } func AdminGistDelete(ctx *context.Context) error { @@ -124,7 +126,7 @@ func AdminGistDelete(ctx *context.Context) error { gist.RemoveFromIndex() ctx.AddFlash(ctx.Tr("flash.admin.gist-deleted"), "success") - return ctx.RedirectTo("/admin-panel/gists") + return ctx.RedirectTo("/-/admin-panel/gists") } func AdminConfig(ctx *context.Context) error { @@ -187,7 +189,7 @@ func AdminInvitationsCreate(ctx *context.Context) error { } ctx.AddFlash(ctx.Tr("flash.admin.invitation-created"), "success") - return ctx.RedirectTo("/admin-panel/invitations") + return ctx.RedirectTo("/-/admin-panel/invitations") } func AdminInvitationsDelete(ctx *context.Context) error { @@ -202,5 +204,5 @@ func AdminInvitationsDelete(ctx *context.Context) error { } ctx.AddFlash(ctx.Tr("flash.admin.invitation-deleted"), "success") - return ctx.RedirectTo("/admin-panel/invitations") + return ctx.RedirectTo("/-/admin-panel/invitations") } diff --git a/internal/web/handlers/admin/admin_test.go b/internal/web/handlers/admin/admin_test.go index ad06bdd..b72f5af 100644 --- a/internal/web/handlers/admin/admin_test.go +++ b/internal/web/handlers/admin/admin_test.go @@ -20,11 +20,12 @@ func TestAdminPages(t *testing.T) { defer webtest.Teardown(t) urls := []string{ - "/admin-panel", - "/admin-panel/users", - "/admin-panel/gists", - "/admin-panel/invitations", - "/admin-panel/configuration", + "/-/admin-panel", + "/-/admin-panel/users", + "/-/admin-panel/gists", + "/-/admin-panel/invitations", + "/-/admin-panel/configuration", + "/-/admin-panel/actions", } s.Register(t, "thomas") @@ -67,12 +68,12 @@ func TestAdminSetConfig(t *testing.T) { s.Register(t, "nonadmin") t.Run("NoUser", func(t *testing.T) { - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404) }) t.Run("NonAdminUser", func(t *testing.T) { s.Login(t, "nonadmin") - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {db.SettingDisableSignup}, "value": {"1"}}, 404) }) t.Run("AdminUser", func(t *testing.T) { @@ -83,13 +84,13 @@ func TestAdminSetConfig(t *testing.T) { require.NoError(t, err) require.Equal(t, "0", val) - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {setting}, "value": {"1"}}, 200) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {setting}, "value": {"1"}}, 200) val, err = db.GetSetting(setting) require.NoError(t, err) require.Equal(t, "1", val) - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {setting}, "value": {"0"}}, 200) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {setting}, "value": {"0"}}, 200) val, err = db.GetSetting(setting) require.NoError(t, err) @@ -110,12 +111,12 @@ func TestAdminPagination(t *testing.T) { t.Run("Pagination", func(t *testing.T) { s.Login(t, "thomas") - s.Request(t, "GET", "/admin-panel/users", nil, 200) - s.Request(t, "GET", "/admin-panel/users?page=2", nil, 200) - s.Request(t, "GET", "/admin-panel/users?page=3", nil, 404) - s.Request(t, "GET", "/admin-panel/users?page=0", nil, 200) - s.Request(t, "GET", "/admin-panel/users?page=-1", nil, 200) - s.Request(t, "GET", "/admin-panel/users?page=a", nil, 200) + s.Request(t, "GET", "/-/admin-panel/users", nil, 200) + s.Request(t, "GET", "/-/admin-panel/users?page=2", nil, 200) + s.Request(t, "GET", "/-/admin-panel/users?page=3", nil, 404) + s.Request(t, "GET", "/-/admin-panel/users?page=0", nil, 200) + s.Request(t, "GET", "/-/admin-panel/users?page=-1", nil, 200) + s.Request(t, "GET", "/-/admin-panel/users?page=a", nil, 200) }) } @@ -147,11 +148,11 @@ func TestAdminUserOperations(t *testing.T) { require.NoError(t, err) require.Equal(t, int64(2), count) - s.Request(t, "POST", "/admin-panel/users/2/delete", nil, 404) + s.Request(t, "POST", "/-/admin-panel/users/2/delete", nil, 404) s.Login(t, "thomas") - s.Request(t, "POST", "/admin-panel/users/2/delete", nil, 302) + s.Request(t, "POST", "/-/admin-panel/users/2/delete", nil, 302) count, err = db.CountAll(db.User{}) require.NoError(t, err) @@ -192,11 +193,11 @@ func TestAdminGistOperations(t *testing.T) { _, err = os.Stat(filepath.Join(config.GetHomeDir(), git.ReposDirectory, "nonadmin", gist1Db.Identifier())) require.NoError(t, err) - s.Request(t, "POST", "/admin-panel/gists/1/delete", nil, 404) + s.Request(t, "POST", "/-/admin-panel/gists/1/delete", nil, 404) s.Login(t, "thomas") - s.Request(t, "POST", "/admin-panel/gists/1/delete", nil, 302) + s.Request(t, "POST", "/-/admin-panel/gists/1/delete", nil, 302) count, err = db.CountAll(db.Gist{}) require.NoError(t, err) @@ -217,7 +218,7 @@ func TestAdminInvitationOperations(t *testing.T) { t.Run("Invitation", func(t *testing.T) { s.Login(t, "thomas") - s.Request(t, "POST", "/admin-panel/invitations", url.Values{ + s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{ "nbMax": {""}, "expiredAtUnix": {""}, }, 302) @@ -228,7 +229,7 @@ func TestAdminInvitationOperations(t *testing.T) { require.Equal(t, uint(10), invitation1.NbMax) require.InDelta(t, time.Now().Unix()+604800, invitation1.ExpiresAt, 10) - s.Request(t, "POST", "/admin-panel/invitations", url.Values{ + s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{ "nbMax": {"aa"}, "expiredAtUnix": {"1735722000"}, }, 302) @@ -242,7 +243,7 @@ func TestAdminInvitationOperations(t *testing.T) { NbMax: 10, }) - s.Request(t, "POST", "/admin-panel/invitations", url.Values{ + s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{ "nbMax": {"20"}, "expiredAtUnix": {"1735722000"}, }, 302) @@ -260,7 +261,7 @@ func TestAdminInvitationOperations(t *testing.T) { require.NoError(t, err) require.Equal(t, int64(3), count) - s.Request(t, "POST", "/admin-panel/invitations/1/delete", nil, 302) + s.Request(t, "POST", "/-/admin-panel/invitations/1/delete", nil, 302) count, err = db.CountAll(db.Invitation{}) require.NoError(t, err) diff --git a/internal/web/handlers/auth/oauth.go b/internal/web/handlers/auth/oauth.go index 5d89c93..b7feffa 100644 --- a/internal/web/handlers/auth/oauth.go +++ b/internal/web/handlers/auth/oauth.go @@ -48,7 +48,7 @@ func Oauth(ctx *context.Context) error { provider, err := oauth.DefineProvider(providerStr, opengistUrl) if err != nil { ctx.AddFlash(ctx.Tr("error.oauth-unsupported"), "error") - return ctx.Redirect(302, "/login") + return ctx.Redirect(302, "/-/login") } if err = provider.RegisterProvider(); err != nil { @@ -63,7 +63,7 @@ func OauthCallback(ctx *context.Context) error { provider, err := oauth.CompleteUserAuth(ctx) if err != nil { ctx.AddFlash(fmt.Sprintf("%s: %s", ctx.Tr("auth.oauth.no-provider"), err.Error()), "error") - return ctx.Redirect(302, "/login") + return ctx.Redirect(302, "/-/login") } currUser := ctx.User @@ -74,7 +74,7 @@ func OauthCallback(ctx *context.Context) error { // check if this OAuth account is already linked to another user if existingUser, err := db.GetUserByProvider(user.UserID, provider.GetProvider()); err == nil && existingUser != nil { ctx.AddFlash(ctx.Tr("flash.auth.oauth-already-linked", config.C.OIDCProviderName), "error") - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } provider.UpdateUserDB(currUser) @@ -84,7 +84,7 @@ func OauthCallback(ctx *context.Context) error { } ctx.AddFlash(ctx.Tr("flash.auth.account-linked-oauth", config.C.OIDCProviderName), "success") - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } userDB, err := db.GetUserByProvider(user.UserID, provider.GetProvider()) @@ -92,7 +92,7 @@ func OauthCallback(ctx *context.Context) error { if err != nil { if ctx.GetData("DisableSignup") == true { ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error") - return ctx.Redirect(302, "/login") + return ctx.Redirect(302, "/-/login") } if !errors.Is(err, gorm.ErrRecordNotFound) { @@ -136,7 +136,7 @@ func OauthCallback(ctx *context.Context) error { func OauthRegister(ctx *context.Context) error { if ctx.GetData("DisableSignup") == true { ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error") - return ctx.Redirect(302, "/login") + return ctx.Redirect(302, "/-/login") } sess := ctx.GetSession() @@ -154,7 +154,7 @@ func OauthRegister(ctx *context.Context) error { func ProcessOauthRegister(ctx *context.Context) error { if ctx.GetData("DisableSignup") == true { ctx.AddFlash(ctx.Tr("error.signup-disabled"), "error") - return ctx.Redirect(302, "/login") + return ctx.Redirect(302, "/-/login") } sess := ctx.GetSession() @@ -292,8 +292,8 @@ func OauthUnlink(ctx *context.Context) error { } ctx.AddFlash(ctx.Tr("flash.auth.account-unlinked-oauth", config.C.OIDCProviderName), "success") - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } - return ctx.RedirectTo("/settings") + return ctx.RedirectTo("/-/settings") } diff --git a/internal/web/handlers/auth/oauth_test.go b/internal/web/handlers/auth/oauth_test.go index 4e1d7fa..ab50ad2 100644 --- a/internal/web/handlers/auth/oauth_test.go +++ b/internal/web/handlers/auth/oauth_test.go @@ -103,6 +103,6 @@ func TestOIDCLoginPKCE(t *testing.T) { }) defer resp.Body.Close() - require.Equal(t, "/login", resp.Request.URL.Path) + require.Equal(t, "/-/login", resp.Request.URL.Path) }) } diff --git a/internal/web/handlers/auth/password.go b/internal/web/handlers/auth/password.go index 6d1b9d2..acaf08b 100644 --- a/internal/web/handlers/auth/password.go +++ b/internal/web/handlers/auth/password.go @@ -131,7 +131,7 @@ func ProcessLogin(ctx *context.Context) error { if errors.As(err, &authErr) { log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP()) ctx.AddFlash(ctx.Tr("flash.auth.invalid-credentials"), "error") - return ctx.RedirectTo("/login") + return ctx.RedirectTo("/-/login") } return ctx.ErrorRes(500, "Authentication system error", nil) } @@ -159,5 +159,5 @@ func ProcessLogin(ctx *context.Context) error { func Logout(ctx *context.Context) error { ctx.DeleteSession() ctx.DeleteCsrfCookie() - return ctx.RedirectTo("/all") + return ctx.RedirectTo("/-/all") } diff --git a/internal/web/handlers/auth/password_test.go b/internal/web/handlers/auth/password_test.go index 3d606c8..45ebf07 100644 --- a/internal/web/handlers/auth/password_test.go +++ b/internal/web/handlers/auth/password_test.go @@ -16,7 +16,7 @@ func TestRegisterPage(t *testing.T) { s.Register(t, "thomas") t.Run("Form", func(t *testing.T) { - s.Request(t, "GET", "/register", nil, 200) + s.Request(t, "GET", "/-/register", nil, 200) s.TestCtxData(t, echo.Map{ "isLoginPage": false, "disableForm": false, @@ -26,10 +26,10 @@ func TestRegisterPage(t *testing.T) { t.Run("FormDisabled", func(t *testing.T) { s.Login(t, "thomas") - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200) s.Logout() - s.Request(t, "GET", "/register", nil, 200) + s.Request(t, "GET", "/-/register", nil, 200) s.TestCtxData(t, echo.Map{ "disableSignup": true, }) @@ -37,9 +37,9 @@ func TestRegisterPage(t *testing.T) { t.Run("FormDisabledWithInviteCode", func(t *testing.T) { s.Login(t, "thomas") - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200) - s.Request(t, "POST", "/admin-panel/invitations", url.Values{ + s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{ "nbMax": {"10"}, "expiredAtUnix": {""}, }, 302) @@ -49,11 +49,11 @@ func TestRegisterPage(t *testing.T) { s.Logout() - s.Request(t, "GET", "/register", nil, 200) + s.Request(t, "GET", "/-/register", nil, 200) s.TestCtxData(t, echo.Map{ "disableSignup": true, }) - s.Request(t, "GET", "/register?code="+invitation.Code, nil, 200) + s.Request(t, "GET", "/-/register?code="+invitation.Code, nil, 200) s.TestCtxData(t, echo.Map{ "disableSignup": false, }) @@ -73,7 +73,7 @@ func TestProcessRegister(t *testing.T) { require.True(t, user.IsAdmin) s.Logout() - s.Request(t, "POST", "/register", db.UserDTO{Username: "seconduser", Password: "password123"}, 302) + s.Request(t, "POST", "/-/register", db.UserDTO{Username: "seconduser", Password: "password123"}, 302) user, err = db.GetUserByUsername("seconduser") require.NoError(t, err) require.False(t, user.IsAdmin) @@ -81,27 +81,32 @@ func TestProcessRegister(t *testing.T) { }) t.Run("DuplicateUsername", func(t *testing.T) { - s.Request(t, "POST", "/register", db.UserDTO{Username: "useraaa", Password: "password123"}, 302) + s.Request(t, "POST", "/-/register", db.UserDTO{Username: "useraaa", Password: "password123"}, 302) s.Logout() - s.Request(t, "POST", "/register", db.UserDTO{Username: "useraaa", Password: "password456"}, 200) + s.Request(t, "POST", "/-/register", db.UserDTO{Username: "useraaa", Password: "password456"}, 200) s.Logout() }) t.Run("InvalidUsername", func(t *testing.T) { - s.Request(t, "POST", "/register", db.UserDTO{Username: "", Password: "password123"}, 200) - s.Request(t, "POST", "/register", db.UserDTO{Username: "aze@", Password: "password123"}, 200) + s.Request(t, "POST", "/-/register", db.UserDTO{Username: "", Password: "password123"}, 200) + s.Request(t, "POST", "/-/register", db.UserDTO{Username: "aze@", Password: "password123"}, 200) + s.Request(t, "POST", "/-/register", db.UserDTO{Username: "-", Password: "password123"}, 200) + + exists, err := db.UserExists("-") + require.NoError(t, err) + require.False(t, exists) }) t.Run("EmptyPassword", func(t *testing.T) { - s.Request(t, "POST", "/register", db.UserDTO{Username: "newuser", Password: ""}, 200) + s.Request(t, "POST", "/-/register", db.UserDTO{Username: "newuser", Password: ""}, 200) }) t.Run("RegisterDisabled", func(t *testing.T) { s.Login(t, "thomas") - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200) s.Logout() - s.Request(t, "POST", "/register", db.UserDTO{Username: "blocked", Password: "password123"}, 403) + s.Request(t, "POST", "/-/register", db.UserDTO{Username: "blocked", Password: "password123"}, 403) exists, err := db.UserExists("blocked") require.NoError(t, err) @@ -110,8 +115,8 @@ func TestProcessRegister(t *testing.T) { t.Run("RegisterWithInvitationCode", func(t *testing.T) { s.Login(t, "thomas") - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200) - s.Request(t, "POST", "/admin-panel/invitations", url.Values{ + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-signup"}, "value": {"1"}}, 200) + s.Request(t, "POST", "/-/admin-panel/invitations", url.Values{ "nbMax": {"10"}, "expiredAtUnix": {""}, }, 302) @@ -124,7 +129,7 @@ func TestProcessRegister(t *testing.T) { s.Logout() - s.Request(t, "POST", "/register?code="+invitation.Code, db.UserDTO{Username: "inviteduser", Password: "password123"}, 302) + s.Request(t, "POST", "/-/register?code="+invitation.Code, db.UserDTO{Username: "inviteduser", Password: "password123"}, 302) user, err := db.GetUserByUsername("inviteduser") require.NoError(t, err) @@ -142,7 +147,7 @@ func TestLoginPage(t *testing.T) { s.Register(t, "thomas") t.Run("Form", func(t *testing.T) { - s.Request(t, "GET", "/login", nil, 200) + s.Request(t, "GET", "/-/login", nil, 200) s.TestCtxData(t, echo.Map{ "isLoginPage": true, "disableForm": false, @@ -151,10 +156,10 @@ func TestLoginPage(t *testing.T) { t.Run("FormDisabled", func(t *testing.T) { s.Login(t, "thomas") - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200) s.Logout() - s.Request(t, "GET", "/login", nil, 200) + s.Request(t, "GET", "/-/login", nil, 200) s.TestCtxData(t, echo.Map{ "disableForm": true, }) @@ -168,7 +173,7 @@ func TestProcessLogin(t *testing.T) { s.Register(t, "thomas") t.Run("ValidCredentials", func(t *testing.T) { - resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 302) + resp := s.Request(t, "POST", "/-/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 302) require.Equal(t, "/", resp.Header.Get("Location")) require.NotEmpty(t, s.SessionCookie) require.Equal(t, "thomas", s.User().Username) @@ -177,28 +182,28 @@ func TestProcessLogin(t *testing.T) { }) t.Run("InvalidPassword", func(t *testing.T) { - resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "wrongpassword"}, 302) - require.Equal(t, "/login", resp.Header.Get("Location")) + resp := s.Request(t, "POST", "/-/login", db.UserDTO{Username: "thomas", Password: "wrongpassword"}, 302) + require.Equal(t, "/-/login", resp.Header.Get("Location")) require.Nil(t, s.User()) }) t.Run("NonExistentUser", func(t *testing.T) { - resp := s.Request(t, "POST", "/login", db.UserDTO{Username: "nonexistent", Password: "password"}, 302) - require.Equal(t, "/login", resp.Header.Get("Location")) + resp := s.Request(t, "POST", "/-/login", db.UserDTO{Username: "nonexistent", Password: "password"}, 302) + require.Equal(t, "/-/login", resp.Header.Get("Location")) require.Nil(t, s.User()) }) t.Run("EmptyCredentials", func(t *testing.T) { - s.Request(t, "POST", "/login", db.UserDTO{Username: "", Password: ""}, 302) + s.Request(t, "POST", "/-/login", db.UserDTO{Username: "", Password: ""}, 302) require.Nil(t, s.User()) }) t.Run("LoginFormDisabled", func(t *testing.T) { s.Login(t, "thomas") - s.Request(t, "PUT", "/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200) + s.Request(t, "PUT", "/-/admin-panel/set-config", url.Values{"key": {"disable-login-form"}, "value": {"1"}}, 200) s.Logout() - s.Request(t, "POST", "/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 403) + s.Request(t, "POST", "/-/login", db.UserDTO{Username: "thomas", Password: "thomas"}, 403) require.Nil(t, s.User()) }) } @@ -213,8 +218,8 @@ func TestLogout(t *testing.T) { s.Login(t, "thomas") require.Equal(t, "thomas", s.User().Username) - resp := s.Request(t, "GET", "/logout", nil, 302) - require.Equal(t, "/all", resp.Header.Get("Location")) + resp := s.Request(t, "GET", "/-/logout", nil, 302) + require.Equal(t, "/-/all", resp.Header.Get("Location")) require.Nil(t, s.User()) s.Request(t, "GET", "/", nil, 302) }) diff --git a/internal/web/handlers/auth/totp.go b/internal/web/handlers/auth/totp.go index 2b38235..5850d1c 100644 --- a/internal/web/handlers/auth/totp.go +++ b/internal/web/handlers/auth/totp.go @@ -15,7 +15,7 @@ func BeginTotp(ctx *context.Context) error { return ctx.ErrorRes(500, "Cannot check for user MFA", err) } else if hasTotp { ctx.AddFlash(ctx.Tr("auth.totp.already-enabled"), "error") - return ctx.RedirectTo("/settings/mfa") + return ctx.RedirectTo("/-/settings/authentication") } ogUrl, err := url.Parse(ctx.GetData("baseHttpUrl").(string)) @@ -36,6 +36,7 @@ func BeginTotp(ctx *context.Context) error { ctx.SetData("totpSecret", totpSecret) ctx.SetData("totpQrcode", qrcode) + ctx.SetData("settingsHeaderPage", "authentication") return ctx.Html("totp.html") @@ -48,7 +49,7 @@ func FinishTotp(ctx *context.Context) error { return ctx.ErrorRes(500, "Cannot check for user MFA", err) } else if hasTotp { ctx.AddFlash(ctx.Tr("auth.totp.already-enabled"), "error") - return ctx.RedirectTo("/settings/mfa") + return ctx.RedirectTo("/-/settings/authentication") } dto := &db.TOTPDTO{} @@ -58,7 +59,7 @@ func FinishTotp(ctx *context.Context) error { if err := ctx.Validate(dto); err != nil { ctx.AddFlash("Invalid secret", "error") - return ctx.RedirectTo("/settings/totp/generate") + return ctx.RedirectTo("/-/settings/totp/generate") } sess := ctx.GetSession() @@ -70,7 +71,7 @@ func FinishTotp(ctx *context.Context) error { if !totp.Validate(dto.Code, secret) { ctx.AddFlash(ctx.Tr("auth.totp.invalid-code"), "error") - return ctx.RedirectTo("/settings/totp/generate") + return ctx.RedirectTo("/-/settings/totp/generate") } userTotp := &db.TOTP{ @@ -95,6 +96,7 @@ func FinishTotp(ctx *context.Context) error { ctx.SaveSession(sess) ctx.SetData("recoveryCodes", codes) + ctx.SetData("settingsHeaderPage", "authentication") return ctx.Html("totp.html") } @@ -135,7 +137,7 @@ func AssertTotp(ctx *context.Context) error { } ctx.AddFlash(ctx.Tr("auth.totp.code-used", dto.Code), "warning") - redirectUrl = "/settings/mfa" + redirectUrl = "/-/settings/authentication" } sess.Values["user"] = userId @@ -158,7 +160,7 @@ func DisableTotp(ctx *context.Context) error { } ctx.AddFlash(ctx.Tr("auth.totp.disabled"), "success") - return ctx.RedirectTo("/settings/mfa") + return ctx.RedirectTo("/-/settings/authentication") } func RegenerateTotpRecoveryCodes(ctx *context.Context) error { @@ -174,5 +176,6 @@ func RegenerateTotpRecoveryCodes(ctx *context.Context) error { } ctx.SetData("recoveryCodes", codes) + ctx.SetData("settingsHeaderPage", "authentication") return ctx.Html("totp.html") } diff --git a/internal/web/handlers/gist/all.go b/internal/web/handlers/gist/all.go index 88bca4e..956f1c8 100644 --- a/internal/web/handlers/gist/all.go +++ b/internal/web/handlers/gist/all.go @@ -21,31 +21,41 @@ func AllGists(ctx *context.Context) error { fromUserStr := ctx.Param("user") userLogged := ctx.User pageInt := handlers.GetPage(ctx) + mode := ctx.GetData("mode") sort := "created" order := "desc" - if userLogged != nil { - if style := userLogged.GetStyle(); style != nil { - if style.DefaultSort == "updated" { - sort = "updated" - } - if style.DefaultOrder == "asc" { - order = "asc" + // Some feeds (e.g. "recently liked" / "recently forked") have a fixed order: + // creation time descending, ignoring the user's default-sort style and the + // sort/order query params. The template hides the sort dropdown when sortable + // is false. + sortable := mode != "all-liked" && mode != "all-forked" + ctx.SetData("sortable", sortable) + + if sortable { + if userLogged != nil { + if style := userLogged.GetStyle(); style != nil { + if style.DefaultSort == "updated" { + sort = "updated" + } + if style.DefaultOrder == "asc" { + order = "asc" + } } } - } - if ctx.QueryParam("sort") == "updated" { - sort = "updated" - } else if ctx.QueryParam("sort") == "created" { - sort = "created" - } + if ctx.QueryParam("sort") == "updated" { + sort = "updated" + } else if ctx.QueryParam("sort") == "created" { + sort = "created" + } - if ctx.QueryParam("order") == "asc" { - order = "asc" - } else if ctx.QueryParam("order") == "desc" { - order = "desc" + if ctx.QueryParam("order") == "asc" { + order = "asc" + } else if ctx.QueryParam("order") == "desc" { + order = "desc" + } } sortText := ctx.TrH("gist.list.sort-by-" + sort) @@ -67,24 +77,50 @@ func AllGists(ctx *context.Context) error { currentUserId = 0 } - mode := ctx.GetData("mode") if fromUserStr == "" { switch mode { case "search": ctx.SetData("htmlTitle", ctx.TrH("gist.list.search-results")) ctx.SetData("searchQuery", ctx.QueryParam("q")) pagination.Query = ctx.QueryParam("q") - urlPage = "search" + urlPage = "-/search" gists, err = db.GetAllGistsFromSearch(currentUserId, ctx.QueryParam("q"), pageInt-1, sort, order, "") case "topics": ctx.SetData("htmlTitle", ctx.TrH("gist.list.topic-results-topic", ctx.Param("topic"))) ctx.SetData("topic", ctx.Param("topic")) - urlPage = "topics/" + ctx.Param("topic") - gists, err = db.GetAllGistsFromSearch(currentUserId, "", pageInt-1, sort, order, ctx.Param("topic")) + urlPage = "-/topics/" + ctx.Param("topic") + + if languages, err := db.GetGistLanguagesByTopic(currentUserId, ctx.Param("topic")); err != nil { + return ctx.ErrorRes(500, "Error fetching languages", err) + } else { + ctx.SetData("languages", languages) + } + + title, language, visibility, topics := readGistFilters(ctx, pagination) + gists, _, err = db.GetAllGistsByTopicFiltered(currentUserId, ctx.Param("topic"), title, language, visibility, topics, pageInt-1, sort, order) case "all": + ctx.SetData("currentPage", "all") ctx.SetData("htmlTitle", ctx.TrH("gist.list.all")) - urlPage = "all" - gists, err = db.GetAllGistsForCurrentUser(currentUserId, nil, pageInt-1, sort, order, 11, 10) + urlPage = "-/all" + + if languages, err := db.GetGistLanguages(currentUserId); err != nil { + return ctx.ErrorRes(500, "Error fetching languages", err) + } else { + ctx.SetData("languages", languages) + } + + title, language, visibility, topics := readGistFilters(ctx, pagination) + gists, _, err = db.GetAllGistsForCurrentUserFiltered(currentUserId, title, language, visibility, topics, pageInt-1, sort, order) + case "all-liked": + ctx.SetData("currentPage", "recently-liked") + ctx.SetData("htmlTitle", ctx.TrH("gist.list.recently-liked")) + urlPage = "-/liked" + gists, err = db.GetAllGistsLiked(currentUserId, nil, pageInt-1, sort, order, 11, 10) + case "all-forked": + ctx.SetData("currentPage", "recently-forked") + ctx.SetData("htmlTitle", ctx.TrH("gist.list.recently-forked")) + urlPage = "-/forked" + gists, err = db.GetAllGistsForked(currentUserId, nil, pageInt-1, sort, order, 11, 10) } } else { var fromUser *db.User @@ -99,6 +135,12 @@ func AllGists(ctx *context.Context) error { } ctx.SetData("fromUser", fromUser) + // Highlight "My gists" on any of the logged-in user's own tabs + // (gists, liked, forked). + if userLogged != nil && fromUserStr == userLogged.Username { + ctx.SetData("currentPage", "mine") + } + if countFromUser, err := db.CountAllGistsFromUser(fromUser.ID, currentUserId); err != nil { return ctx.ErrorRes(500, "Error counting gists", err) } else { @@ -134,25 +176,7 @@ func AllGists(ctx *context.Context) error { } else { ctx.SetData("languages", languages) } - title := ctx.QueryParam("title") - language := ctx.QueryParam("language") - visibility := ctx.QueryParam("visibility") - topicsStr := ctx.QueryParam("topics") - topics := strings.Fields(topicsStr) - if len(topics) > 10 { - topics = topics[:10] - } - slices.Sort(topics) - topics = slices.Compact(topics) - pagination.Title = title - pagination.Language = language - pagination.Visibility = visibility - pagination.Topics = topicsStr - - ctx.SetData("title", title) - ctx.SetData("language", language) - ctx.SetData("visibility", visibility) - ctx.SetData("topics", topicsStr) + title, language, visibility, topics := readGistFilters(ctx, pagination) ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-from", fromUserStr)) gists, count, err = db.GetAllGistsFromUser(fromUser.ID, currentUserId, title, language, visibility, topics, pageInt-1, sort, order) ctx.SetData("countFromUser", count) @@ -178,6 +202,34 @@ func AllGists(ctx *context.Context) error { return ctx.Html("all.html") } +// readGistFilters pulls the faceted filter query params (title, language, +// visibility, topics) shared by the explore "all"/"topics" feeds and the +// per-user gist list, mirrors them into pagination and template data, and +// returns the parsed values. +func readGistFilters(ctx *context.Context, pagination *handlers.PaginationParams) (title, language, visibility string, topics []string) { + title = ctx.QueryParam("title") + language = ctx.QueryParam("language") + visibility = ctx.QueryParam("visibility") + topicsStr := ctx.QueryParam("topics") + topics = strings.Fields(topicsStr) + if len(topics) > 10 { + topics = topics[:10] + } + slices.Sort(topics) + topics = slices.Compact(topics) + + pagination.Title = title + pagination.Language = language + pagination.Visibility = visibility + pagination.Topics = topicsStr + + ctx.SetData("title", title) + ctx.SetData("language", language) + ctx.SetData("visibility", visibility) + ctx.SetData("topics", topicsStr) + return +} + // Search handles the search page for gists. // // It takes a query parameter "q" which is a search query in the format: diff --git a/internal/web/handlers/gist/create.go b/internal/web/handlers/gist/create.go index 108ddbd..c31c19a 100644 --- a/internal/web/handlers/gist/create.go +++ b/internal/web/handlers/gist/create.go @@ -17,6 +17,8 @@ import ( ) func Create(ctx *context.Context) error { + ctx.SetData("currentPage", "new") + ctx.SetData("dto", new(db.GistDTO)) ctx.SetData("htmlTitle", ctx.TrH("gist.new.create-a-new-gist")) return ctx.Html("create.html") } @@ -139,7 +141,9 @@ func ProcessCreate(ctx *context.Context) error { if isCreate { gist = dto.ToGist() gist.ExpiresAt = dto.ExpiresAtTimestamp() - } else { + } else if ctx.FormValue("_edit_metadata") == "1" { + // The legacy editor still edits files and metadata in one form. The new + // editor omits this marker because metadata has its own settings page. gist = dto.ToExistingGist(gist) } diff --git a/internal/web/handlers/gist/edit_test.go b/internal/web/handlers/gist/edit_test.go index b139644..064962f 100644 --- a/internal/web/handlers/gist/edit_test.go +++ b/internal/web/handlers/gist/edit_test.go @@ -139,3 +139,41 @@ func TestArchive(t *testing.T) { }, 302) }) } + +func TestLegacyEditorUpdatesMetadata(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + s.Register(t, "thomas") + _, original, username, identifier := s.CreateGist(t, "0") + + s.Login(t, "thomas") + s.Request(t, "POST", "/"+username+"/"+identifier+"/edit", url.Values{ + "_edit_metadata": {"1"}, + "title": {"Updated from legacy UI"}, + "description": {"Updated description"}, + "url": {"legacy-url"}, + "topics": {"legacy compatibility"}, + "name": {"file.txt"}, + "content": {"updated content"}, + }, 302) + + updated, err := db.GetGist(username, "legacy-url") + require.NoError(t, err) + require.Equal(t, original.ID, updated.ID) + require.Equal(t, "Updated from legacy UI", updated.Title) + require.Equal(t, "Updated description", updated.Description) + require.ElementsMatch(t, []string{"legacy", "compatibility"}, updated.TopicsSlice()) + + // A file-only edit from the new UI must keep metadata unchanged. + s.Request(t, "POST", "/"+username+"/legacy-url/edit", url.Values{ + "name": {"file.txt"}, + "content": {"new UI content"}, + }, 302) + + updated, err = db.GetGist(username, "legacy-url") + require.NoError(t, err) + require.Equal(t, "Updated from legacy UI", updated.Title) + require.Equal(t, "Updated description", updated.Description) + require.ElementsMatch(t, []string{"legacy", "compatibility"}, updated.TopicsSlice()) +} diff --git a/internal/web/handlers/gist/fork.go b/internal/web/handlers/gist/fork.go index a090d49..e27050b 100644 --- a/internal/web/handlers/gist/fork.go +++ b/internal/web/handlers/gist/fork.go @@ -82,6 +82,7 @@ func Forks(ctx *context.Context) error { return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil) } + ctx.SetData("page", "forks") ctx.SetData("htmlTitle", ctx.TrH("gist.forks.for", gist.Title)) ctx.SetData("revision", "HEAD") return ctx.Html("forks.html") diff --git a/internal/web/handlers/gist/gist.go b/internal/web/handlers/gist/gist.go index 7d6b276..acc6b04 100644 --- a/internal/web/handlers/gist/gist.go +++ b/internal/web/handlers/gist/gist.go @@ -144,6 +144,13 @@ func GistJs(ctx *context.Context) error { autoMode = false } + // In auto mode the card design follows the OS via a prefers-color-scheme + // media query (see .theme-auto in embed.css), so it no longer depends on the + // JS class-toggle alone. + if autoMode { + ctx.SetData("themeAuto", true) + } + gist := ctx.GetData("gist").(*db.Gist) var files []*git.File @@ -259,7 +266,7 @@ func escapeJavaScriptContent(htmlContent, cssUrl, themeUrl string, autoMode bool