]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Merge branch 'develop' into shorter-URLs-channels-accounts
authorChocobozzz <me@florianbigard.com>
Fri, 28 May 2021 07:10:57 +0000 (09:10 +0200)
committerChocobozzz <me@florianbigard.com>
Fri, 28 May 2021 07:10:57 +0000 (09:10 +0200)
36 files changed:
client/src/app/+accounts/account-video-channels/account-video-channels.component.ts
client/src/app/+admin/users/user-edit/user-edit.component.html
client/src/app/+admin/users/user-list/user-list.component.html
client/src/app/+my-library/+my-video-channels/my-video-channels.component.html
client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html
client/src/app/+remote-interaction/remote-interaction.component.ts
client/src/app/+search/search.component.ts
client/src/app/+video-channels/video-channels.component.ts
client/src/app/+videos/+video-watch/comment/video-comment.component.html
client/src/app/+videos/+video-watch/video-avatar-channel.component.html
client/src/app/+videos/+video-watch/video-watch.component.html
client/src/app/+videos/video-list/overview/video-overview.component.html
client/src/app/app-routing.module.ts
client/src/app/menu/menu.component.html
client/src/app/root.component.ts [new file with mode: 0644]
client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts
client/src/app/shared/shared-main/account/actor.service.ts [new file with mode: 0644]
client/src/app/shared/shared-main/account/index.ts
client/src/app/shared/shared-main/shared-main.module.ts
client/src/app/shared/shared-main/users/user-notification.model.ts
client/src/app/shared/shared-video-comment/video-comment.model.ts
client/src/app/shared/shared-video-miniature/video-miniature.component.html
client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html
client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html
server/controllers/api/actor.ts [new file with mode: 0644]
server/controllers/api/index.ts
server/controllers/client.ts
server/helpers/custom-validators/actor.ts [new file with mode: 0644]
server/helpers/middlewares/video-channels.ts
server/lib/client-html.ts
server/middlewares/validators/actor.ts [new file with mode: 0644]
server/middlewares/validators/index.ts
server/tests/api/check-params/actors.ts [new file with mode: 0644]
server/tests/client.ts
shared/extra-utils/actors/actors.ts [new file with mode: 0644]
shared/extra-utils/index.ts

index 7e916e12284c4fdf2799c05c9de716fabc3aa73c..e146a5cd2c4075b53ab19d08f88faab160d7f3a9 100644 (file)
@@ -139,6 +139,6 @@ export class AccountVideoChannelsComponent implements OnInit, OnDestroy {
   }
 
   getVideoChannelLink (videoChannel: VideoChannel) {
-    return [ '/video-channels', videoChannel.nameWithHost ]
+    return [ '/c', videoChannel.nameWithHost ]
   }
 }
index 5e92c0f36a0bcbc943e94c243a605cec32e27383..772ebf27214bdc1d37a70738c697ed65c3161188 100644 (file)
@@ -10,7 +10,7 @@
     <ng-container *ngIf="!isCreation()">
       <li class="breadcrumb-item active" i18n>Edit</li>
       <li class="breadcrumb-item active" aria-current="page">
-        <a *ngIf="user" [routerLink]="[ '/accounts', user?.username ]">{{ user?.username }}</a>
+        <a *ngIf="user" [routerLink]="[ '/a', user?.username ]">{{ user?.username }}</a>
       </li>
     </ng-container>
   </ol>
index 44d8a7e87c7f16f618967578d543c512da9a819a..5b4f35c772e33d62bb62b918906fbe92e20debef 100644 (file)
@@ -87,7 +87,7 @@
       </td>
 
       <td *ngIf="isSelected('username')">
-        <a i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer" [routerLink]="[ '/accounts/' + user.username ]">
+        <a i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer" [routerLink]="[ '/a/' + user.username ]">
           <div class="chip two-lines">
             <my-actor-avatar [account]="user?.account" size="32"></my-actor-avatar>
            <div>
index e41cbe921f3720eade830521f999bc591546f7ef..9f139b4f2922779802284fb1c9ef94b7419b8362 100644 (file)
 
 <div class="video-channels">
   <div *ngFor="let videoChannel of videoChannels; let i = index" class="video-channel">
-    <my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/video-channels', videoChannel.nameWithHost ]"></my-actor-avatar>
+    <my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/c', videoChannel.nameWithHost ]"></my-actor-avatar>
 
     <div class="video-channel-info">
-      <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]" class="video-channel-names" i18n-title title="Channel page">
+      <a [routerLink]="[ '/c', videoChannel.nameWithHost ]" class="video-channel-names" i18n-title title="Channel page">
         <div class="video-channel-display-name">{{ videoChannel.displayName }}</div>
         <div class="video-channel-name">{{ videoChannel.nameWithHost }}</div>
       </a>
index f91cebacfbbf1a3e5904526f85837d1aebbdacdf..1bd459059b80e5f39e68f8d403b7537e8c98b5ad 100644 (file)
 
 <div class="video-channels" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
   <div *ngFor="let videoChannel of videoChannels" class="video-channel">
-    <my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/video-channels', videoChannel.nameWithHost ]"></my-actor-avatar>
+    <my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/c', videoChannel.nameWithHost ]"></my-actor-avatar>
 
     <div class="video-channel-info">
-      <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]" class="video-channel-names" i18n-title title="Channel page">
+      <a [routerLink]="[ '/c', videoChannel.nameWithHost ]" class="video-channel-names" i18n-title title="Channel page">
         <div class="video-channel-display-name">{{ videoChannel.displayName }}</div>
         <div class="video-channel-name">{{ videoChannel.nameWithHost }}</div>
       </a>
 
       <div i18n class="video-channel-followers">{{ videoChannel.followersCount }} subscribers</div>
 
