feat(aether): initial commit – self-hosted Cloud, Phasen 1–7

Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+200
View File
@@ -0,0 +1,200 @@
package office
import (
"context"
"crypto/tls"
"encoding/xml"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// Die Ermittlungsliste („discovery") sagt, welche Adresse ein Office-Server
// für welchen Dateityp anbietet.
//
// Fest verdrahten ließe sich das nicht: Collabora und OnlyOffice bauen ihre
// Adressen verschieden auf, und beide ändern sie zwischen Fassungen. Gefragt
// wird deshalb der Server selbst – einmal, dann für eine Weile gemerkt.
type discoveryXML struct {
NetZone struct {
Apps []struct {
Name string `xml:"name,attr"`
Actions []struct {
Name string `xml:"name,attr"`
Ext string `xml:"ext,attr"`
URLSrc string `xml:"urlsrc,attr"`
} `xml:"action"`
} `xml:"app"`
} `xml:"net-zone"`
}
// discoveryTTL: Wie lange die Liste gilt. Kurz genug, dass ein Neustart des
// Office-Servers nach einer Aktualisierung durchschlägt; lang genug, dass
// nicht jedes Öffnen eine zusätzliche Anfrage auslöst.
const discoveryTTL = 30 * time.Minute
// Client spricht mit dem Office-Server.
type Client struct {
provider string
baseURL string
// publicBase ist die Adresse, unter der der **Browser** den Office-Server
// erreicht. Die Ermittlungsliste nennt die, unter der der Server sich
// selbst sieht – im Compose-Netz also `collabora:9980`. Die schickt man
// keinem Browser.
publicBase string
http *http.Client
mu sync.Mutex
actions map[string]string // ".docx|edit" -> urlsrc
fetched time.Time
}
// NewClient baut den Zugang zum Office-Server.
//
// allowedHosts begrenzt, wohin überhaupt verbunden werden darf. **Das ist
// keine Formalie:** Das Backend ruft hier eine Adresse aus der Konfiguration
// auf – ohne Grenze wird daraus ein Weg ins interne Netz, sobald jemand die
// Konfiguration beeinflussen kann (SSRF). Die Adresse aus OFFICE_URL zählt
// immer dazu, sonst wäre die Voreinstellung unbrauchbar.
func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insecureTLS bool) (*Client, error) {
u, err := url.Parse(baseURL)
if err != nil || u.Host == "" {
return nil, fmt.Errorf("OFFICE_URL is not a valid address: %q", baseURL)
}
allowed := map[string]bool{strings.ToLower(u.Hostname()): true}
for _, h := range allowedHosts {
allowed[strings.ToLower(strings.TrimSpace(h))] = true
}
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
if !allowed[strings.ToLower(host)] {
return nil, fmt.Errorf("office: host %q is not in OFFICE_ALLOWED_HOSTS", host)
}
return (&net.Dialer{Timeout: 10 * time.Second}).DialContext(ctx, network, addr)
},
// #nosec G402 – nur wenn ausdrücklich eingeschaltet, für den
// Testaufbau mit selbstsigniertem Zertifikat auf demselben Rechner.
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecureTLS},
}
if publicURL == "" {
publicURL = baseURL
}
return &Client{
provider: provider,
baseURL: strings.TrimRight(baseURL, "/"),
publicBase: strings.TrimRight(publicURL, "/"),
http: &http.Client{Timeout: 20 * time.Second, Transport: transport},
}, nil
}
// toPublic schreibt Schema und Host einer Adresse aus der Ermittlungsliste auf
// die Adresse um, unter der der Browser den Server erreicht.
//
// Der Pfad bleibt unangetastet – er enthält die Fassungsnummer des Servers
// (`/browser/1ec9f2b9/cool.html`) und ändert sich mit jeder Aktualisierung.
func (c *Client) toPublic(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return raw
}
pub, err := url.Parse(c.publicBase)
if err != nil || pub.Host == "" {
return raw
}
u.Scheme, u.Host = pub.Scheme, pub.Host
return u.String()
}
func (c *Client) Provider() string { return c.provider }
// EditURL liefert die Adresse, unter der ein Dateityp bearbeitet wird.
//
// Fällt auf „view" zurück, wenn der Server für diesen Typ kein Bearbeiten
// anbietet – lesen ist besser als eine Fehlermeldung.
func (c *Client) EditURL(ctx context.Context, ext string, write bool) (string, error) {
if err := c.refresh(ctx); err != nil {
return "", err
}
c.mu.Lock()
defer c.mu.Unlock()
order := []string{"edit", "view"}
if !write {
order = []string{"view", "edit"}
}
for _, action := range order {
if u, ok := c.actions[ext+"|"+action]; ok {
return c.toPublic(u), nil
}
}
return "", ErrNoSupport
}
// refresh holt die Liste, wenn sie fehlt oder zu alt ist.
func (c *Client) refresh(ctx context.Context) error {
c.mu.Lock()
fresh := c.actions != nil && time.Since(c.fetched) < discoveryTTL
c.mu.Unlock()
if fresh {
return nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/hosting/discovery", nil)
if err != nil {
return err
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("office server unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("office discovery returned %d", resp.StatusCode)
}
// Grenze gegen eine Antwort, die kein Ende nimmt: Die Liste ist wenige
// hundert Kilobyte groß.
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return err
}
var doc discoveryXML
if err := xml.Unmarshal(body, &doc); err != nil {
return fmt.Errorf("office discovery is not valid XML: %w", err)
}
actions := map[string]string{}
for _, app := range doc.NetZone.Apps {
for _, a := range app.Actions {
if a.Ext == "" || a.URLSrc == "" {
continue
}
ext := a.Ext
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
actions[strings.ToLower(ext)+"|"+a.Name] = a.URLSrc
}
}
if len(actions) == 0 {
return fmt.Errorf("office discovery listed no file types")
}
c.mu.Lock()
c.actions, c.fetched = actions, time.Now()
c.mu.Unlock()
return nil
}
+305
View File
@@ -0,0 +1,305 @@
package office
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"aether/internal/acl"
"aether/internal/auth"
"aether/internal/files"
"aether/internal/storage"
"aether/pkg/response"
)
// maxSaveBytes begrenzt, was der Office-Server zurückschreiben darf.
//
// Ein Textdokument, das größer ist, hat kein Mensch geschrieben. Ohne Grenze
// wäre der Endpunkt eine Möglichkeit, die Platte zu füllen – er ist der
// einzige, der ohne Sitzung schreibt.
const maxSaveBytes = 512 << 20
type Handler struct {
client *Client
signer *Signer
files *files.Store
blobs storage.Storage
acl *acl.Store
// baseURL ist die Adresse dieser Anwendung im **Browser**. Sie steht als
// PostMessageOrigin in der Auskunft – der Office-Server prüft damit, an
// wen er Nachrichten aus dem Rahmen schicken darf.
baseURL string
// wopiURL ist die Adresse, unter der der **Office-Server** hierher findet.
// Aus seinem Container heraus ist „localhost" er selbst.
wopiURL string
}
func NewHandler(c *Client, s *Signer, fileStore *files.Store, blobs storage.Storage, aclStore *acl.Store, publicURL, wopiURL string) *Handler {
if wopiURL == "" {
wopiURL = publicURL
}
return &Handler{
client: c, signer: s, files: fileStore, blobs: blobs, acl: aclStore,
baseURL: strings.TrimRight(publicURL, "/"),
wopiURL: strings.TrimRight(wopiURL, "/"),
}
}
// Enabled sagt der Oberfläche, ob es die Bearbeitung überhaupt gibt. Ein
// nil-Handler heißt „abgeschaltet" – dann ist die Antwort eine Auskunft, kein
// Fehler.
func (h *Handler) Enabled() bool { return h != nil && h.client != nil }
// ── Vom Browser aufgerufen ───────────────────────────────────────────────────
// Session beantwortet GET /api/v1/office/session/{id}.
//
// Gibt zurück, wohin der Browser den Rahmen zeigen soll und mit welchem Token.
// Das Token entsteht **hier**, im angemeldeten Zusammenhang – der WOPI-Weg
// selbst kennt die Sitzung nicht.
func (h *Handler) Session(w http.ResponseWriter, r *http.Request) {
if !h.Enabled() {
response.JSON(w, http.StatusOK, map[string]any{"enabled": false})
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return
}
user := auth.UserFromContext(r.Context())
if user == nil {
response.Error(w, http.StatusUnauthorized, "not signed in")
return
}
// Zugriff auflösen: eigene Datei, geliehene oder Gruppenordner. Wer nur
// lesen darf, bekommt ein Token ohne Schreibrecht – nicht etwa gar keins.
access, err := h.acl.ResolveFile(r.Context(), user.ID, id)
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return
}
f, err := h.files.GetFile(r.Context(), id, access.OwnerID)
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return
}
if !Editable(f.Name) {
response.Error(w, http.StatusUnsupportedMediaType, "this file type cannot be opened in Office")
return
}
write := access.Permission == acl.PermWrite
src, err := h.client.EditURL(r.Context(), Extension(f.Name), write)
if err != nil {
log.Ctx(r.Context()).Warn().Err(err).Msg("office: discovery failed")
response.Error(w, http.StatusBadGateway, "the office server is not reachable")
return
}
token, exp := h.signer.Issue(f.ID, user.ID, write)
wopiSrc := fmt.Sprintf("%s/wopi/files/%s", h.wopiURL, f.ID)
// Die Adresse aus der Ermittlungsliste trägt Platzhalter in <>-Klammern
// („<ui=UI_LLCC&>"). Alles, was wir nicht setzen, muss weg – sonst
// schickt der Browser die Klammern mit.
src = stripPlaceholders(src)
sep := "?"
if strings.Contains(src, "?") {
sep = "&"
}
// **Vollständig kodiert**, nicht nur die auffälligen Zeichen: Collabora
// weist eine unkodierte WOPISrc zurück („WOPISrc must be URI-encoded.
// This is highly problematic with proxies, load balancers, and when
// tunneling") – Doppelpunkt und Schrägstrich eingeschlossen.
src = fmt.Sprintf("%s%sWOPISrc=%s", src, sep, url.QueryEscape(wopiSrc))
response.JSON(w, http.StatusOK, map[string]any{
"enabled": true,
"provider": h.client.Provider(),
"url": src,
"token": token,
"token_ttl": exp.UnixMilli(),
"can_write": write,
"file_name": f.Name,
})
}
// ── Vom Office-Server aufgerufen (WOPI) ──────────────────────────────────────
// authorize prüft das Token und holt Datei und Zugriffskontext.
//
// Absichtlich ohne Sitzung und ohne CSRF: Diese Aufrufe kommen vom
// Office-Server, nicht aus einem Browser. Das Token ist die einzige Berechtigung
// – und es gilt für **genau eine Datei**, damit ein entwendetes Token nicht
// zum Generalschlüssel wird.
func (h *Handler) authorize(w http.ResponseWriter, r *http.Request, needWrite bool) (*files.File, acl.Access, bool) {
raw := r.URL.Query().Get("access_token")
if raw == "" {
raw = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
access, err := h.signer.Verify(raw)
if err != nil {
// 401, nicht 403: Der Office-Server versucht es damit gar nicht erst
// erneut, statt in eine Schleife zu laufen.
http.Error(w, "invalid or expired token", http.StatusUnauthorized)
return nil, acl.Access{}, false
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil || id != access.FileID {
// Das Token gehört zu einer anderen Datei. 404 statt 403 – sonst
// verriete die Antwort, dass es die Datei gibt.
http.Error(w, "not found", http.StatusNotFound)
return nil, acl.Access{}, false
}
if needWrite && !access.Write {
http.Error(w, "read only", http.StatusForbidden)
return nil, acl.Access{}, false
}
// Die Rechte werden **bei jedem Aufruf neu** aufgelöst, nicht aus dem
// Token übernommen: Eine zurückgezogene Freigabe soll sofort wirken und
// nicht erst, wenn das Token abläuft.
ctxAccess, err := h.acl.ResolveFile(r.Context(), access.UserID, id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return nil, acl.Access{}, false
}
if needWrite && ctxAccess.Permission != acl.PermWrite {
http.Error(w, "read only", http.StatusForbidden)
return nil, acl.Access{}, false
}
f, err := h.files.GetFile(r.Context(), id, ctxAccess.OwnerID)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return nil, acl.Access{}, false
}
return f, ctxAccess, true
}
// CheckFileInfo beantwortet GET /wopi/files/{id} – die Auskunft über die Datei.
func (h *Handler) CheckFileInfo(w http.ResponseWriter, r *http.Request) {
f, access, ok := h.authorize(w, r, false)
if !ok {
return
}
canWrite := access.Permission == acl.PermWrite
// **Ohne die Hülle `{"data": …}` dieser Anwendung.** WOPI ist ein fremdes
// Protokoll mit festem Antwortformat; ein Feld drumherum verstünde der
// Office-Server nicht.
writeRawJSON(w, map[string]any{
"BaseFileName": f.Name,
"Size": f.Size,
"Version": fmt.Sprintf("%d", f.Version),
// Wer speichert, steht später in der Versionshistorie – dort soll ein
// Name stehen und nicht eine Kennung.
"OwnerId": f.OwnerID.String(),
"UserId": access.OwnerID.String(),
"UserFriendlyName": access.OwnerUsername,
"UserCanWrite": canWrite,
"UserCanNotWriteRelative": true,
// Kein „Speichern unter", kein Drucken-nach-Hause, keine Verweise auf
// fremde Dienste: Was die Datei verlassen könnte, bleibt aus.
"DisablePrint": false,
"DisableExport": false,
"DisableCopy": false,
"HideSaveOption": !canWrite,
"HideExportOption": false,
"PostMessageOrigin": h.baseURL,
"LastModifiedTime": f.UpdatedAt.UTC().Format(time.RFC3339),
})
}
// GetFile beantwortet GET /wopi/files/{id}/contents.
func (h *Handler) GetFile(w http.ResponseWriter, r *http.Request) {
f, _, ok := h.authorize(w, r, false)
if !ok {
return
}
rc, err := h.blobs.Get(r.Context(), f.StorageKey)
if err != nil {
http.Error(w, "storage error", http.StatusInternalServerError)
return
}
defer rc.Close()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("X-WOPI-ItemVersion", fmt.Sprintf("%d", f.Version))
if _, err := io.Copy(w, rc); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Msg("office: sending file failed")
}
}
// PutFile beantwortet POST /wopi/files/{id}/contents – das Speichern.
func (h *Handler) PutFile(w http.ResponseWriter, r *http.Request) {
f, access, ok := h.authorize(w, r, true)
if !ok {
return
}
body := http.MaxBytesReader(w, r.Body, maxSaveBytes)
defer body.Close()
updated, err := files.ReplaceContent(r.Context(), h.files, h.blobs, f, access, body)
if err != nil {
if errors.Is(err, files.ErrQuotaExceeded) {
// 413 sagt dem Office-Server, dass es an der Größe lag – er zeigt
// das dem Menschen an, statt still nicht zu speichern.
http.Error(w, "storage quota exceeded", http.StatusRequestEntityTooLarge)
return
}
log.Ctx(r.Context()).Error().Err(err).Str("file_id", f.ID.String()).
Msg("office: saving failed")
http.Error(w, "could not save", http.StatusInternalServerError)
return
}
w.Header().Set("X-WOPI-ItemVersion", fmt.Sprintf("%d", updated.Version))
w.WriteHeader(http.StatusOK)
}
// stripPlaceholders entfernt die <…>-Platzhalter aus einer urlsrc.
//
// Die Ermittlungsliste liefert z. B.
//
// https://office/browser/dist/cool.html?<ui=UI_LLCC&><rs=DC_LLCC&>
//
// Jeder Platzhalter, den der Host nicht ersetzt, muss weg – sonst landen die
// spitzen Klammern in der Adresse und der Server lehnt sie ab.
func stripPlaceholders(src string) string {
var b strings.Builder
depth := 0
for _, r := range src {
switch r {
case '<':
depth++
case '>':
if depth > 0 {
depth--
}
default:
if depth == 0 {
b.WriteRune(r)
}
}
}
out := b.String()
// Ein zurückbleibendes „?" oder „&" am Ende stört keinen Server, ist aber
// unsauber in der Adresszeile.
return strings.TrimRight(out, "?&")
}
// writeRawJSON schreibt ein Objekt ohne die Hülle dieser Anwendung.
func writeRawJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(v)
}
+167
View File
@@ -0,0 +1,167 @@
// Package office bindet einen Office-Server an – Collabora oder OnlyOffice.
//
// **Ein Adapter für beide.** Beide sprechen WOPI („Web Application Open
// Platform Interface"), die Schnittstelle, über die ein Office-Server sich
// eine Datei holt und wieder zurückschreibt. Zwei getrennte Integrationen zu
// bauen hieße, dieselbe Sache zweimal zu pflegen; umgeschaltet wird über
// OFFICE_PROVIDER in der .env.
//
// **Die Richtung der Aufrufe ist das Entscheidende.** Der Office-Server ist
// kein Anhängsel des Browsers – er holt die Datei selbst hier ab:
//
// Browser ──► Office-Server (die Bedienoberfläche im <iframe>)
// Office-Server ──► Aether /wopi/... (Datei holen, Datei zurückschreiben)
//
// Daraus folgt alles Weitere: Der Office-Server muss Aether unter seiner
// **öffentlichen** Adresse erreichen (PUBLIC_URL), nicht unter localhost. Und
// weil dieser Weg an der Sitzung vorbeiführt, trägt jeder Aufruf ein eigenes,
// kurzlebiges Zugriffstoken.
package office
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
var (
ErrBadToken = errors.New("invalid access token")
ErrExpired = errors.New("access token expired")
ErrNoSupport = errors.New("this file type cannot be opened in Office")
)
// tokenTTL ist die Lebensdauer eines Zugriffstokens.
//
// Zwölf Stunden klingt lang für ein Token – es ist aber die Zeit, die ein
// geöffnetes Dokument im Browser stehen darf, bevor das Speichern scheitert.
// Kürzer hieße: Wer ein Dokument über die Mittagspause offen lässt, verliert
// beim Zurückkommen seine Änderungen. Der Schaden bei einem entwendeten Token
// ist begrenzt, weil es **an genau eine Datei** gebunden ist.
const tokenTTL = 12 * time.Hour
// Access beschreibt, was ein Token erlaubt.
type Access struct {
FileID uuid.UUID
UserID uuid.UUID
Write bool
Expiry time.Time
}
// Signer stellt Zugriffstoken aus und prüft sie.
//
// HMAC über den Inhalt, nichts in der Datenbank: Ein Token ist damit
// überprüfbar, ohne bei jedem Zugriff nachzuschlagen – und es verfällt von
// selbst, statt eine Aufräumaufgabe zu brauchen.
type Signer struct {
key []byte
}
func NewSigner(secret string) *Signer {
// Abgeleitet statt direkt verwendet: Dasselbe Geheimnis signiert die
// Sitzungscookies. Zwei Zwecke sollen nie denselben Schlüssel teilen –
// sonst wäre ein Fehler im einen Verfahren einer im anderen.
sum := sha256.Sum256([]byte("aether-wopi\x00" + secret))
return &Signer{key: sum[:]}
}
// Issue stellt ein Token für genau diese Datei und diesen Nutzer aus.
func (s *Signer) Issue(fileID, userID uuid.UUID, write bool) (string, time.Time) {
exp := time.Now().Add(tokenTTL)
payload := make([]byte, 0, 41)
payload = append(payload, fileID[:]...)
payload = append(payload, userID[:]...)
var w byte
if write {
w = 1
}
payload = append(payload, w)
payload = binary.BigEndian.AppendUint64(payload, uint64(exp.Unix()))
mac := hmac.New(sha256.New, s.key)
mac.Write(payload)
full := append(payload, mac.Sum(nil)...)
return base64.RawURLEncoding.EncodeToString(full), exp
}
// Verify prüft ein Token und gibt zurück, was es erlaubt.
func (s *Signer) Verify(token string) (Access, error) {
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil || len(raw) != 41+sha256.Size {
return Access{}, ErrBadToken
}
payload, sig := raw[:41], raw[41:]
mac := hmac.New(sha256.New, s.key)
mac.Write(payload)
// Zeitkonstanter Vergleich: Ein normaler Vergleich verrät über die
// Laufzeit, wie viele Bytes stimmen – daraus lässt sich eine Signatur
// Byte für Byte erraten.
if !hmac.Equal(sig, mac.Sum(nil)) {
return Access{}, ErrBadToken
}
var a Access
copy(a.FileID[:], payload[0:16])
copy(a.UserID[:], payload[16:32])
a.Write = payload[32] == 1
a.Expiry = time.Unix(int64(binary.BigEndian.Uint64(payload[33:41])), 0)
if time.Now().After(a.Expiry) {
return Access{}, ErrExpired
}
return a, nil
}
// editableExtensions sind die Endungen, die ein Office-Server bearbeiten kann.
//
// Eine **Positivliste**, keine Ausschlussliste: Was hier nicht steht, wird
// nicht an den Office-Server gereicht. Ein PDF gehört bewusst nicht dazu – es
// hat seinen eigenen Betrachter, und ein Office-Server würde daraus beim
// Speichern ein anderes Dokument machen.
var editableExtensions = map[string]bool{
// Textdokumente
"odt": true, "docx": true, "doc": true, "rtf": true, "txt": true,
// Tabellen
"ods": true, "xlsx": true, "xls": true, "csv": true,
// Präsentationen
"odp": true, "pptx": true, "ppt": true,
// Zeichnungen
"odg": true,
}
// Editable sagt, ob eine Datei im Office-Server geöffnet werden kann.
func Editable(name string) bool {
i := strings.LastIndexByte(name, '.')
if i < 0 {
return false
}
return editableExtensions[strings.ToLower(name[i+1:])]
}
// Extension liefert die Endung in Kleinbuchstaben, mit Punkt – so, wie die
// Ermittlungsliste des Office-Servers sie führt.
func Extension(name string) string {
i := strings.LastIndexByte(name, '.')
if i < 0 {
return ""
}
return strings.ToLower(name[i:])
}
// ProviderName ist der Anzeigename für die Oberfläche.
func ProviderName(provider string) string {
switch provider {
case "collabora":
return "Collabora Online"
case "onlyoffice":
return "OnlyOffice"
default:
return fmt.Sprintf("Office (%s)", provider)
}
}