-      <a [routerLink]="[ '/accounts', videoChannel.ownerBy ]" i18n-title title="Owner account page" class="actor-owner">
+      <a [routerLink]="[ '/a', videoChannel.ownerBy ]" i18n-title title="Owner account page" class="actor-owner">
         <span i18n>Created by {{ videoChannel.ownerBy }}</span>
 
         <my-actor-avatar [account]="videoChannel.ownerAccount" size="18"></my-actor-avatar>
index e24607b24c3e94eb5ec86b0b13a58faabf44caea..3ebe62f49e9120034e6ec8686ae46b359dd6ceff 100644 (file)
@@ -43,7 +43,7 @@ export class RemoteInteractionComponent implements OnInit {
       } else if (channelResult.data.length !== 0) {
         const channel = new VideoChannel(channelResult.data[0])
 
-        redirectUrl = '/video-channels/' + channel.nameWithHost
+        redirectUrl = '/c/' + channel.nameWithHost
       } else {
         this.error = $localize`Cannot access to the remote resource`
         return
index dcf654b7a52ddc4b6b907a0089e384bc0ab99bfd..4381659e1f09bbd2003e337c2bc54a26953e7325 100644 (file)
@@ -213,7 +213,7 @@ export class SearchComponent implements OnInit, OnDestroy {
     const linkType = this.getVideoLinkType()
 
     if (linkType === 'internal') {
-      return [ '/video-channels', channel.nameWithHost ]
+      return [ '/c', channel.nameWithHost ]
     }
 
     if (linkType === 'lazy-load') {
index 41fdb5e799e2f44fa9a5c11fa1e31a900924be29..3833d9c542c03a0509020d5e3c76b6b6914e0615 100644 (file)
@@ -112,7 +112,7 @@ export class VideoChannelsComponent implements OnInit, OnDestroy {
   }
 
   getAccountUrl () {
-    return [ '/accounts', this.videoChannel.ownerBy ]
+    return [ '/a', this.videoChannel.ownerBy ]
   }
 
   private loadChannelVideosCount () {
index d7ba40ef6cce432d8d3953c4d7e0e429860839b9..fc0d66ffd285695f329c56278794c721f2246f58 100644 (file)
@@ -11,7 +11,7 @@
 
         <div class="comment-account-date">
           <div class="comment-account">
-            <a [routerLink]="[ '/accounts', comment.by ]">
+            <a [routerLink]="[ '/a', comment.by ]">
               <span class="comment-account-name" [ngClass]="{ 'video-author': video.account.id === comment.account.id }">
                 {{ comment.account.displayName }}
               </span>
index 5f149cbd1155a3372a0bf3dc452e86ebe06dc550..5a722185848a2ed11ed2e25a574c0890ef0ded76 100644 (file)
@@ -1,11 +1,11 @@
 <div class="wrapper" [ngClass]="{ 'generic-channel': genericChannel }">
   <my-actor-avatar
     class="channel" [channel]="video.channel"
-    [internalHref]="[ '/video-channels', video.byVideoChannel ]" [title]="channelLinkTitle"
+    [internalHref]="[ '/c', video.byVideoChannel ]" [title]="channelLinkTitle"
   ></my-actor-avatar>
 
   <my-actor-avatar
     class="account" [account]="video.account"
-    [internalHref]="[ '/accounts', video.byAccount ]" [title]="accountLinkTitle">
+    [internalHref]="[ '/a', video.byAccount ]" [title]="accountLinkTitle">
   </my-actor-avatar>
 </div>
index 4779602d2422149fea89136ffd145a07eccb6074..bb41fba779792e2b305ee6142bff07075ca002bd 100644 (file)
 
               <div class="video-info-channel-left-links ml-1">
                 <ng-container *ngIf="!isChannelDisplayNameGeneric()">
-                  <a [routerLink]="[ '/video-channels', video.byVideoChannel ]" i18n-title title="Channel page">
+                  <a [routerLink]="[ '/c', video.byVideoChannel ]" i18n-title title="Channel page">
                     {{ video.channel.displayName }}
                   </a>
-                  <a [routerLink]="[ '/accounts', video.byAccount ]" i18n-title title="Account page">
+                  <a [routerLink]="[ '/a', video.byAccount ]" i18n-title title="Account page">
                     <span i18n>By {{ video.byAccount }}</span>
                   </a>
                 </ng-container>
 
                 <ng-container *ngIf="isChannelDisplayNameGeneric()">
-                  <a [routerLink]="[ '/accounts', video.byAccount ]" class="single-link" i18n-title title="Account page">
+                  <a [routerLink]="[ '/a', video.byAccount ]" class="single-link" i18n-title title="Account page">
                     <span i18n>{{ video.byAccount }}</span>
                   </a>
                 </ng-container>
index e21bffb6c5ad9ebe04b696f13c14ce1043ec08c0..d3c602aa583a03fa87060c83505892d423036479 100644 (file)
@@ -32,7 +32,7 @@
 
       <div class="section channel videos" *ngFor="let object of overview.channels">
         <div class="section-title">
-          <a [routerLink]="[ '/video-channels', buildVideoChannelBy(object) ]">
+          <a [routerLink]="[ '/c', buildVideoChannelBy(object) ]">
             <my-actor-avatar [channel]="buildVideoChannel(object)"></my-actor-avatar>
 
             <h2 class="section-title">{{ object.channel.displayName }}</h2>
index 4e3cce590d1b3f795483649cbd7fb5a93bebc849..4619c404642020344baa4c4d0cb15447ba839890 100644 (file)
@@ -1,10 +1,11 @@
 import { NgModule } from '@angular/core'
-import { RouteReuseStrategy, RouterModule, Routes } from '@angular/router'
+import { RouteReuseStrategy, RouterModule, Routes, UrlMatchResult, UrlSegment } from '@angular/router'
 import { CustomReuseStrategy } from '@app/core/routing/custom-reuse-strategy'
 import { MenuGuards } from '@app/core/routing/menu-guard.service'
 import { POSSIBLE_LOCALES } from '@shared/core-utils/i18n'
 import { MetaGuard, PreloadSelectedModulesList } from './core'
 import { EmptyComponent } from './empty.component'
+import { RootComponent } from './root.component'
 
 const routes: Routes = [
   {
@@ -34,12 +35,12 @@ const routes: Routes = [
     canActivateChild: [ MetaGuard ]
   },
   {
-    path: 'accounts',
+    path: 'a',
     loadChildren: () => import('./+accounts/accounts.module').then(m => m.AccountsModule),
     canActivateChild: [ MetaGuard ]
   },
   {
-    path: 'video-channels',
+    path: 'c',
     loadChildren: () => import('./+video-channels/video-channels.module').then(m => m.VideoChannelsModule),
     canActivateChild: [ MetaGuard ]
   },
@@ -82,6 +83,30 @@ const routes: Routes = [
     path: 'video-playlists/watch',
     redirectTo: 'videos/watch/playlist'
   },
+  {
+    path: 'accounts',
+    redirectTo: 'a'
+  },
+  {
+    path: 'video-channels',
+    redirectTo: 'c'
+  },
+  {
+    matcher: (url): UrlMatchResult => {
+      // Matches /@:actorName
+      if (url.length === 1 && url[0].path.match(/^@[\w]+$/gm)) {
+        return {
+          consumed: url,
+          posParams: {
+            actorName: new UrlSegment(url[0].path.substr(1), {})
+          }
+        }
+      }
+
+      return null
+    },
+    component: RootComponent
+  },
   {
     path: '',
     component: EmptyComponent // Avoid 404, app component will redirect dynamically
index fcc0bc21a891b772cafc2ec96fb2c186755697a4..2c2c4f260d4ab618c66eebedc21c1f1a82f02805 100644 (file)
@@ -18,7 +18,7 @@
             </div>
 
             <div ngbDropdownMenu>
-              <a *ngIf="user.account" ngbDropdownItem ngbDropdownToggle class="dropdown-item" [routerLink]="[ '/accounts', user.account.nameWithHost ]"
+              <a *ngIf="user.account" ngbDropdownItem ngbDropdownToggle class="dropdown-item" [routerLink]="[ '/a', user.account.nameWithHost ]"
                 #profile (click)="onActiveLinkScrollToAnchor(profile)">
                 <my-global-icon iconName="go" aria-hidden="true"></my-global-icon> <ng-container i18n>Public profile</ng-container>
               </a>
diff --git a/client/src/app/root.component.ts b/client/src/app/root.component.ts
new file mode 100644 (file)
index 0000000..5a09e50
--- /dev/null
@@ -0,0 +1,44 @@
+import { Component, OnInit } from '@angular/core'
+import { catchError, distinctUntilChanged, map, switchMap } from 'rxjs/operators'
+import { ActivatedRoute, Router } from '@angular/router'
+import { RestExtractor } from '@app/core'
+import { ActorService } from '@app/shared/shared-main/account'
+import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
+
+@Component({
+  selector: 'my-root',
+  template: ''
+})
+export class RootComponent implements OnInit {
+  constructor (
+    private actorService: ActorService,
+    private route: ActivatedRoute,
+    private restExtractor: RestExtractor,
+    private router: Router
+  ) {
+  }
+
+  ngOnInit () {
+    this.route.params
+        .pipe(
+          map(params => params[ 'actorName' ]),
+          distinctUntilChanged(),
+          switchMap(actorName => this.actorService.getActorType(actorName)),
+          catchError(err => this.restExtractor.redirectTo404IfNotFound(err, 'other', [
+            HttpStatusCode.BAD_REQUEST_400,
+            HttpStatusCode.NOT_FOUND_404
+          ]))
+        )
+        .subscribe(actorType => {
+          const actorName = this.route.snapshot.params[ 'actorName' ]
+
+          if (actorType === 'Account') {
+            this.router.navigate([ `/a/${actorName}` ], { state: { type: 'others', obj: { status: 200 } }, skipLocationChange: true })
+          }
+
+          if (actorType === 'VideoChannel') {
+            this.router.navigate([ `/c/${actorName}` ], { state: { type: 'others', obj: { status: 200 } }, skipLocationChange: true })
+          }
+        })
+  }
+}
index 4dc2b4f10b65c3a0ce0afc9dfc05396537896e26..07b9dddba01335b3958322bc7014b3de348959f7 100644 (file)
@@ -124,7 +124,7 @@ export class AbuseListTableComponent extends RestTable implements OnInit {
   }
 
   getAccountUrl (abuse: ProcessedAbuse) {
-    return '/accounts/' + abuse.flaggedAccount.nameWithHost
+    return '/a/' + abuse.flaggedAccount.nameWithHost
   }
 
   getVideoEmbed (abuse: AdminAbuse) {
diff --git a/client/src/app/shared/shared-main/account/actor.service.ts b/client/src/app/shared/shared-main/account/actor.service.ts
new file mode 100644 (file)
index 0000000..464ed45
--- /dev/null
@@ -0,0 +1,37 @@
+import { Observable, ReplaySubject } from 'rxjs'
+import { catchError, map, tap } from 'rxjs/operators'
+import { HttpClient } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor } from '@app/core'
+import { Account as ServerAccount, VideoChannel as ServerVideoChannel } from '@shared/models'
+import { environment } from '../../../../environments/environment'
+
+type KeysOfUnion<T> = T extends T ? keyof T: never
+type ServerActor = KeysOfUnion<ServerAccount | ServerVideoChannel>
+
+@Injectable()
+export class ActorService {
+  static BASE_ACTOR_API_URL = environment.apiUrl + '/api/v1/actors/'
+
+  actorLoaded = new ReplaySubject<string>(1)
+
+  constructor (
+    private authHttp: HttpClient,
+    private restExtractor: RestExtractor
+  ) {}
+
+  getActorType (actorName: string): Observable<string> {
+    return this.authHttp.get<ServerActor>(ActorService.BASE_ACTOR_API_URL + actorName)
+                .pipe(
+                  map(actorHash => {
+                    if (actorHash[ 'userId' ]) {
+                      return 'Account'
+                    }
+
+                    return 'VideoChannel'
+                  }),
+                  tap(actor => this.actorLoaded.next(actor)),
+                  catchError(res => this.restExtractor.handleError(res))
+                )
+  }
+}
index b80ddb9f532329b13c3ae9da047f030635368cef..c6cdcd574c980c54a5f6dd9aec4838a43a381546 100644 (file)
@@ -1,3 +1,4 @@
 export * from './account.model'
 export * from './account.service'
 export * from './actor.model'
+export * from './actor.service'
index f9b6085cf70e10468035d4fe7a877a6a4987f4e7..f06f25ca570543ea585937fcfb1c5531aac84051 100644 (file)
@@ -17,7 +17,7 @@ import {
 import { LoadingBarModule } from '@ngx-loading-bar/core'
 import { LoadingBarHttpClientModule } from '@ngx-loading-bar/http-client'
 import { SharedGlobalIconModule } from '../shared-icons'
-import { AccountService } from './account'
+import { AccountService, ActorService } from './account'
 import {
   AutofocusDirective,
   BytesPipe,
@@ -161,6 +161,7 @@ import { VideoChannelService } from './video-channel'
     AUTH_INTERCEPTOR_PROVIDER,
 
     AccountService,
+    ActorService,
 
     UserHistoryService,
     UserNotificationService,
index ed5791794d1a05e1c88a6207e2bc3b5c0def2fc5..002a01583b91c22cd810607ed78cee1804e4e849 100644 (file)
@@ -242,7 +242,7 @@ export class UserNotification implements UserNotificationServer {
   }
 
   private buildAccountUrl (account: { name: string, host: string }) {
-    return '/accounts/' + Actor.CREATE_BY_STRING(account.name, account.host)
+    return '/a/' + Actor.CREATE_BY_STRING(account.name, account.host)
   }
 
   private buildVideoImportUrl () {
index 9a4e3954e5b2bdcf6bc6ddd6c9e2d7d3a9a01e5b..1a2fe03db6a491c23c2f6afc4ffbf8efecdcbc2b 100644 (file)
@@ -95,7 +95,7 @@ export class VideoCommentAdmin implements VideoCommentAdminServerModel {
     if (this.account) {
       this.by = Actor.CREATE_BY_STRING(this.account.name, this.account.host)
 
-      this.account.localUrl = '/accounts/' + this.by
+      this.account.localUrl = '/a/' + this.by
     }
   }
 }
index 645be92bd3d4336dc8f64b4866e7df224e6abc8c..6c34123ed3cce483a07ba54fd91662cb81367fb0 100644 (file)
       <div class="d-flex video-miniature-meta">
         <my-actor-avatar
           *ngIf="displayOptions.avatar && displayOwnerVideoChannel()" [title]="channelLinkTitle"
-          [channel]="video.channel" [size]="actorImageSize" [internalHref]="[ '/video-channels', video.byVideoChannel ]"
+          [channel]="video.channel" [size]="actorImageSize" [internalHref]="[ '/c', video.byVideoChannel ]"
         ></my-actor-avatar>
 
         <my-actor-avatar
           *ngIf="displayOptions.avatar && displayOwnerAccount()" [title]="channelLinkTitle"
-          [account]="video.account" [size]="actorImageSize" [internalHref]="[ '/video-channels', video.byVideoChannel ]"
+          [account]="video.account" [size]="actorImageSize" [internalHref]="[ '/c', video.byVideoChannel ]"
         ></my-actor-avatar>
 
         <div class="w-100 d-flex flex-column">
             </span>
           </span>
 
-          <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerAccount()" class="video-miniature-account" [routerLink]="[ '/video-channels', video.byVideoChannel ]">
+          <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerAccount()" class="video-miniature-account" [routerLink]="[ '/c', video.byVideoChannel ]">
             {{ video.byAccount }}
           </a>
-          <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerVideoChannel()" class="video-miniature-channel" [routerLink]="[ '/video-channels', video.byVideoChannel ]">
+          <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerVideoChannel()" class="video-miniature-channel" [routerLink]="[ '/c', video.byVideoChannel ]">
             {{ video.byVideoChannel }}
           </a>
 
index ec004a407083b28473a3c73dbd3311a0f0380320..e74f58f4736b77639ea5a130739b96317518caac 100644 (file)
@@ -20,7 +20,7 @@
           [attr.title]="playlistElement.video.name"
         >{{ playlistElement.video.name }}</a>
 
-        <a *ngIf="accountLink" tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', playlistElement.video.byAccount ]">
+        <a *ngIf="accountLink" tabindex="-1" class="video-info-account" [routerLink]="[ '/a', playlistElement.video.byAccount ]">
           {{ playlistElement.video.byAccount }}
         </a>
         <span *ngIf="!accountLink" tabindex="-1" class="video-info-account">{{ playlistElement.video.byAccount }}</span>
index f50f950039b55bf4d4115df990420731109e1ace..81c36e6fe4cb5571b90420aa0b5b2a799722fd5f 100644 (file)
@@ -19,7 +19,7 @@
       {{ playlist.displayName }}
     </a>
 
-    <a i18n [routerLink]="[ '/video-channels', playlist.videoChannelBy ]" class="by" *ngIf="displayChannel && playlist.videoChannelBy">
+    <a i18n [routerLink]="[ '/c', playlist.videoChannelBy ]" class="by" *ngIf="displayChannel && playlist.videoChannelBy">
       {{ playlist.videoChannelBy }}
     </a>
 
diff --git a/server/controllers/api/actor.ts b/server/controllers/api/actor.ts
new file mode 100644 (file)
index 0000000..da7f2eb
--- /dev/null
@@ -0,0 +1,37 @@
+import * as express from 'express'
+import { JobQueue } from '../../lib/job-queue'
+import { asyncMiddleware } from '../../middlewares'
+import { actorNameWithHostGetValidator } from '../../middlewares/validators'
+
+const actorRouter = express.Router()
+
+actorRouter.get('/:actorName',
+  asyncMiddleware(actorNameWithHostGetValidator),
+  getActor
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+  actorRouter
+}
+
+// ---------------------------------------------------------------------------
+
+function getActor (req: express.Request, res: express.Response) {
+  let accountOrVideoChannel
+
+  if (res.locals.account) {
+    accountOrVideoChannel = res.locals.account
+  }
+
+  if (res.locals.videoChannel) {
+    accountOrVideoChannel = res.locals.videoChannel
+  }
+
+  if (accountOrVideoChannel.isOutdated()) {
+    JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'actor', url: accountOrVideoChannel.Actor.url } })
+  }
+
+  return res.json(accountOrVideoChannel.toFormattedJSON())
+}
index 28378654ab1175165b44de221f62614aaa37141c..9ffcf133783b6bd31b01b485ada7fc4ae5a3eef3 100644 (file)
@@ -16,6 +16,7 @@ import { pluginRouter } from './plugins'
 import { searchRouter } from './search'
 import { serverRouter } from './server'
 import { usersRouter } from './users'
+import { actorRouter } from './actor'
 import { videoChannelRouter } from './video-channel'
 import { videoPlaylistRouter } from './video-playlist'
 import { videosRouter } from './videos'
@@ -40,6 +41,7 @@ apiRouter.use('/bulk', bulkRouter)
 apiRouter.use('/oauth-clients', oauthClientsRouter)
 apiRouter.use('/config', configRouter)
 apiRouter.use('/users', usersRouter)
+apiRouter.use('/actors', actorRouter)
 apiRouter.use('/accounts', accountsRouter)
 apiRouter.use('/video-channels', videoChannelRouter)
 apiRouter.use('/video-playlists', videoPlaylistRouter)
index 022a17ff47c8f7921bdbde3d11bf5f38ba43f040..35e5af9d19ef7d6558777ebb799a4248b189a154 100644 (file)
@@ -21,8 +21,9 @@ const testEmbedPath = join(distPath, 'standalone', 'videos', 'test-embed.html')
 // Do not use a template engine for a so little thing
 clientsRouter.use('/videos/watch/playlist/:id', asyncMiddleware(generateWatchPlaylistHtmlPage))
 clientsRouter.use('/videos/watch/:id', asyncMiddleware(generateWatchHtmlPage))
-clientsRouter.use('/accounts/:nameWithHost', asyncMiddleware(generateAccountHtmlPage))
-clientsRouter.use('/video-channels/:nameWithHost', asyncMiddleware(generateVideoChannelHtmlPage))
+clientsRouter.use([ '/accounts/:nameWithHost', '/a/:nameWithHost' ], asyncMiddleware(generateAccountHtmlPage))
+clientsRouter.use([ '/video-channels/:nameWithHost', '/c/:nameWithHost' ], asyncMiddleware(generateVideoChannelHtmlPage))
+clientsRouter.use('/@:nameWithHost', asyncMiddleware(generateActorHtmlPage))
 
 const embedMiddlewares = [
   CONFIG.CSP.ENABLED
@@ -155,6 +156,12 @@ async function generateVideoChannelHtmlPage (req: express.Request, res: express.
   return sendHTML(html, res)
 }
 
+async function generateActorHtmlPage (req: express.Request, res: express.Response) {
+  const html = await ClientHtml.getActorHTMLPage(req.params.nameWithHost, req, res)
+
+  return sendHTML(html, res)
+}
+
 async function generateManifest (req: express.Request, res: express.Response) {
   const manifestPhysicalPath = join(root(), 'client', 'dist', 'manifest.webmanifest')
   const manifestJson = await readFile(manifestPhysicalPath, 'utf8')
diff --git a/server/helpers/custom-validators/actor.ts b/server/helpers/custom-validators/actor.ts
new file mode 100644 (file)
index 0000000..ad129e0
--- /dev/null
@@ -0,0 +1,10 @@
+import { isAccountNameValid } from './accounts'
+import { isVideoChannelNameValid } from './video-channels'
+
+function isActorNameValid (value: string) {
+  return isAccountNameValid(value) || isVideoChannelNameValid(value)
+}
+
+export {
+  isActorNameValid
+}
index e6eab65a28a0d43238bdc6b7ee9ccca2e6e82082..e30ea90b3628d39427c25a8514d279020eb6d3a9 100644 (file)
@@ -3,22 +3,22 @@ import { MChannelBannerAccountDefault } from '@server/types/models'
 import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
 import { VideoChannelModel } from '../../models/video/video-channel'
 
-async function doesLocalVideoChannelNameExist (name: string, res: express.Response) {
+async function doesLocalVideoChannelNameExist (name: string, res: express.Response, sendNotFound = true) {
   const videoChannel = await VideoChannelModel.loadLocalByNameAndPopulateAccount(name)
 
-  return processVideoChannelExist(videoChannel, res)
+  return processVideoChannelExist(videoChannel, res, sendNotFound)
 }
 
-async function doesVideoChannelIdExist (id: number, res: express.Response) {
+async function doesVideoChannelIdExist (id: number, res: express.Response, sendNotFound = true) {
   const videoChannel = await VideoChannelModel.loadAndPopulateAccount(+id)
 
-  return processVideoChannelExist(videoChannel, res)
+  return processVideoChannelExist(videoChannel, res, sendNotFound)
 }
 
-async function doesVideoChannelNameWithHostExist (nameWithDomain: string, res: express.Response) {
+async function doesVideoChannelNameWithHostExist (nameWithDomain: string, res: express.Response, sendNotFound = true) {
   const videoChannel = await VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithDomain)
 
-  return processVideoChannelExist(videoChannel, res)
+  return processVideoChannelExist(videoChannel, res, sendNotFound)
 }
 
 // ---------------------------------------------------------------------------
@@ -29,10 +29,12 @@ export {
   doesVideoChannelNameWithHostExist
 }
 
-function processVideoChannelExist (videoChannel: MChannelBannerAccountDefault, res: express.Response) {
+function processVideoChannelExist (videoChannel: MChannelBannerAccountDefault, res: express.Response, sendNotFound = true) {
   if (!videoChannel) {
-    res.status(HttpStatusCode.NOT_FOUND_404)
-       .json({ error: 'Video channel not found' })
+    if (sendNotFound) {
+      res.status(HttpStatusCode.NOT_FOUND_404)
+        .json({ error: 'Video channel not found' })
+    }
 
     return false
   }
index 4b2968e8bd4ccecdfc6c841710106b5247209cce..2f6bce1c77620591a2c4b47ad6f2c196eee97926 100644 (file)
@@ -198,11 +198,24 @@ class ClientHtml {
   }
 
   static async getAccountHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
-    return this.getAccountOrChannelHTMLPage(() => AccountModel.loadByNameWithHost(nameWithHost), req, res)
+    const accountModelPromise = AccountModel.loadByNameWithHost(nameWithHost)
+    return this.getAccountOrChannelHTMLPage(() => accountModelPromise, req, res)
   }
 
   static async getVideoChannelHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
-    return this.getAccountOrChannelHTMLPage(() => VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost), req, res)
+    const videoChannelModelPromise = VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost)
+    return this.getAccountOrChannelHTMLPage(() => videoChannelModelPromise, req, res)
+  }
+
+  static async getActorHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
+    const accountModel = await AccountModel.loadByNameWithHost(nameWithHost)
+
+    if (accountModel) {
+      return this.getAccountOrChannelHTMLPage(() => new Promise(resolve => resolve(accountModel)), req, res)
+    } else {
+      const videoChannelModelPromise = VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost)
+      return this.getAccountOrChannelHTMLPage(() => videoChannelModelPromise, req, res)
+    }
   }
 
   static async getEmbedHTML () {
diff --git a/server/middlewares/validators/actor.ts b/server/middlewares/validators/actor.ts
new file mode 100644 (file)
index 0000000..99b529d
--- /dev/null
@@ -0,0 +1,59 @@
+import * as express from 'express'
+import { param } from 'express-validator'
+import { isActorNameValid } from '../../helpers/custom-validators/actor'
+import { logger } from '../../helpers/logger'
+import { areValidationErrors } from './utils'
+import {
+  doesAccountNameWithHostExist,
+  doesLocalAccountNameExist,
+  doesVideoChannelNameWithHostExist,
+  doesLocalVideoChannelNameExist
+} from '../../helpers/middlewares'
+import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
+
+const localActorValidator = [
+  param('actorName').custom(isActorNameValid).withMessage('Should have a valid actor name'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking localActorValidator parameters', { parameters: req.params })
+
+    if (areValidationErrors(req, res)) return
+
+    const isAccount = await doesLocalAccountNameExist(req.params.actorName, res, false)
+    const isVideoChannel = await doesLocalVideoChannelNameExist(req.params.actorName, res, false)
+
+    if (!isAccount || !isVideoChannel) {
+      res.status(HttpStatusCode.NOT_FOUND_404)
+         .json({ error: 'Actor not found' })
+    }
+
+    return next()
+  }
+]
+
+const actorNameWithHostGetValidator = [
+  param('actorName').exists().withMessage('Should have an actor name with host'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking actorNameWithHostGetValidator parameters', { parameters: req.params })
+
+    if (areValidationErrors(req, res)) return
+
+    const isAccount = await doesAccountNameWithHostExist(req.params.actorName, res, false)
+    const isVideoChannel = await doesVideoChannelNameWithHostExist(req.params.actorName, res, false)
+
+    if (!isAccount && !isVideoChannel) {
+      res.status(HttpStatusCode.NOT_FOUND_404)
+         .json({ error: 'Actor not found' })
+    }
+
+    return next()
+  }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+  localActorValidator,
+  actorNameWithHostGetValidator
+}
index 24faeea3efdb272d583c3c5186693cf7dfddbac2..3e1a1e5ce94dbed402e9726b9862389ce180874d 100644 (file)
@@ -1,5 +1,6 @@
 export * from './abuse'
 export * from './account'
+export * from './actor'
 export * from './actor-image'
 export * from './blocklist'
 export * from './oembed'
diff --git a/server/tests/api/check-params/actors.ts b/server/tests/api/check-params/actors.ts
new file mode 100644 (file)
index 0000000..3a03edc
--- /dev/null
@@ -0,0 +1,37 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import 'mocha'
+
+import { cleanupTests, flushAndRunServer, ServerInfo } from '../../../../shared/extra-utils'
+import { getActor } from '../../../../shared/extra-utils/actors/actors'
+import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
+
+describe('Test actors API validators', function () {
+  let server: ServerInfo
+
+  // ---------------------------------------------------------------
+
+  before(async function () {
+    this.timeout(30000)
+
+    server = await flushAndRunServer(1)
+  })
+
+  describe('When getting an actor', function () {
+    it('Should return 404 with a non existing actorName', async function () {
+      await getActor(server.url, 'arfaze', HttpStatusCode.NOT_FOUND_404)
+    })
+
+    it('Should return 200 with an existing accountName', async function () {
+      await getActor(server.url, 'root', HttpStatusCode.OK_200)
+    })
+
+    it('Should return 200 with an existing channelName', async function () {
+      await getActor(server.url, 'root_channel', HttpStatusCode.OK_200)
+    })
+  })
+
+  after(async function () {
+    await cleanupTests([ server ])
+  })
+})
index a385edd2681d3fb175e85420581729f0aa76f3eb..d9a472fddbaccca872e98fd4f161f258485e7d3f 100644 (file)
@@ -145,27 +145,51 @@ describe('Test a client controllers', function () {
   describe('Open Graph', function () {
 
     it('Should have valid Open Graph tags on the account page', async function () {
-      const res = await request(servers[0].url)
+      const accountPageTests = (res) => {
+        expect(res.text).to.contain(`<meta property="og:title" content="${account.displayName}" />`)
+        expect(res.text).to.contain(`<meta property="og:description" content="${account.description}" />`)
+        expect(res.text).to.contain('<meta property="og:type" content="website" />')
+        expect(res.text).to.contain(`<meta property="og:url" content="${servers[0].url}/accounts/${servers[0].user.username}" />`)
+      }
+
+      accountPageTests(await request(servers[0].url)
         .get('/accounts/' + servers[0].user.username)
         .set('Accept', 'text/html')
-        .expect(HttpStatusCode.OK_200)
+        .expect(HttpStatusCode.OK_200))
+
+      accountPageTests(await request(servers[0].url)
+        .get('/a/' + servers[0].user.username)
+        .set('Accept', 'text/html')
+        .expect(HttpStatusCode.OK_200))
 
-      expect(res.text).to.contain(`<meta property="og:title" content="${account.displayName}" />`)
-      expect(res.text).to.contain(`<meta property="og:description" content="${account.description}" />`)
-      expect(res.text).to.contain('<meta property="og:type" content="website" />')
-      expect(res.text).to.contain(`<meta property="og:url" content="${servers[0].url}/accounts/${servers[0].user.username}" />`)
+      accountPageTests(await request(servers[0].url)
+        .get('/@' + servers[0].user.username)
+        .set('Accept', 'text/html')
+        .expect(HttpStatusCode.OK_200))
     })
 
     it('Should have valid Open Graph tags on the channel page', async function () {
-      const res = await request(servers[0].url)
+      const channelPageOGtests = (res) => {
+        expect(res.text).to.contain(`<meta property="og:title" content="${servers[0].videoChannel.displayName}" />`)
+        expect(res.text).to.contain(`<meta property="og:description" content="${channelDescription}" />`)
+        expect(res.text).to.contain('<meta property="og:type" content="website" />')
+        expect(res.text).to.contain(`<meta property="og:url" content="${servers[0].url}/video-channels/${servers[0].videoChannel.name}" />`)
+      }
+
+      channelPageOGtests(await request(servers[0].url)
         .get('/video-channels/' + servers[0].videoChannel.name)
         .set('Accept', 'text/html')
-        .expect(HttpStatusCode.OK_200)
+        .expect(HttpStatusCode.OK_200))
+
+      channelPageOGtests(await request(servers[0].url)
+        .get('/c/' + servers[0].videoChannel.name)
+        .set('Accept', 'text/html')
+        .expect(HttpStatusCode.OK_200))
 
-      expect(res.text).to.contain(`<meta property="og:title" content="${servers[0].videoChannel.displayName}" />`)
-      expect(res.text).to.contain(`<meta property="og:description" content="${channelDescription}" />`)
-      expect(res.text).to.contain('<meta property="og:type" content="website" />')
-      expect(res.text).to.contain(`<meta property="og:url" content="${servers[0].url}/video-channels/${servers[0].videoChannel.name}" />`)
+      channelPageOGtests(await request(servers[0].url)
+        .get('/@' + servers[0].videoChannel.name)
+        .set('Accept', 'text/html')
+        .expect(HttpStatusCode.OK_200))
     })
 
     it('Should have valid Open Graph tags on the watch page with video id', async function () {
@@ -232,27 +256,51 @@ describe('Test a client controllers', function () {
     })
 
     it('Should have valid twitter card on the account page', async function () {
-      const res = await request(servers[0].url)
+      const accountPageTests = (res) => {
+        expect(res.text).to.contain('<meta property="twitter:card" content="summary" />')
+        expect(res.text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
+        expect(res.text).to.contain(`<meta property="twitter:title" content="${account.name}" />`)
+        expect(res.text).to.contain(`<meta property="twitter:description" content="${account.description}" />`)
+      }
+
+      accountPageTests(await request(servers[0].url)
         .get('/accounts/' + account.name)
         .set('Accept', 'text/html')
-        .expect(HttpStatusCode.OK_200)
+        .expect(HttpStatusCode.OK_200))
 
-      expect(res.text).to.contain('<meta property="twitter:card" content="summary" />')
-      expect(res.text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
-      expect(res.text).to.contain(`<meta property="twitter:title" content="${account.name}" />`)
-      expect(res.text).to.contain(`<meta property="twitter:description" content="${account.description}" />`)
+      accountPageTests(await request(servers[0].url)
+        .get('/a/' + account.name)
+        .set('Accept', 'text/html')
+        .expect(HttpStatusCode.OK_200))
+
+      accountPageTests(await request(servers[0].url)
+        .get('/@' + account.name)
+        .set('Accept', 'text/html')
+        .expect(HttpStatusCode.OK_200))
     })
 
     it('Should have valid twitter card on the channel page', async function () {
-      const res = await request(servers[0].url)
+      const channelPageTests = (res) => {
+        expect(res.text).to.contain('<meta property="twitter:card" content="summary" />')
+        expect(res.text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
+        expect(res.text).to.contain(`<meta property="twitter:title" content="${servers[0].videoChannel.displayName}" />`)
+        expect(res.text).to.contain(`<meta property="twitter:description" content="${channelDescription}" />`)
+      }
+
+      channelPageTests(await request(servers[0].url)
         .get('/video-channels/' + servers[0].videoChannel.name)
         .set('Accept', 'text/html')
-        .expect(HttpStatusCode.OK_200)
+        .expect(HttpStatusCode.OK_200))
 
-      expect(res.text).to.contain('<meta property="twitter:card" content="summary" />')
-      expect(res.text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
-      expect(res.text).to.contain(`<meta property="twitter:title" content="${servers[0].videoChannel.displayName}" />`)
-      expect(res.text).to.contain(`<meta property="twitter:description" content="${channelDescription}" />`)
+      channelPageTests(await request(servers[0].url)
+        .get('/c/' + servers[0].videoChannel.name)
+        .set('Accept', 'text/html')
+        .expect(HttpStatusCode.OK_200))
+
+      channelPageTests(await request(servers[0].url)
+        .get('/@' + servers[0].videoChannel.name)
+        .set('Accept', 'text/html')
+        .expect(HttpStatusCode.OK_200))
     })
 
     it('Should have valid twitter card if Twitter is whitelisted', async function () {
@@ -280,21 +328,45 @@ describe('Test a client controllers', function () {
       expect(resVideoPlaylistRequest.text).to.contain('<meta property="twitter:card" content="player" />')
       expect(resVideoPlaylistRequest.text).to.contain('<meta property="twitter:site" content="@Kuja" />')
 
-      const resAccountRequest = await request(servers[0].url)
+      const accountTests = (res) => {
+        expect(res.text).to.contain('<meta property="twitter:card" content="summary" />')
+        expect(res.text).to.contain('<meta property="twitter:site" content="@Kuja" />')
+      }
+
+      accountTests(await request(servers[0].url)
         .get('/accounts/' + account.name)
         .set('Accept', 'text/html')
-        .expect(HttpStatusCode.OK_200)
+        .expect(HttpStatusCode.OK_200))
+
+      accountTests(await request(servers[0].url)
+        .get('/a/' + account.name)
+        .set('Accept', 'text/html')
+        .expect(HttpStatusCode.OK_200))
+
+      accountTests(await request(servers[0].url)
+        .get('/@' + account.name)
+        .set('Accept', 'text/html')
+        .expect(HttpStatusCode.OK_200))
 
-      expect(resAccountRequest.text).to.contain('<meta property="twitter:card" content="summary" />')
-      expect(resAccountRequest.text).to.contain('<meta property="twitter:site" content="@Kuja" />')
+      const channelTests = (res) => {
+        expect(res.text).to.contain('<meta property="twitter:card" content="summary" />')
+        expect(res.text).to.contain('<meta property="twitter:site" content="@Kuja" />')
+      }
 
-      const resChannelRequest = await request(servers[0].url)
+      channelTests(await request(servers[0].url)
         .get('/video-channels/' + servers[0].videoChannel.name)
         .set('Accept', 'text/html')
-        .expect(HttpStatusCode.OK_200)
+        .expect(HttpStatusCode.OK_200))
+
+      channelTests(await request(servers[0].url)
+        .get('/c/' + servers[0].videoChannel.name)
+        .set('Accept', 'text/html')
+        .expect(HttpStatusCode.OK_200))
 
-      expect(resChannelRequest.text).to.contain('<meta property="twitter:card" content="summary" />')
-      expect(resChannelRequest.text).to.contain('<meta property="twitter:site" content="@Kuja" />')
+      channelTests(await request(servers[0].url)
+        .get('/@' + servers[0].videoChannel.name)
+        .set('Accept', 'text/html')
+        .expect(HttpStatusCode.OK_200))
     })
   })
 
@@ -343,13 +415,23 @@ describe('Test a client controllers', function () {
     })
 
     it('Should use the original account URL for the canonical tag', async function () {
-      const res = await makeHTMLRequest(servers[1].url, '/accounts/root@' + servers[0].host)
-      expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/accounts/root" />`)
+      const accountURLtest = (res) => {
+        expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/accounts/root" />`)
+      }
+
+      accountURLtest(await makeHTMLRequest(servers[1].url, '/accounts/root@' + servers[0].host))
+      accountURLtest(await makeHTMLRequest(servers[1].url, '/a/root@' + servers[0].host))
+      accountURLtest(await makeHTMLRequest(servers[1].url, '/@root@' + servers[0].host))
     })
 
     it('Should use the original channel URL for the canonical tag', async function () {
-      const res = await makeHTMLRequest(servers[1].url, '/video-channels/root_channel@' + servers[0].host)
-      expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/video-channels/root_channel" />`)
+      const channelURLtests = (res) => {
+        expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/video-channels/root_channel" />`)
+      }
+
+      channelURLtests(await makeHTMLRequest(servers[1].url, '/video-channels/root_channel@' + servers[0].host))
+      channelURLtests(await makeHTMLRequest(servers[1].url, '/c/root_channel@' + servers[0].host))
+      channelURLtests(await makeHTMLRequest(servers[1].url, '/@root_channel@' + servers[0].host))
     })
 
     it('Should use the original playlist URL for the canonical tag', async function () {
diff --git a/shared/extra-utils/actors/actors.ts b/shared/extra-utils/actors/actors.ts
new file mode 100644 (file)
index 0000000..4a4aba7
--- /dev/null
@@ -0,0 +1,18 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { makeGetRequest } from '../requests/requests'
+import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
+
+function getActor (url: string, actorName: string, statusCodeExpected = HttpStatusCode.OK_200) {
+  const path = '/api/v1/actors/' + actorName
+
+  return makeGetRequest({
+    url,
+    path,
+    statusCodeExpected
+  })
+}
+
+export {
+  getActor
+}
index 3bc09ead587a11a173a17a260629bb9dc9d8ab3e..9f5b5bb28997f00e97e3c0a7533f82da55e2ba6e 100644 (file)
@@ -1,3 +1,4 @@
+export * from './actors/actors'
 export * from './bulk/bulk'
 
 export * from './cli/cli'